jkunz ef69fb5e90 fix(services): pull a missing image before cleaning container-owned data
`gitzone services clean`, `gitzone services prune` and the lifecycle
rollback remove data owned by a container's user from inside a container.
When that service's image was missing, which is the case whenever an
earlier gitzone release wrote the data, they refused and left the deletion
half-finished, so `gitzone services start` refused until the image was
pulled by hand. removeDataDirectory now pulls the image through
DockerContainer.ensureImagePresent.

The legacy MongoDB migration's private pull helper is folded into the same
method, and a migration test asserts both images are present before the
legacy container is stopped.
2026-10-22 21:45:46 +00:00
…
2026-10-21 13:07:21 +00:00
…

@git.zone/cli 🚀

@git.zone/cli is the development workflow CLI behind the gitzone and gzone commands. It helps TypeScript-heavy teams keep projects tidy, create semantic source commits, manage local Docker-backed services, scaffold new modules, and release software through explicit, target-based release configuration.

It is opinionated where that saves time: source commits and releases are separate, changelog entries flow through a standard Pending section, project config lives in .smartconfig.json, and release targets make side effects visible before they happen.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add -g @git.zone/cli

After installation, both binaries point to the same CLI:

gitzone --help
gzone --help

The Big Idea

gitzone commit handles source history.

gitzone release handles release transactions.

That split is intentional. A commit should not unexpectedly publish npm packages, push Docker images, or trigger remote release pipelines. A release should clearly show which targets it will publish to.

Quick Start

# Preview project standardization work
gitzone format

# Apply formatting changes
gitzone format --write

# Create a semantic source commit
gitzone commit

# Preview the configured release transaction
gitzone release --plan

# Release pending changelog entries to configured targets
gitzone release

Commands

Command Purpose
commit Analyze changes and create one semantic source commit
release Turn pending changelog entries into a versioned release and publish targets
format Plan or apply project formatting and standardization
config Inspect, update, and migrate .smartconfig.json
services Manage local MongoDB, ObjectStorage, and Elasticsearch containers
tools Manage the global @git.zone toolchain
template Scaffold projects from built-in templates
meta Manage multi-repository workspaces
open Open repository assets like CI pages
docker Report and reclaim Docker resources created by git.zone tooling
deprecate Deprecate npm packages across registries
start Prepare an existing project for local work
helpers Run small helper utilities

Global flags include --help, --json, --plain, --agent, --no-interactive, and --no-check-updates.

Toolchain Management

gitzone tools replaces the former gtools command from @git.zone/tools. It manages globally installed @git.zone development tools through pnpm.

# Check installed @git.zone tools and update outdated packages
gitzone tools update

# Update without prompts
gitzone tools update -y

# Install missing managed @git.zone tools
gitzone tools install

The managed tools include @git.zone/tsrepo and @git.zone/rsrepo, not the retired single tools they replace (tsbuild, tstest, tsbundle, tswatch, tsdoc, tspublish, tsrust), which gitzone tools neither installs nor updates; a global rsrepo is the engine a Rust project's release finds on the PATH when its package.json does not declare one.

gitzone tools update checks @git.zone/cli first. If the CLI itself needs an update, it updates @git.zone/cli and asks you to rerun the command before updating the rest of the toolchain.

Commit Workflow

gitzone commit creates one semantic source commit. It does not bump versions, create tags, publish packages, or push Docker images.

# Interactive semantic commit
gitzone commit

# Read-only AI recommendation
gitzone commit recommend --json

# Auto-accept safe recommendations
gitzone commit -y

# Auto-accept a breaking recommendation explicitly
gitzone commit -y --allow-breaking

# Auto-accept, test, build, and push
gitzone commit -ytbp

# Show the resolved workflow without mutating anything
gitzone commit --plan

# Supply a message and changelog entry without AI
gitzone commit -y --message 'fix(cache): expire stale entries' --changelog 'Expired entries are removed before lookup.'

# Preserve a multiline message and custom Pending Markdown
gitzone commit -y --message-file /tmp/commit.txt --changelog-file /tmp/pending.md

The commit flow:

  1. Analyze the working tree.
  2. Suggest commit type, scope, and message.
  3. Write a human-readable entry into changelog.md under ## Pending.
  4. Stage and create one semantic source commit.
  5. Optionally run formatting, tests, build, and push based on flags or config.

Commit flags:

Flag Meaning
-y, --yes Auto-accept safe recommendations
--allow-breaking Allow -y/--yes to accept BREAKING CHANGE recommendations
-t, --test Add test step
-b, --build Add build step
-p, --push Push after the source commit
-f, --format Run gitzone format --write before commit
--plan Show resolved workflow only
-m, --message <text> Use a complete semantic commit message without AI
--message-file <path> Read the complete message from a UTF-8 file
--changelog <text> Supply custom Pending text with a manual message
--changelog-file <path> Read custom Pending text from a UTF-8 file

Manual messages start with type(scope): description; the scope is optional. Conventional ! subjects and BREAKING CHANGE: footers are supported. The entire message, including its body, is passed literally to Git. File inputs preserve multiline text; CRLF becomes LF and terminal newlines are normalized. Each input is limited to 128 KiB. Use --message or --message-file once, and at most one changelog option. Changelog overrides require a manual message.

Without a changelog override, GitZone derives the Pending entry from the semantic subject and body. Custom text becomes an entry in the message's semantic bucket. For complete Markdown, supply a Pending fragment such as:

### Fixes

- Remove expired entries before lookup.
  - Preserve entries whose validity has not expired.

Fragments may contain ### Breaking Changes, ### Features, ### Fixes, ### Documentation, and ### Maintenance. Omit the document title, ## Pending, and version headings. GitZone merges entries into the existing Pending buckets and preserves previous entries and release history. Pending headings continue to determine the release bump; a breaking message requires a Breaking Changes entry, and either a breaking message or new breaking changelog text requires the usual interactive confirmation or -y --allow-breaking.

Manual mode replaces the AI analysis step with manual. It keeps the configured format, test, build, staging and push behavior. --plan validates and displays the message and changelog without mutation or AI access. A clean repository remains unchanged. Input is validated before workflow steps run; it never falls back to AI after an input error. Place input files outside the repository if they should not be included by the existing stage-all workflow.

-r is intentionally not part of commit anymore. Use gitzone release.

Release Workflow

gitzone release releases from main. Running it on another branch fails before release metadata or ref mutation unless the bare --merge flag is explicitly supplied, or the branch carries a release.line configuration that names it (see Maintenance lines).

--merge is intentionally strict: the source and existing main worktree must be clean, the source must be linearly rebased onto current remote main, and the Git target must push both the branch and tag. GitZone pins the single configured push URL, compare-and-swap fast-forwards local main, updates its verified worktree, and lease-pushes that exact source commit before creating release metadata. It rejects diverged or merged history, local replacement refs or grafts, stale worktrees, incomplete shallow history, changed destinations, and remote races. If the pre-release push fails, GitZone restores local main only when doing so cannot overwrite concurrent work. This plumbing-level integration intentionally does not run merge hooks or write ORIG_HEAD.

gitzone release performs the release core once, then publishes to configured targets. When Docker is active, its immutable candidate must qualify before any Git, npm, or OCI destination publication. An active Git target must then succeed and be verified before npm or OCI publication can start.

Before interactive confirmation or source mutation, a Docker release verifies the project-local tSDocker capabilities and validates the deterministic request against the source commit. After the release commit and configured build, GitZone validates the final request against the release commit immediately before atomically installing the schema-2 journal.

The release core is not configurable plumbing. It always follows the same professional release transaction:

  1. Verify the release branch, clean state, and worktree ownership of main (or of the maintenance branch a release line names). A project that configures packageFiles must carry exactly the files list it generates (step preflight.packageFiles, see Package contents). A release that builds or packs a commit with a package.json also proves here that the source commit carries a pnpm-lock.yaml its checkout can install with --frozen-lockfile. An npm release then reads every registry's dist-tags for every package it publishes: latest never moves downwards, and a line takes latest only while every higher major is a prerelease (step preflight.npmDistTags, see A line that keeps latest).
  2. Read changelog.md ## Pending entries and infer or accept a semver bump, raised to release.versionFloor when that is higher.
  3. Run configured tests.
  4. With --merge, fast-forward and lease-push main before release metadata is created.
  5. Update the version through the project's version source (package.json, or the Cargo workspace of a projectType: "rust" project, see Rust workspaces and the cargo target) and, in TypeScript projects, the baked commit info.
  6. Move pending changelog entries into the new version section.
  7. Create the local release commit and tag on main, and record the release intent, so any later failure is resumable (see Resuming a release that failed before its journal).
  8. Create a disposable detached checkout of that release commit, install its locked dependencies when the commit carries a package.json (and run cargo fetch --locked in a Rust project), and run the configured release build there. An active Docker target additionally builds the release worktree its qualification reads.
  9. When npm is selected, prepare every named tspublish module outside the repository, then pack each module and the public root package once into an exact tarball from that checkout. Every tarball must then carry each entry point its packed package.json names (core.packNpmArtifact.entryPoints), and its estimated publish body must fit each registry's maxPublishBytes (core.packNpmArtifact.publishSize, planned only when a limit is configured); the packed size and the body per registry are printed either way. See Package contents.
  10. Atomically install a durable journal before final publication. A release that publishes only the root package to npm uses journal schema 1, one that also or only publishes tspublish modules uses schema 3, a Docker release uses schema 2 with or without modules, and a Gitea asset release uses schema 4, all in the same storage tree.
  11. For schema 2, run the project-local tSDocker qualification build and configured image tests, then durably record the candidate digest graph and complete ordered promotion set.
  12. Publish and verify Git, then every npm package at every registry, modules first and the root package last, then each Docker destination. Remove only the exact qualified candidate after every destination is verified, or after a terminal qualification destination conflict before Git/npm publication.
  13. Remove the test artifacts tstest wrote, so a machine does not accumulate test output release after release. This runs only after a successful publication, leaving the logs in place when a release fails. It runs the project's node_modules/.bin/tsrepo test clean when the project installs @git.zone/tsrepo, otherwise its node_modules/.bin/tstest clean (tstest 6.1.0 or later). It is skipped when the project has neither, and a project opts out with {"@git.zone/tsrepo": {"test": {"cleanup": {"onRelease": false}}}}, or the legacy {"@git.zone/tstest": {"cleanup": {"onRelease": false}}}, in .smartconfig.json; both at once is an error. See the tstest readme for what it removes; snapshots are kept.

The release builds and packs the release commit

A release publishes what its release commit contains. After the commit and tag exist, GitZone creates a disposable detached worktree of that exact commit under the system temporary directory, runs pnpm install --frozen-lockfile in it when that commit carries a package.json, and runs the configured release build and pnpm pack there. Gitea assets are frozen from the same checkout. The checkout must still be the exact commit with a clean tree before and after packing, and it is removed on success and on failure. Recovery (gitzone release recover) has always worked this way; every release now does.

Consequences worth knowing before upgrading:

  • The release build no longer refreshes dist_* in your working folder for npm-only, Git-only, and Gitea-asset releases. Run pnpm build yourself when you want fresh local output.
  • A build that depends on untracked or ignored local files now fails instead of publishing output nobody else can reproduce.
  • Untracked files that match the files globs can no longer reach the tarball.
  • Unreadable paths anywhere in the working folder, such as container-owned service data under .nogit/, no longer break packing. pnpm walks the whole project tree before applying files, and the checkout does not contain that data.
  • A release commit that carries a package.json must install from its own committed lockfile. Preflight proves that before anything is created: the source commit has to carry a tracked pnpm-lock.yaml, and pnpm install --frozen-lockfile --lockfile-only — pnpm's no-install, no-write form of the same check — has to accept it. A lockfile that is missing, ignored, or outdated stops the release there, with no release commit, no tag, and nothing pushed. Commit the lockfile, or run pnpm install and commit it, then release again. The version bump only changes version, which never changes a dependency specifier, so a lockfile accepted in preflight is still accepted in the release commit.
  • Pnpm commands use the scope carried by the release commit. A repository that tracks its own root pnpm-workspace.yaml retains that workspace's settings and member validation. Otherwise, lockfile verification and checkout installation use --ignore-workspace. For a committed root workspace, configured test and build commands clear inherited workspace overrides and let each pnpm process discover the workspace from its own working directory. Independent fixture projects can therefore install their own dependencies, including their own workspace members. Without a committed root workspace, configured commands retain the repository directory as a forced pnpm scope; independent fixture installs are not supported in that mode. Both modes exclude an unrelated enclosing workspace from release lockfile validation and installation.
  • The release commit must carry a package.json: it holds the version gitzone releases. A commit without one is refused before anything is created.
  • The checkout holds only committed files, so a gitignored project-level .npmrc is not in it. A project that authenticates private dependencies through such a file now fails its checkout install after the local commit and tag exist. Authenticate at user level instead: pnpm npm login --registry=<registry> — what gitzone config doctor suggests as well — writes ~/.npmrc, and the checkout reads that.
  • An install that fails in the checkout for an environmental reason — an unreachable registry, a missing credential, an unusable store — stops the release with the local release commit and tag created, nothing pushed, and no release journal written. The release recorded its intent, so fix the environment and run gitzone release resume <version> -y: it prepares and publishes that exact tagged commit without moving any ref. A tag created by GitZone 7.2.6 or earlier has no recorded intent; a release with a Git target plus an npm and/or Docker target continues with gitzone release recover <version>, and other configurations of that age delete the tag and drop the release commit (git tag -d v<version> and git reset --hard HEAD^ on main) and release again. Confirm first that HEAD is the release commit — git log -1 --format=%s prints v<version> — so that a commit made on top of it is never dropped.
  • The --plan step list names the isolated work: preflight.lockfile for a project with a package.json, then core.releaseCheckout, core.releaseCheckout.build, then core.packNpmArtifact and its checks core.packNpmArtifact.entryPoints and, with a configured limit, core.packNpmArtifact.publishSize.

An active docker target additionally runs the build in the release worktree, as before, and the plan shows that step as core.build. tSDocker binds qualification to the project directory: the journaled request carries that cwd and a project identity hash derived from it, so the image is still built from the working folder and untracked files there can still reach an image. Moving Docker qualification to the release commit belongs to @git.zone/tsdocker and is not part of this release.

Targets decide what happens after that:

Target What it does
git Atomically pushes the exact release commit to main (or to the maintenance branch of a release line) and the new tag, often triggering remote CI release builds
npm Publishes the same journaled tarball to every configured registry; verifies its bytes anonymously, or its integrity record with the registry's configured authentication for private packages
docker Qualifies immutable OCI digest graphs with project-local tSDocker, promotes each journaled destination under single-writer alias fencing, verifies exact digests, then cleans the candidate; a terminal qualification conflict permits cleanup only
# Preview the resolved release plan
gitzone release --plan

# Build and pack HEAD like a release, without creating or publishing anything
gitzone release check

# Release to configured targets
gitzone release

# From a clean feature branch already rebased onto current main
gitzone release --merge

# Release only to npm
gitzone release --target npm

# Remove npm and Docker from the resolved targets; Git remains only if configured
gitzone release --no-publish

# Override inferred semver level
gitzone release --minor

# Run the release in its own session and return once it has started
gitzone release -y --detach

Release flags:

Flag Meaning
-y, --yes Run without interactive confirmation
-t, --test Enable preflight tests
-b, --build Enable the release build after local release metadata is created
-p, --push Explicitly select the git target; combine with other target flags as needed
--target <csv> Select git, giteaAssets, npm, cargo, and/or docker; npm cannot omit a configured enabled Gitea asset target
--npm Explicitly select the npm target; combine with other target flags as needed
--docker Explicitly select digest-qualified Docker publication; requires project-local @git.zone/tsdocker >= 3.5.1 with protocol v1
--no-publish Remove Gitea assets, npm and Docker from the resolved target set without implicitly enabling Git
--no-build Disable the separate post-metadata project build; Docker qualification still performs its required image build
--merge Fast-forward and lease-push a cleanly rebased feature branch into main, then release from main; incompatible with Docker or Gitea assets
--major, --minor, --patch Override inferred semver level; release.versionFloor still applies
--plan Show the resolved workflow without fetching or mutating refs, files, the index, or worktrees
check Build and pack HEAD of a clean tree in a disposable checkout, print the artifacts and every problem, and remove everything; see Checking a release without publishing
inspect [version] Read one or all durable release journals; add --json for machine-readable output
resume <version> Resume journaled Git, npm, qualification, promotion, or cleanup work; fresh-release overrides are rejected, and --json remains inspect-only
--recover-attempt <id> Recover the exact 32-character lowercase hexadecimal attempt.id shown by inspect --json, after independently proving its publisher stopped
--detach With -y, run a release or resume in its own session after the foreground checks; see Running a release detached

Checking a release without publishing

gitzone release check runs the build and pack half of a release on HEAD and creates nothing:

gitzone release check
gitzone release check --json
gitzone release check --target cargo --no-build

It needs a clean working tree, because a release packs only committed files, and a version source the release accepts: a project that gitzone release --plan refuses for its version source (a Cargo.toml without projectType: "rust", a Rust workspace outside the repository root, independent crate versions) is reported with the plan's message before anything is checked out. It checks out HEAD into a disposable detached worktree exactly as a release checks out its release commit (pnpm install --frozen-lockfile when the commit carries a package.json, cargo fetch --locked in a Rust project), runs the configured release build there, and then, for the active targets:

  • npm: packs every named tspublish module and the public root package with the qualified pnpm, runs the release's tarball checks (entry points and the configured maxPublishBytes), and lists every packed file.
  • cargo: reads the workspace plan through rsrepo engine plan, prints the same plan lines as gitzone release --plan with every problem a release would refuse, and packs the crates the release would publish through rsrepo engine package, listing each crate's files and sha256. It probes no registry.

Docker and Gitea assets are named as not checked: tSDocker qualifies images and the asset manifest is frozen only during a release. The checkout and every artifact are removed afterwards, also after a failure. The check never changes the version, commits, tags, writes a journal or publishes, and it takes no --plan, --merge or --detach. Targets and the build resolve as for gitzone release --plan, so --target, --cargo, --npm and --no-build apply. It exits 1 when it finds a problem, such as a dirty tree, a failed install or build, a tarball check, a blocking crate problem or a failed package. --json prints the report (ok, sourceOid, targets, build, npm.packages, cargo.order, cargo.crates, notChecked, problems).

Version floor

release.versionFloor sets a minimum for the next release version. Use it when a repository must join a common version line, for example when every serve.zone package moves to major 32:

{
  "@git.zone/cli": {
    "release": {
      "versionFloor": "32.0.0"
    }
  }
}

GitZone first infers the version as usual. It starts from the current package.json version and applies the level from the Pending changelog or from --major, --minor or --patch. The release version is then the higher of that inferred version and the floor. The floor is committed configuration, so the plain gitzone release -y honours it without a flag, and nobody has to hand-edit package.json to a version that was never published.

gitzone release --plan states the outcome:

Plan line Meaning
version floor: 32.0.0 applied (inferred 3.6.0 -> release 32.0.0) The floor is higher than the inferred version and becomes the release version
version floor: 3.5.4 satisfied by inferred 3.6.0 The inferred version already meets the floor; nothing changes
version floor: 32.0.0 inert (current 32.0.0 already meets it); remove release.versionFloor The project has reached the floor; delete the setting
version notice: the floor raises the major version above inferred 3.6.0, and Pending ... The floor raised the major version above the inferred version and Pending has no ### Breaking Changes section; the line is informational and stays until such a section exists

A floor does not require a ### Breaking Changes Pending section. --major never required one, and the floor is the same kind of explicit maintainer decision, reviewed in a commit. Aligning a version line is often not an API break, so requiring the section would force a false changelog entry. A major release is also the safe direction for consumers, because ^3 ranges never resolve to 32.0.0. The plan notice only makes that jump visible and never blocks the release. It appears only when the floor itself raises the major version; a major that --major produces prints no notice.

When the floor decides the version, the release journal records that decision with the release identity:

"release": {
  "version": "32.0.0",
  "tag": "v32.0.0",
  "mainOid": "...",
  "tagOid": "...",
  "versionFloor": { "floor": "32.0.0", "inferredVersion": "3.6.0" }
}

gitzone release inspect and gitzone release resume print the same version floor: ... applied line. The record is part of the journal's immutable identity. resume always publishes the journaled version and never resolves a new one. This matters because after the release commit the floor is inert, and a fresh resolution would infer 32.0.1. recover takes its version from the existing tag and writes no floor record, because the failed run did not record its level override.

The floor must be a canonical x.y.z string whose components do not exceed 9007199254740991, except for a matching prerelease channel. Every rejection throws ReleaseVersionError, and its code names the rule that failed. malformed-version-floor and unsupported-prerelease-floor are configuration errors that --plan and gitzone config doctor report. --plan and the release raise noncanonical-version when the current package.json version is not canonical. current-version-changed is raised only while the release runs:

Code Cause
malformed-version-floor Anything else, such as "32", "v32.0.0", null, a number, or a component above 9007199254740991
unsupported-prerelease-floor A prerelease or build suffix without a matching configured prerelease channel
invalid-prerelease-config A malformed channel, mismatched or missing prerelease floor, forbidden dist-tag or version-level override
noncanonical-version A floor is configured but the current package.json version is not a canonical x.y.z version to compare against
current-version-changed The release checkout's package.json version differs from the version the release was resolved from

gitzone config show lists the configured floor.

Prerelease channels on main

A committed prerelease channel publishes a new version, including a newer major, from integrated main under a non-latest npm dist-tag. For example, to release version 6 while stable consumers remain on version 5:

{
  "@git.zone/cli": {
    "release": {
      "versionFloor": "6.0.0-beta.0",
      "prerelease": { "identifier": "beta", "distTag": "beta" }
    }
  }
}

Use gitzone release --plan, then gitzone release -y. Starting from 5.1.2, this releases 6.0.0-beta.0; subsequent releases become 6.0.0-beta.1, 6.0.0-beta.2, and so on, regardless of the Pending changelog's semver level. --major, --minor, and --patch are rejected while the channel is configured. Raising the floor selects a higher sequence or a newer version core; a floor cannot lower the current core. Components and the sequence must be safe integers. The plan and gitzone config doctor validate the channel and its floor.

Keep both settings throughout the beta. The required matching x.y.z-identifier.N floor makes earlier GitZone releases, including 8.8.2, reject the release before creating metadata instead of ignoring an unknown channel and publishing latest. Identifiers and dist-tags use lowercase letters and digits separated by hyphens and start with a letter. Dist-tags cannot be latest or a semver range. Prerelease channels and maintenance lines are mutually exclusive, and prereleases release main in place without --merge.

Every selected registry must already have a stable latest below the prerelease core. The channel's tag must be absent or point to an equal or earlier prerelease of the same identifier. Publication always passes --tag=beta in this example; there is no flag that overrides the committed channel. Verification proves the exact artifact and the channel tag (or a later sequence of the same core and identifier), and rejects a prerelease taking latest. The journal records the stable latest found before publication. A registry conflict stops the release; GitZone never repairs dist-tags automatically.

The identifier, dist-tag, and configured floor are part of the immutable release intent and journal. resume refuses changed channel configuration and resumes the recorded version. Accepted uploads are verified without publishing again. Use gitzone release resume 6.0.0-beta.0 -y after an interruption; unjournaled prerelease recover is rejected because it cannot prove the original channel. Git and npm targets are supported. Docker and Gitea asset targets are explicitly rejected in --plan and before any release side effects; disable those targets before configuring a prerelease channel.

To promote 6.0.0-beta.N to 6.0.0, remove both release.prerelease and its prerelease release.versionFloor in a reviewed commit, add the Pending release entry, and run the normal release flow. That explicit removal promotes the exact version core, without a semver-level flag. Leaving the channel configured always produces another prerelease.

Maintenance lines

A maintenance line releases an older major from its own branch under its own npm dist-tag, so that latest never moves. While main has published the next major only as a prerelease, a line may instead keep latest (see A line that keeps latest). It is opted into per branch: the maintenance branch commits a release.line block in its .smartconfig.json, and that block makes that branch, and only that branch, a release source. main never carries the block.

Worked example: @push.rocks/smartserve publishes 8.x as latest, and its 4.x line lives on the branch v4-maintenance under the dist-tag v4-lts (4.4.0). A security fix is released as 4.4.1 like this. On v4-maintenance, .smartconfig.json carries:

{
  "@git.zone/cli": {
    "release": {
      "line": { "branch": "v4-maintenance", "distTag": "v4-lts" },
      "targets": {
        "git": { "enabled": true, "remote": "origin", "pushBranch": true, "pushTags": true },
        "npm": {
          "enabled": true,
          "registries": ["https://verdaccio.lossless.digital", "https://registry.npmjs.org"],
          "accessLevel": "public"
        }
      }
    }
  }
}

Commit the fix with gitzone commit on v4-maintenance; the branch keeps its own changelog.md, whose ## Pending section feeds the release exactly as on main. Then, still on v4-maintenance:

gitzone release --plan
# source branch: v4-maintenance
# release line: v4-maintenance -> npm dist-tag v4-lts (major 4; latest is never moved)
# npm dist-tag: v4-lts
# npm dist-tags of @push.rocks/smartserve at https://registry.npmjs.org: v4-lts 4.4.0 -> 4.4.1, latest 8.2.0 unchanged
gitzone release -y

The release commits v4.4.1 on v4-maintenance, tags it, pushes exactly refs/heads/v4-maintenance and refs/tags/v4.4.1 with a lease on v4-maintenance, and publishes each package with pnpm publish <tarball> --tag=v4-lts. main is neither read nor written.

The rules:

  • release.line takes exactly branch and distTag. The branch is a canonical branch name other than main. The dist-tag is latest (see A line that keeps latest) or a tag of the line's own: lowercase letters and digits joined by hyphens that start with a letter and are never a semver range such as v4 or x, which npm refuses as a tag.
  • The release must run on the branch the line names. On any other branch, main included, the release stops before anything is created. A branch without release.line still releases only main.
  • A line publishes to Git, npm and Docker. Docker images carry only tags bound to the release version (see Docker images on a line). Gitea marks its newest release as the latest one, so a line with an enabled giteaAssets target is refused, and so are --merge and gitzone release recover. A line release records its intent, so gitzone release resume <version> -y continues it.
  • A line keeps its major. A Pending ### Breaking Changes section, --major or a release.versionFloor that changes the major stops the release.
  • Before anything is created, and again right before each upload, every package must, at every registry, already have a canonical latest with a higher major than the release, and the line's own tag must be absent or below the release version. Otherwise the release stops; a refusal right before an upload is recorded as the terminal dist-tag-conflict and nothing is published.
  • After the upload the line's tag must name the version, or a later version of the same major, and latest must still carry a higher major. A line whose version took latest is a terminal dist-tag-conflict. gitzone never writes dist-tags: the error names the exact command that restores latest, pnpm dist-tag add <package>@<previous latest> latest --registry=<registry>, where the previous latest is the version the release recorded when it claimed the upload.
  • release.targets.npm.alreadyPublished applies unchanged: identical bytes already published, with the line's tag on them and latest still a higher major, verify without an upload under success and stop under error. Identical bytes without the line's tag stay unverified; gitzone does not add the tag.
  • A line whose distTag is latest replaces the three dist-tag rules above with its own, described in the next section.
  • Private packages follow the same rules. Their dist-tags are read with the registry's authentication through pnpm view <package> dist-tags --json, which every release path (fresh, resume and recover) qualifies from pnpm view --help in both pnpm command dialects before it authenticates, reads or publishes anything.

A line that keeps latest

While main publishes the next major only as a prerelease (see Prerelease channels on main), latest still belongs to the current major, and fixes for it come from its maintenance branch. Such a line names latest as its dist-tag. Continuing the prerelease example: main has released 6.0.0-beta.0 under beta, latest is 5.1.2, and release/5.x branched from v5.1.2 carries:

{
  "@git.zone/cli": {
    "release": {
      "line": { "branch": "release/5.x", "distTag": "latest" }
    }
  }
}

The version comes from the branch's own changelog.md Pending section, as on every line; a ### Features entry releases 5.2.0:

gitzone release --plan
# source branch: release/5.x
# release line: release/5.x -> npm dist-tag latest (major 5; latest moves only while every higher major is a prerelease)
# npm dist-tag: latest
# npm dist-tags of @example/package at https://registry.npmjs.org: latest 5.1.2 -> 5.2.0, beta 6.0.0-beta.0 unchanged
gitzone release -y

Before anything is created, and again right before each upload, every package must, at every registry, show exactly that state:

  • no dist-tag carries a stable version of a higher major;
  • latest is a canonical version of the line's major, not above the release;
  • some dist-tag carries a prerelease of a higher major.

Otherwise the release stops like any line: before anything is committed or tagged, or right before an upload with the terminal dist-tag-conflict and nothing published. Publication passes --tag=latest, and verification treats latest as it does for main: it names the version or a later one. The same holds for release.targets.npm.alreadyPublished: identical bytes already published with latest on them verify without an upload. The line records no previousLatest, because it moves latest itself. Every other line rule applies unchanged: the branch, the major, Docker images only under version-bound tags (the Docker latest tag never moves), no Gitea assets, no --merge and no recover.

Once main promotes the higher major, for example to 6.0.0, the line stops in preflight because latest carries 6.0.0. Commit a dist-tag of its own on the branch, such as "distTag": "v5-lts", and the line continues under the usual rules.

Docker images on a line

A line may enable the docker target, but its images never move a tag that another release owns, such as latest, a literal like stable or a major tag like 4. gitzone does not decide this itself: it asks the project-local tSDocker for its release-version alias policy (tSDocker 3.11.0 or later, capability release-version-alias-policy), which admits a tag only when its template contains ##version##. Name the Dockerfiles Dockerfile_##version## (or Dockerfile_##version##-alpine and the like) and keep every @git.zone/tsdocker.additionalTags entry bound to ##version##, such as v##version##.

gitzone release --plan asks tSDocker (release-validate, which builds nothing and contacts no registry) for the exact tags each selected Dockerfile will publish:

gitzone release --plan
# release line: maintenance/32.2 -> npm dist-tag corestore32 (major 32; latest is never moved)
# targets: git, npm, docker
# docker alias policy: release-version (every tag is bound to 32.2.4; floating tags such as latest are refused)
# docker image Dockerfile_##version##: 32.2.4

A Dockerfile or additional tag that would publish a floating tag stops the plan and the release before anything is committed or tagged, naming the Dockerfile or tag and the tag it would publish:

Release line corestore32 publishes Docker images only under tags bound to 32.2.4, and tSDocker refused the release (INVALID_RELEASE_CONFIG): Alias policy release-version refuses this release: Dockerfile publishes the tag "latest", which is not bound to the release version; name the Dockerfile with ##version##, such as Dockerfile_##version##.

The policy is part of the journaled qualification request, so tSDocker enforces it again when it qualifies the built images, and resume continues under the same policy. A project-local tSDocker without the capability stops a line with Docker before anything is created; main releases keep every configured tag, latest included, and do not need the capability.

latest only moves upwards

The same check guards every main release: when a registry's latest is a canonical version above the release version, the release stops before anything is committed, tagged or published, and names release.line as the way to release an older major. A release that finds latest moved above it right before an upload records a terminal dist-tag-conflict and publishes nothing. A prerelease or non-semver latest and a package the registry does not know yet do not stop a main release.

A registry whose dist-tags cannot be read (an outage, a timeout, an unexpected answer) now stops a main release in preflight as well, before the release commit. This is intended: an unknown latest could be above the release, so publishing would risk a downgrade. Release again once the registry answers.

Journal compatibility

A line release journals release.line (branch and distTag) in the release identity and its intent, and its dist-tag as npm.tag. Each npm destination it claims records previousLatest, the latest version found when the upload was claimed. git.expectedRemoteMainOid keeps its name and holds the leased commit of the release branch, main or the line's branch. Journals of main releases are byte-identical to before. GitZone 8.3.x and earlier refuse to read a line journal or intent, so they can neither resume a line release onto main nor publish it under latest. A line release with Docker writes a schema 2 journal whose Docker request carries options.aliasPolicy: "release-version"; a schema 2 journal is accepted only when that policy is present exactly for a line. GitZone 8.7.x and earlier refuse to read such a journal or its intent. A line that keeps latest journals distTag: "latest" and npm.tag: "latest" and never records previousLatest. GitZone 8.9.x and earlier refuse to read its journal or intent, and GitZone 8.4.0 to 8.9.x refuse its configuration as well.

Exact artifacts and release journals

Journal schema 1 supports Git and public or private npm publication. Schema 2 adds Docker qualification, promotion, and cleanup evidence without changing the storage path or schema-1 parsing and resume behavior. Fresh and resumed npm publication dynamically qualifies the active stable pnpm version; there is no version allowlist. GitZone reads minimum-release-age and minimum-release-age-exclude through that exact pnpm invocation in the release directory, so project configuration and global policy retain pnpm's normal precedence. It verifies the selected version's publication time through the configured registry unless the effective policy disables the age delay or excludes that version. Missing timestamps follow minimum-release-age-ignore-missing-time, including pnpm's default of true; malformed policy, metadata failures, immature versions, and prerelease versions stop qualification. GitZone also verifies the required pack/publish command contract and keeps every subsequent command bound to the qualified exact version. A new pnpm version with the same capabilities needs no GitZone update once it satisfies the policy. Registries must be unique canonical credential-free HTTP(S) URLs. release.targets.npm.accessLevel is public (the default) or private; see Private npm packages.

GitZone keeps the selected pnpm version for help checks, packing, and publication through pnpm with <version>, including release recovery and resume. This avoids mixing a launcher's help output with a project-selected package-manager version. Use a standalone pnpm installation; Corepack launchers cannot run pnpm with. The normal release-age policy also applies when pnpm provisions that version.

The pnpm release tests cover known command dialects and dynamic version qualification and qualify actual packaging and script-free tarball publication with the active version. Run testUnit with each maturity-eligible standalone version and a matching project pin in an isolated checkout. For a focused matrix, invoke the project-local tsrepo test on test/test.pnpmrelease.node.ts from a disposable directory whose package.json pins that row. Do not wrap these invocations in another pnpm with: pnpm rejects nested use. Keep the normal release-age policy for every row.

For a repository's first release, run from clean local main against an empty remote. GitZone verifies that no remote refs exist, records Git's zero object ID as the expected absent main, and atomically creates main and the release tag with an exact absent-ref lease. Concurrent creation of main rejects the push. A nonempty remote missing main, an unreachable remote, and first-release --merge are rejected. The normal release journal retains this initial identity for inspect/resume if publication is interrupted.

A selected Git target requires a canonical remote name and exactly one resolved push URL. Before any remote contact, including during --plan, GitZone rejects HTTP(S) usernames or passwords, embedded passwords, query strings, fragments, and unsupported protocols. Canonical local paths and SSH destinations may retain their required SSH username.

Every release that reaches final publication stores journal.json under the repository's Git common directory. A release that publishes the root package additionally runs pnpm pack once and stores the resulting package.tgz beside it; every tspublish module is stored as package.<sha256-of-package-name>.tgz. The v1 path names the storage format and contains all supported journal schemas:

<git-common-dir>/gitzone/releases/v1/v<version>/package.tgz
<git-common-dir>/gitzone/releases/v1/v<version>/journal.json

Frozen Gitea assets are stored beside them as asset.<sha256-of-asset-name>.bin. Together, the tarballs and assets are a record's payload.

After a fresh release, recovery or resume completes, GitZone keeps the payload of the newest three completed local version records by journal creation time and deletes the payload of older completed records. Journals and every other file of a record stay, so gitzone release inspect still lists every version. Set @git.zone/cli.release.recordRetention in .smartconfig.json to a positive integer to change this limit, for example "release": { "recordRetention": 5 }. The current run's record is always retained, even when resuming an older version. Incomplete records (including accepted uploads awaiting verification), release intents, temporary preparation directories and records locked by an active publication or resume are retained in addition to the configured count. A malformed or missing journal stops cleanup before deletion; stale record locks require the same manual owner check as other release locks. Retention affects local records only; it leaves Git tags and published releases unchanged. A pruned completed version has nothing left to resume: gitzone release resume reports that its artifacts were removed.

Pruning on demand

A repository that is not released again keeps its records until the next release completes. gitzone release prune applies the same retention at any time, through the same implementation:

gitzone release prune                  # report only
gitzone release prune --keep 1         # report against another retention count
gitzone release prune --apply          # delete the reported payloads
gitzone release prune --json           # machine-readable report

It reads release.recordRetention (default 3) unless --keep <n> overrides it, and deletes nothing without --apply. It refuses to run, with --apply or without, while the release store shows a release at work: a journal, record, creation or recovery-preparation lock (v<version>.lock, v<version>.record.lock, v<version>.create.lock, v<version>.prepare.lock), the detached-run lock (detached.lock) or a temporary preparation directory (.v<version>.tmp-*). The error lists those paths; remove a stale one only after checking that no release process is left. An attached release has no such state before it starts preparing its artifacts; prune never touches a record that release later creates.

--json prints one object, also on failure ({ "ok": false, "error": "..." } with exit code 1):

{
  "ok": true,
  "dryRun": true,
  "keep": 3,
  "root": "/repo/.git/gitzone/releases/v1",
  "records": [
    {
      "version": "35.1.9",
      "state": "completed",
      "createdAt": "2026-10-12T17:49:20.443Z",
      "completedAt": "2026-10-12T18:32:01.907Z",
      "bytes": 2125164346,
      "payloadBytes": 2125156896,
      "payloadFiles": 12,
      "action": "kept",
      "reason": "newest-completed"
    }
  ],
  "totals": {
    "records": 47,
    "kept": 4,
    "pruned": 43,
    "blocked": 0,
    "bytes": 83701754868,
    "wouldFreeBytes": 73850194634,
    "freedBytes": 0
  }
}

Records are ordered newest first by journal creation time. state is completed, incomplete or invalid (the journal is missing or malformed; createdAt and completedAt are then null). bytes counts every regular file of the record directory, payloadBytes and payloadFiles its tarballs and assets. action and reason are:

action reason meaning
kept newest-completed within the newest keep completed records
kept incomplete not complete; resume needs its payload
pruned older-completed payload removed by --apply; a dry run reports it as removable
pruned payload-absent beyond retention with no payload left; nothing to free
blocked intent-present a release intent for this version exists
blocked record-in-use a publication or resume holds the record lock
blocked invalid-journal detail names the error; --apply stops before any deletion

totals.wouldFreeBytes sums the payloads of pruned records; totals.freedBytes is the same number after --apply and 0 in a dry run.

The canonical journal binds the release commit, annotated tag object, hashed Git destination, npm registries, per-target attempt states, and, when npm is selected, the package identity, tarball size, SHA-1, SHA-256, and SHA-512 integrity. Schema 2 also binds the exact tSDocker request, candidate identity, qualification result, ordered destination promotions, probe or promotion evidence, and terminal cleanup result. Journal updates use revision compare-and-swap under an interprocess lock. Release identity and destinations cannot change after journal creation. Qualification and promotion evidence is append-only, destination conflicts are terminal, and completed Docker state cannot regress. Inspect and resume reject malformed, future, noncanonical, duplicate-key, or merely reformatted journal JSON rather than repairing or normalizing it.

Packing must leave the release tree clean. Each registry receives that exact stored tarball through pnpm publish <tarball> --tag=<dist-tag> --ignore-scripts, so publish lifecycle scripts do not run. For a public package, success is recorded only after anonymous no-redirect probes verify version metadata, SHA-1 and integrity fields, the downloaded tarball bytes, and the release's dist-tag: latest, or the tag of a maintenance line. Each metadata request is bounded at 10 s; the tarball download, sized by the packed artifact, runs as long as bytes keep arriving, aborting after 30 s without data or once 10 s plus the time its size needs at 256 KiB/s have passed, so large tarballs with native binaries verify while a stalled registry still ends the probe as retryable. The dist-tag is satisfied by the released version itself and by any later release that has since carried the tag on (for a line tag, a later version of the same major), so superseded releases stay verifiable; an older version, a prerelease, a non-semver value or an absent tag does not. A matching pre-existing version follows release.targets.npm.alreadyPublished; conflicting bytes always stop the release, while transient responses and dist-tag propagation remain retryable.

Private npm packages

With release.targets.npm.accessLevel: "private" the journal records npm.access: "private", each registry receives the stored tarball through pnpm publish <tarball> --access=restricted --tag=<dist-tag> --ignore-scripts, and verification runs through the same qualified pnpm with the registry's configured authentication instead of anonymously. GitZone never reads, parses, logs or journals a credential: pnpm reads its own .npmrc for the destination, exactly as it does when it publishes, so authenticate at user level with pnpm login --registry=<registry>.

  • Before anything is committed, tagged or published — and again on resume and recover — pnpm whoami --registry=<registry> must succeed for every private destination. Otherwise the release stops with NpmRegistryAuthenticationError, which names the registry, the login step and the command to run next, and quotes the redacted pnpm whoami failure.
  • A destination is verified when pnpm view <name>@<version> --json reports the packed artifact's dist.integrity and dist.shasum, a dist.tarball on the registry's own origin without credentials, query or fragment, and the same dist-tag rules as public releases. pnpm has no command that downloads a tarball with the registry's authentication, so the bytes are proven by the registry's integrity record rather than downloaded.
  • A not-found answer counts as absence only when pnpm whoami confirms the authentication, because a registry answers an unauthenticated read of a private package with 401, 403 or 404 alike. whoami proves you are logged in, not that your account may read that package: a registry that answers 404 to a logged-in account without read access still looks like absence, so the release credential must be able to read the packages it publishes. Recovery checks a private version unpublished the same way.
  • An authentication failure found by the checks before and after publishing changes nothing in the journal: a pending target stays pending, and an accepted upload stays accepted and is never sent again. A credential revoked between that check and pnpm publish makes the publish command fail, and the target is recorded as failed like any rejected publish. Fix the credential and run gitzone release resume <version> -y.
  • A scope registry in an .npmrc (@scope:registry=…) overrides --registry for that scope's packages, so private publish and view commands also pass --config.@scope:registry=<registry> and always address the journaled destination.

Public releases are unchanged.

Accepted npm uploads

A registry accepts an upload before it serves it, and npmjs can need minutes or more than an hour to make the version readable. GitZone records that state as accepted: the upload was taken, its bytes are proven locally, and only an exact probe (anonymous, or authenticated for a private package) promotes it to verified. accepted is not failed. failed means the registry rejected the submission or the publish command itself failed.

Journals written before the accepted state recorded the same upload as failed with verification-inconclusive, because every earlier writer chose that cause exactly when the publish command succeeded. Loading such a journal adopts those registries as accepted, so their uploads are verified instead of sent again; a registry that failed for any other cause, or that never claimed a publication attempt, keeps its recorded failure. Loading leaves the stored bytes untouched, and the next journal transaction writes the adopted state.

After submitting, GitZone waits release.targets.npm.verificationWindowMinutes (default 15) for the version to become readable, probing with doubling backoff from two seconds up to twenty. If the window ends without a readable version, the run reports the accepted target, exits successfully, and leaves the journal incomplete; the release did not fail. A later gitzone release resume <version> -y verifies the accepted target and never republishes it. An HTTP 409 during any publish attempt, for a staged or an already published version, is read the same way: the registry holds that version, so the target becomes accepted and verification still has to prove the exact bytes.

Because Docker destinations follow verified npm publication, a Docker release whose npm target is still accepted stops after npm and is resumed from the release commit, because Docker promotion still has work to do. Only a resume with no publication work left runs from a later main.

A resume whose remaining targets are only accepted or verified publishes nothing, so it does not require the working tree to sit on the release commit. It runs from any later main that still contains that commit and still carries the release tag, both locally and on the unchanged remote; work can therefore continue on main while a registry catches up. A resume that still has publication work left continues to require the journaled release commit.

Publishing independently installable components

The npm target publishes what the project declares, without further configuration:

  • every ts*/tspublish.json descriptor that names a package, in tspublish's dependency and order sequence;
  • then the root package, unless package.json sets "private": true. A private root manifest stays the shared version and dependency catalog, and only its modules publish. gitzone format adds "private": true to a package.json without private only when npm is not a release target; with an npm target the choice stays yours, so formatting never stops the root package from publishing.

Descriptors without a name stay build-order-only folders for TsBuild. Every named module must declare "registries": ["useBase"]: GitZone publishes it to release.targets.npm.registries with the target's access level and alreadyPublished policy, exactly like the root package. Explicit, empty, and extendBase registry declarations are rejected before the release creates anything, and so are a module named like a public root package, a non-boolean "private", and an enabled npm target with nothing to publish. Each error names the file and the change that resolves it.

{
  "@git.zone/cli": {
    "release": {
      "targets": {
        "npm": {
          "enabled": true,
          "registries": [
            "https://verdaccio.lossless.digital",
            "https://registry.npmjs.org"
          ],
          "accessLevel": "public"
        },
        "docker": { "enabled": true, "engine": "tsdocker" }
      }
    }
  }
}

gitzone release --plan reads and validates the package set and prints it in publication order, for example:

plan: … core.releaseCheckout -> core.releaseCheckout.build -> core.prepareNpmModules -> core.packNpmArtifact -> core.writeReleaseJournal -> target.docker.qualify -> target.git -> target.npm -> target.docker.promote -> target.docker.cleanup
npm packages (publication order):
  @example/api-client (ts_apiclient, ts_interfaces)
  @example/service (root)

A private root prints root @example/service: private, not published instead of its line. Modules publish before the root package because a module can only depend on its siblings and on the root's dependencies, while the root package may depend on its own modules.

The normal build must generate every module's compiled folders. In the release checkout, GitZone re-reads the package set from the release commit and refuses to publish one that differs from the plan. It then uses tspublish's prepare() API to isolate the built modules beside the checkout, outside every repository, packs each module and the root package once, and removes the preparation directory.

The journal records the ordered module set and one target per package and registry: the modules first, then the root package. Each module tarball uses the canonical filename package.<sha256-of-package-name>.tgz beside journal.json; the root package keeps package.tgz. Every package uses the release version, and sibling dependencies use that exact version. Git is published before any npm package. All stored hashes are checked before publication; completion requires every package at every registry to verify. If publication fails partway through, release resume <version> -y verifies the completed targets and publishes only the missing ones from the retained tarballs, never a target that already verified. Resume never reruns discovery, preparation, builds, or packing.

npm can hold accepted uploads for publish-time scanning before they become installable. A release with modules submits every package in publication order, then waits for availability across the submitted set so scans can overlap. Command failures and artifact conflicts still stop submission immediately. Every package, tarball and dist-tag must verify before the release is complete, and Docker promotion waits until every package verified.

See npm's publish-time scanning announcement for the registry's availability behavior.

GitZone is the only publisher of these packages. A package.json script that runs the standalone tspublish publisher (without the plan or prepare subcommand) fails every release and resume that publishes npm packages, because it would upload different bytes for the same version outside the journal.

Rust workspaces and the cargo target

A pure Rust repository sets "projectType": "rust" under @git.zone/cli in .smartconfig.json. Its root Cargo workspace carries the version; the repository never needs or gets a package.json, and its default test and build commands are rsrepo test and rsrepo build. Only TypeScript projects get a baked 00_commitinfo_data.ts.

Every release reads and writes the version through one version source:

Version source Project What the release versions
npm a TypeScript project package.json
npm+internal-rust a TypeScript project with an internal rust/ or ts_rust/ Cargo workspace package.json only; the internal crates keep their own versions
cargo projectType: "rust" [workspace.package].version (or the single package's version) and every internal path dependency's version requirement, through rsrepo engine set-version, which also updates Cargo.lock offline

A workspace whose crates carry independent versions is refused: gitzone releases one shared version. Every Cargo operation runs through the rsrepo release engine (protocol version 1). gitzone uses the project's node_modules/.bin/rsrepo when package.json declares @git.zone/rsrepo (a declared but missing install is an error), and otherwise rsrepo on PATH. gitzone has no dependency on rsrepo; it checks the protocol version and the engine commands before use.

The release checkout of a Rust project needs a committed Cargo.lock. It runs cargo fetch --locked and installs npm dependencies only when the release commit carries a package.json.

Crates publish only through the cargo target. Without one, a release tags and pushes the workspace but publishes no crate:

{
  "@git.zone/cli": {
    "projectType": "rust",
    "release": {
      "targets": {
        "git": { "enabled": true, "remote": "origin" },
        "cargo": {
          "registries": ["crates-io"],
          "members": ["mycrate-core", "mycrate"],
          "verificationWindowMinutes": 15
        }
      }
    }
  }
}
  • registries names crates-io or registries defined in cargo config. Credentials stay with cargo; gitzone and rsrepo never read them.
  • members is optional. Without it, the release publishes every member whose Cargo publish field allows every listed registry.
  • verificationWindowMinutes (1 to 720, default 15) is how long the release waits for an accepted crate to appear in the registry index.
  • enabled: false turns the target off; --cargo or --target cargo selects it for one release.

The cargo target needs projectType: "rust", and a Rust project refuses the npm target, because it has no package.json version to publish. Docker and Gitea assets cannot join a cargo release yet.

A cargo release then works like this:

  1. Before it creates anything, it reads the workspace plan from rsrepo engine plan and refuses the release on a missing Cargo.lock, independent versions, an unknown members entry, no publishable member, a blocking problem on a published crate (such as a missing license or description, a path dependency without a version, a wildcard dependency, or a dependency that cannot be published), and a published crate whose dependency the release does not publish. Because the release moves every internal requirement to the new version, such a dependency would have no matching version in the registry. Each crate's new version must also be absent from every registry (step preflight.cargoRegistries).
  2. After the release commit, it packs the published crates in the release checkout through rsrepo engine package and stores each one in the journal as crate.<sha256(name)>.crate (step core.packageCrates). The cargo cells are an optional cargo section of journal schema 1 or 3, one cell per registry and crate.
  3. After Git is verified, it publishes the crates dependencies first through rsrepo engine publish (cargo publish --locked --no-verify), from a fresh checkout of the release commit. A crate is published to a registry only after its dependencies are verified there, because crates.io refuses a crate whose dependency version it has not indexed yet. The release probes the sparse index from 2 s, doubling up to 20 s between probes, within the verification window.
  4. A crate whose index entry carries the packed sha256 is verified. An accepted crate is never uploaded again; a crate still unindexed after its window stops the run, and gitzone release resume <version> -y probes it later, skips verified crates and publishes the rest. A registry that serves other bytes for the version becomes a terminal conflict, because a published crate version is immutable.

gitzone release --plan prints the registries, the publication order, the members it does not publish and every problem the engine reports. When a problem would make the release refuse (step 1 above), the plan ends with The release would be refused: <n> blocking cargo problem(s), listed above. and exits 1, as it does for its other refusals:

targets: git, cargo
version: 0.3.0 -> 0.4.0
cargo registries: crates-io
cargo workspace: /work/fx (workspace version 0.3.0, Cargo.lock tracked)
cargo publication order: fx-core -> fx-util -> fx-cli
cargo index wait: 15 min per accepted crate
cargo member fx-internal: not published (publish = false)
cargo member fx-internal: problem publish-disabled: fx-internal sets publish = false and is never published

gitzone release check packs the same crates without publishing anything (see Checking a release without publishing).

gitzone template rust scaffolds such a repository: a root virtual workspace whose [workspace.package] carries the version, edition (2024), license (MIT) and repository, one library crate in crates/<name>/ that inherits them, the Cargo.lock that cargo generate-lockfile writes, a .smartconfig.json with projectType: "rust", the Git target and a disabled cargo target, Gitea workflows that run rsrepo check, a readme and license.md. gitzone format treats a Rust project as described in Formatting.

Limits of this version: one release cannot version both package.json and a Cargo workspace, gitzone release recover refuses a cargo project (a cargo release always records its intent, so gitzone release resume continues it), and an older gitzone that does not know the cargo target ignores it.

Gitea release attachments before npm

Enable release.targets.giteaAssets when public source archives, native executables, or other versioned artifacts must be available before npm packages. This works with root packages and ordered tspublish modules. The Git branch and tag target must be active; integrate main before releasing. Docker and --merge cannot be combined with this target.

{
  "@git.zone/cli": {
    "release": {
      "targets": {
        "git": { "enabled": true, "remote": "origin", "pushBranch": true, "pushTags": true },
        "giteaAssets": {
          "enabled": true,
          "apiOrigin": "https://code.example.org",
          "owner": "example",
          "repository": "native-cli",
          "tokenEnv": "GITZONE_RELEASE_GITEA_TOKEN",
          "manifestPath": "dist/release-assets.json"
        },
        "npm": { "enabled": true, "registries": ["https://registry.npmjs.org"], "accessLevel": "public" }
      }
    }
  }
}

The configured HTTPS origin must match the resolved SSH/HTTPS Git host and exact owner/repository path. SSH aliases cannot establish that identity. tokenEnv is the environment variable's name; credentials are never stored in the journal. Before Git publication, authenticated and anonymous repository checks require a public repository, repository-admin authority, and a PAT with write:repository scope (or an existing all scope). Code-push permission alone is insufficient because Gitea grants code and release-unit permissions separately. Configure an existing appropriate credential; the CLI does not acquire or broaden tokens.

The normal release build generates a strict manifest for the new release version:

{
  "schemaVersion": 1,
  "version": "1.2.3",
  "assets": [
    { "name": "source.tar.gz", "path": "dist/source.tar.gz", "size": 12345, "sha256": "<64 lowercase hexadecimal characters>" },
    { "name": "native-linux-x64", "path": "dist/native-linux-x64", "size": 23456, "sha256": "<64 lowercase hexadecimal characters>" }
  ]
}

Replace the example sizes and hashes with the actual file identities. Names and paths must be canonical and unique. Regular files and their ancestor directories must not be symlinks; credential/runtime paths, traversal and globs are rejected. The release is created as a draft and published only after every attachment verified, so no attachment is ever public without the others — native executables never appear without their corresponding source, relinking materials and notices, whatever interrupts the release. Gitea shows a draft, its attachment list and its attachment downloads only to accounts with write access to the repository's releases; everyone else gets 404, also for the attachment's UUID URL and for /releases/download/<tag>/<name>.

Attachments publish one at a time in manifest order by default: each uploads, then is downloaded and hashed, then the next starts. Two transfers in the same direction share the link, so each takes longer than it would alone; a large upload that runs long enough outlasts the reverse proxy in front of Gitea, which then answers HTTP 504 although the bytes kept moving (Spark 35.0.1: two concurrent 385 MB uploads took over 11 minutes each, against about 3 minutes each one after another, and one ended in a 504). Every upload must therefore finish, including the time Gitea needs to store it, within that proxy's timeout, and gitzone never runs two uploads, or two verification downloads, at the same time. concurrency: 2 pipelines the attachments instead: the next one uploads while the previous one is downloaded and hashed, largest first so the last verification is a short one. That is the only overlap that does not share a direction; on a link where the two directions are not independent, keep the default.

Once an attachment publication fails, no further attachment starts; the one already running finishes, because cutting a running transfer off would leave its result inconclusive, and every failure is reported. The journal keeps each attachment's own attempt, so gitzone release resume continues with exactly the unfinished ones. A crash while pipelining can leave two attachments in publishing; each needs its own --recover-attempt, one resume at a time.

An upload that ends in HTTP 504 (or any other unknown outcome) is inspected right away: an attachment Gitea stored anyway is downloaded and hashed and then counts as published, a missing one is recorded as failed and uploaded again on resume. If the proxy let Gitea finish the abandoned request after the resume uploaded the attachment again, the release lists the name twice; that is a destination conflict that stops the release, never a silent replacement.

The lifecycle of the Gitea release:

  1. After the Git push, wait until Gitea has processed the pushed tag, then create the release with draft: true (or adopt the draft or published release that already carries the tag) and read it back. Gitea processes a tag push in its push queue after the push returned, sometimes minutes later, and publishes (draft: false) any release it then finds for the tag, so a draft created earlier would turn public on its own with the attachments it has by then (Spark 35.0.1 with gitzone 8.5.0: the draft turned public while its two largest binaries were uploading). Processing the tag writes a tag-only release row, which the API hides and the public release page (/<owner>/<repo>/releases/tag/<tag>) shows; once that page answers 200, creating the release converts that row into the draft in place and Gitea never touches it again. The wait polls every 5 seconds for at most 15 minutes. A page request that times out, aborts or encounters a transient network failure counts as not yet processed and is polled again within that same deadline; each request is capped by the remaining wait. Definite HTTP statuses other than 200 or 404 still stop immediately. When the wait runs out, its error includes the last page-check error's name and safe message, nothing was created and gitzone release resume waits again. A release that this run created must read back as a draft, otherwise the release stops before any upload.
  2. Upload every attachment into it and download and hash each one. While the release is a draft, listing and downloads carry the token (sent only to the configured Gitea origin, never along a redirect to another host); a draft's attachment cannot be downloaded anonymously. The bytes hashed are the stored object the public URL serves later, so integrity is proven the same way.
  3. Verify the complete set: nothing missing, nothing extra, every frozen identity unchanged.
  4. Before publishing, every listed attachment must be exactly the one this run downloaded and hashed — same attachment id and UUID — so an attachment replaced after the final check, even by one of identical size, is never published: that is a destination conflict and the release stays a draft. A server that reports no UUIDs is held to the attachment id alone; there the final check downloads every attachment again, and Gitea never replaces an attachment's bytes under its id.
  5. Publish the release (PATCH … {"draft": false}), then prove the public view: the release is anonymously visible with the same id, the anonymous attachment list carries exactly the verified attachment ids and UUIDs, and every attachment's public URL answers 200 with its frozen Content-Length and no content encoding; no name or id may appear twice. These public checks read headers only; the bytes are not downloaded again.
  6. Record the journal's completion, then publish npm.

Setting draft: false changes nothing but the flag, is idempotent, and its result is observable, so it has no journal attempt of its own: a resume after a crash before or after it finds the draft or the published release, verifies it and continues. A release that an older gitzone already published keeps the previous semantics: its attachments are listed and downloaded anonymously, and publishing only verifies it. Because every attachment of such a release is public the moment it lands, its missing attachments upload one at a time in manifest order, whatever concurrency says, so sources and notices still come before the binaries that follow them in the manifest. The upload order and concurrency follow the state Gitea reports, never the state gitzone intended, and every upload first reads the release again: a draft that turned public meanwhile stops the release before the next byte is sent (GiteaReleaseStateChangedError), and so does a draft that turned public before publication; resume then continues it as a published release.

Tag-triggered CI that reads the Gitea release sees the same thing anonymous users see. The Git target pushes the tag before the Gitea target creates the draft, so such a job starts while the release is still a draft or absent and gets 404 for it until the release is published complete. A job that polls until the release is complete (Spark's verify-node-release.ts treats a missing release as pending and waits up to 40 minutes) keeps working, and it never sees a partially uploaded release; a job that reads the release once, immediately, fails, as it did before this change.

Publication runs build → freeze every artifact → write schema-4 journal → Git → Gitea assets → npm. Stored assets use asset.<sha256-of-name>.bin filenames next to the retained npm archives. The CLI verifies local hashes before Git, then inspects or creates the exact non-prerelease tag release, as a draft until every attachment verified (see below). Every upload has a revisioned journal attempt. Each accepted attachment must download with the expected size and SHA256 — with the token while the release is a draft, anonymously once it is published; authenticated API redirects are rejected and the token never leaves the configured Gitea origin. Transfers and hashing stream with bounded memory.

A Gitea request is bounded by its progress, not by a fixed duration, so a large binary on a slow server finishes as long as bytes keep moving:

Setting (release.targets.giteaAssets) Default Bound
responseTimeoutSeconds 120 Wait for response headers of a metadata request or download
stallTimeoutSeconds 60 Longest gap without a byte sent or received while a transfer runs
minimumKiBPerSecond 256 Rate floor; a request may take the two bounds above plus its size at this rate
concurrency 1 Attachments of a draft in progress at the same time (1 or 2); 2 uploads one while the previous is verified

The rate floor applies to every request on its own. Since uploads never overlap, and neither do downloads, each direction of the link has to carry minimumKiBPerSecond once, whatever concurrency says. gitzone config doctor prints that rate. These bounds are gitzone's own; a reverse proxy in front of Gitea can end a request sooner (HTTP 504), which is why uploads never share the link.

Gitea stores an upload before it answers, which takes minutes for a large binary. After an upload's last byte the CLI therefore waits for the upload's size at minimumKiBPerSecond (about 28 minutes for 440 MB at 256 KiB/s), or responseTimeoutSeconds when that is longer, and adds that wait to the upload's total bound.

Each bound fails with its own message: no response, upload not acknowledged (every byte sent, no answer within its size allowance), stalled (no bytes for the stall bound), or still progressing but below the rate floor. All of them leave the target inconclusive for gitzone release resume. The settings tune transfers only and may change between runs and resumes.

Any other request failure is inconclusive too, and says why without a URL, header or token: the HTTP status it reached, each error of the cause chain by name and code, a message only when it is plain words, and how far the transfer got, for example Gitea public asset verification is inconclusive (HTTP 200; TypeError: terminated; SocketError [UND_ERR_SOCKET]: other side closed; 1048576 of 461373440 bytes received).

The complete remote attachment set must equal the manifest. Extra names, duplicates, changed release identity, or conflicting bytes stop publication; the CLI has no deletion or replacement path. It verifies the complete set again before it publishes the draft and before npm: the set must be complete with no extra attachment, and every attachment must still carry its frozen name, size and URL. An attachment this run uploaded, downloaded and hashed is not downloaded a second time when its attachment id and UUID are unchanged since that download. Gitea stores an attachment's bytes under its UUID and its API can only rename an attachment, so an unchanged id and UUID mean the bytes the run hashed. Every other attachment, including one whose id or UUID changed, or every attachment on a server that reports no UUIDs, is downloaded and hashed again.

A journal records its completion (completedAt) only after that final check passed. A Gitea-asset release without npm used to be recorded as complete as soon as its last attachment verified, before the final check ran; now a journal whose targets all verified but whose final check has not passed reads completed: no, and gitzone release resume <version> -y runs the check.

An inconclusive or rejected upload leaves npm unpublished and retains the journal for gitzone release resume <version> -y. Resume uses the recorded destination and retained bytes without rebuilding or repacking. It checks the metadata of attachments the journal records as verified without downloading them again, downloads and hashes only the unverified ones, and then verifies the complete public set once more before npm, even for completed journals; a resume runs in a new process, so that final check downloads every attachment. An unresolved active attempt requires the exact --recover-attempt identifier after its publisher has stopped.

Gitea's general attachment-enabled switch is checked. Its issue-attachment size setting is distinct from repository.release.FILE_MAX_SIZE; confirm the actual release and proxy upload limits for the largest planned artifact. A size rejection blocks npm and retains the original artifact; archives are never split or replaced automatically. The manifest and journal provide exact sizes for this check.

Artifact immutability here means conflict refusal by the publisher. Administrators can still change server state. Distributors must retain the exact public corresponding-source and relinking artifacts while distributing the binaries and include those artifacts in their operational retention and backup policy. Independent CI publishers must not rebuild, upload, delete, or publish these same release assets or packages; keep tag jobs limited to verification.

Independent tag-triggered npm publishers are incompatible with this transaction. GitZone rejects a release while .gitea/workflows/default_tags.yaml or .gitlab-ci.yml still contains the legacy npmci npm publish command. Apply the v6 Gitea workflow template or remove the legacy GitLab job and commit that change first.

Inspect and resume without regenerating release identity:

# List journals or inspect one exact version
gitzone release inspect
gitzone release inspect 6.0.0 --json

# Reconcile remote state, then continue only unfinished targets
gitzone release resume 6.0.0 -y

# Only after proving the recorded publisher process has stopped
gitzone release resume 6.0.0 -y --recover-attempt="$ATTEMPT_ID"

Resume requires the annotated release tag and each journaled destination setting to remain exact; the local main commit must be exact for any remaining publication work and must merely contain the release commit for a verification-only resume. The Git remote must still match when Git is journaled; npm registry, access, and already-published settings must still match when npm is journaled. Before any recovery or publication work, a nonterminal schema-2 resume canonical-compares the current Docker request with the journal, verifies every required project-local tSDocker protocol capability, and validates the journaled request; additive future capabilities are accepted. A completed schema-2 journal trusts its terminal cleanup evidence and does not require candidate state or current Docker configuration. Non-journaled target configuration is ignored, so releases created with a target subset remain resumable. Fresh-release overrides such as targets, integration, build, test, and version flags are rejected even in negated forms such as --no-git, --no-publish, and --no-build; JSON output is available only through inspect --json, prune --json and check --json.

Resume probes remotely observable targets before acting. A published Git release is identified by its annotated tag and that tag's commit, so remote main carrying later commits is exact state rather than a conflict. Exact state is accepted without republishing; positive byte, metadata, ref, or digest conflicts fail closed, while inconclusive results do not overwrite a previously verified state. An npm target in accepted state is only ever verified: its bytes are never sent again, and a resume that still cannot read the version leaves it accepted and succeeds. A Git, npm, Docker qualification, promotion, or cleanup target left in publishing state retains authority until the exact recorded 32-character lowercase hexadecimal attempt.id is supplied after independently proving its publisher stopped. An interrupted tSDocker preparing record has no qualification evidence and cannot be promoted; when tSDocker reports RECOVERY_REQUIRED, the exact recovered qualification owner removes only that incomplete deterministic preparation before claiming a fresh qualification attempt. Promotion recovery first reclaims the retired owner's request files and probes the journaled destination; an exact qualified digest is accepted, pending state retires the old attempt and claims a fresh owner before retry, an inconclusive probe retains the old journal owner, and conflicts halt permanently. Final cleanup of a qualified candidate starts only after every promotion is verified and is itself idempotently recoverable. A terminal qualification destination conflict has no promotions, blocks Git/npm, and permits only exact qualified-candidate cleanup.

Schema 1 resumes final Git release-ref and npm publication after the journal is atomically installed. Schema 2 additionally resumes Docker qualification, ordered promotion, and cleanup. Schema 3 resumes the complete npm package set. --merge remains available only for non-Docker releases because its pre-release Git push would violate qualification-before-publication ordering.

Resuming a release that failed before its journal

Once the local release commit and annotated tag exist, a release records its intent before it builds, packs or checks the remote again:

<git-common-dir>/gitzone/releases/v1/v<version>.intent.json

The intent binds the release commit and tag object, the version floor record, the targets and release-build choice the command line selected, and, for a Git target, the remote name, the hashed push destination and the remote main the push will lease. A failure between the tag and the installed journal — the release build, packing, freezing Gitea assets, Docker validation, or a remote that cannot be inspected — is then resumable with the command below. A failed check of the packed tarballs (entry points, publish size, or a tarball too large to inspect) is not: the release commit causes it, so it needs the abandon steps further down.

gitzone release resume 6.0.0 -y

Without a journal, resume reads the intent, resolves the release from the .smartconfig.json the release commit carries with the recorded targets and build choice, and requires clean main at the release commit, the recorded tag object, the recorded push destination and a remote main that still equals the leased value. It then prepares the journal exactly as the release would have — the Docker worktree build, the disposable checkout with its build, packing and frozen assets — and continues as a normal resume from that journal. Tests are not run again: preflight ran them on the source the release commit only adds version metadata to. Installing the journal removes the intent, and from then on the journal alone decides what resume does. A failed resume leaves the intent in place for the next one. recover on a version with a recorded intent runs this resume.

To abandon such a release instead of resuming it (nothing reached a remote, and you delete the local tag and reset main to the commit before the release commit), delete its intent file as well; otherwise a later tag of the same version is handed to a resume that refuses it.

A resume packs the same commit with the configuration that commit carries, so a failure that the commit itself causes — a build that always fails, a missing entry point, a publish body over its limit — fails the resume again. Abandon such a release as described above, fix the project, and release again. For a failed tarball check the error spells these steps out with git -C on the checkout that holds the release commit, which can differ from the folder the release started in (--merge).

Recovering a tag without a recorded intent

For a Git release with npm and/or Docker targets whose local release commit and annotated tag were created by GitZone 7.2.6 or earlier, which recorded no intent, and whose build or pack failed before journal installation, use:

gitzone release recover 6.0.0 --plan
gitzone release recover 6.0.0 -y

Recovery supports configured Git with npm and/or Docker targets, including tspublish modules. It requires clean main, the exact annotated tag at that commit, matching package and completed changelog versions, and no pending changes. The metadata commit must not change release configuration. It also checks remote ancestry, requires the release tag to be absent remotely, and requires an anonymous HTTP 404 for the version of every package it publishes at every npm registry when npm is selected (for a private package, an authenticated not-found answer; see Private npm packages). Published or uncertain Git/npm state cannot be adopted without its original journal. Gitea asset preparation recovery is not supported.

The first attempt records the exact refs, destination and resolved workflow under the common Git directory in gitzone/releases/v1/v<version>.preparation.json. For Docker, this binding also includes the exact canonical qualification request. Retries must match that binding, and a binding written by another GitZone version does not match, because the resolved workflow contains the release plan: when Release recovery identity or configuration differs from its original preparation. appears on the first recovery after upgrading from 6.16.3 or earlier, remove that <git-common-dir>/gitzone/releases/v1/v<version>.preparation.json file and recover again. Recovery uses the same disposable checkout every release uses: a detached worktree of the tagged commit, pnpm install --frozen-lockfile, then the configured tests and build before packing. Recovery always installs, because it matches the release identity against the root package.json and is therefore available only to projects that have one. Recovery runs the configured tests there because no preflight ran ahead of it. Ignored runtime data and local project secrets are not copied. A build that depends on those files must be made reproducible through the project's normal configuration before it can recover.

Prepared npm artifacts are installed through the normal journal API. Docker recovery verifies the project-local tSDocker capabilities and configuration before preparation and validates again before installing a schema-2 journal. Its complete qualification, including a fresh build and configured image tests, runs from the unchanged canonical checkout before Git or npm publication. Candidate state stays at that stable project identity for later resume; images from the disposable preflight build are not adopted. Docker destinations retain the normal qualified digest, observed alias predecessor, probe and conflict checks.

Recovery never creates another version or moves refs. Once the journal exists, another recover call uses resume without repeating preparation. Any unfinished Docker qualification remains part of the normal resume workflow. Use resume --recover-attempt for an interrupted publisher that still owns a journaled attempt, after proving that process stopped.

Concurrent preparations are excluded by v<version>.prepare.lock. A process crash can leave that lock behind; inspect its owner and prove the process has stopped before manually removing that specific lock. Failed build or pack attempts retain their preparation binding and remove only their disposable worktree. --plan validates and describes the local identity without installing dependencies, building, fetching, writing a binding, or publishing; it does not certify registry absence. When the current remote commit is unavailable locally, the plan reports deferred ancestry; execution fetches and verifies that history before writing the preparation binding. Earlier version, changelog, commit or tag failures still require explicit operator reconciliation.

Running a release detached

A release runs its tests, build, push and every publication in one process, which can take longer than the tool call or terminal that started it. With --detach, gitzone release -y and gitzone release resume <version> -y return once the release is running in a session of its own:

gitzone release -y --detach
gitzone release resume 6.0.0 -y --detach

In the foreground, --detach checks what --plan checks — arguments, configuration, the release branch, a clean tree, the Git destination and the version — plus pending changelog entries, or for resume the journal or recorded intent of that version. It needs -y (or release.confirmation: "auto" for a fresh release), because nothing can answer a prompt later, and it cannot be combined with --plan. Any failure there exits non-zero and starts nothing. Everything else — the pnpm and tSDocker capability checks, tests, build, commit, tag and publication — runs in the detached release and appears in its log and recorded outcome.

A fresh release prints its release plan there, as --plan does. The launcher then starts a supervisor with detached: true — a new session and process group without a controlling terminal, stdin /dev/null — and the supervisor starts the release as its child with the same arguments minus --detach. Once the supervisor reports the release running, the launcher prints one line and exits 0:

Release running detached: run <id>, process group <pid>, log <path>. Follow it with `tail -f <path>`; `gitzone release inspect` shows its outcome.

Phase lines in the log carry the time they were printed, for example [2026-10-03T01:48:31.402Z] Running tests followed by Running tests passed in 7 min 8 s., so the log of a release shows where its time went: tests, the release checkout and its build, the Git target, the Gitea release and every attachment (uploading <bytes>, uploaded, downloaded and hashed in <time>), the final attachment-set verification, the publication of the draft, each npm registry and its availability wait, and Docker qualification, promotion and cleanup. A foreground release prints the same lines.

When the supervisor refuses to start the release, the launcher prints why and exits 1. When the supervisor does not report within 60 seconds, the launcher terminates its whole process group (SIGTERM, and SIGKILL after another 10 seconds), then exits 1 and says whether a release had started before it was stopped; gitzone release inspect shows the outcome of such a run.

Everything lives beside the release journals:

<git-common-dir>/gitzone/releases/v1/detached.lock        held by the supervisor while the release runs
<git-common-dir>/gitzone/releases/v1/detached/<id>.json   run record: command, version, pids, state, exit code or signal
<git-common-dir>/gitzone/releases/v1/detached/<id>.log    stdout and stderr of the release

The supervisor records the run as running, and when the release ends, as succeeded (exit code 0) or failed with its non-zero exit code or the signal that killed it, and appends Detached release finished: … to the log. A crash or kill of the release is recorded the same way. From the moment it holds the lock, a SIGHUP, SIGINT or SIGTERM does not end the supervisor: before the release has started, the signal cancels the launch, the lock is released and no run is recorded; after that, the supervisor passes each signal on to the release once and stays to record the outcome. kill -- -<process group> stops the whole run: the release receives the signal directly as well, and the supervisor passes nothing on to a release that has already ended. gitzone release inspect lists the runs, latest first, before the journals, and inspect --json adds them as runs. The run's state says how the process ended; the journal of its version still says what was published, and a failed run continues with gitzone release resume <version> -y, detached or not.

While the lock is held, every other release, resume or recover of the project, detached or not, is refused; only the run's own release passes. --plan runs and inspect stay allowed, since they change nothing. The lock sits in the Git common directory, so all worktrees of a repository share it. A release started without --detach does not take the lock, so a detached launch cannot see it. When the supervisor itself is killed with SIGKILL, or the machine stops, the run stays running and the lock stays held: prove the recorded supervisor process no longer exists, then remove <git-common-dir>/gitzone/releases/v1/detached.lock, as with every other GitZone lock. Run records and logs are kept; remove old ones from detached/ when they are no longer needed.

Standard Changelog

The changelog is convention-based and intentionally not configured.

gitzone commit appends entries to:

## Pending

gitzone release moves those pending entries into a dated version section:

## 2026-05-10 - 2.15.0

The standard buckets are Breaking Changes, Features, Fixes, Documentation, and Maintenance.

Configuration

CLI workflow config lives under @git.zone/cli in .smartconfig.json. Docker release selection lives under @git.zone/cli.release.targets.docker; canonical registries, repository mappings, and platforms remain owned by @git.zone/tsdocker.

{
  "@git.zone/cli": {
    "schemaVersion": 2,
    "projectType": "npm",
    "commit": {
      "confirmation": "prompt",
      "steps": ["analyze", "test", "build", "changelog", "commit", "push"]
    },
    "release": {
      "confirmation": "prompt",
      "preflight": {
        "test": false,
        "build": true
      },
      "targets": {
        "git": {
          "enabled": true,
          "remote": "origin",
          "pushBranch": true,
          "pushTags": true,
          "remoteTimeoutSeconds": 180
        },
        "npm": {
          "enabled": true,
          "registries": ["https://registry.npmjs.org"],
          "accessLevel": "public",
          "alreadyPublished": "success",
          "verificationWindowMinutes": 15,
          "maxPublishBytes": {
            "https://registry.npmjs.org": 10485760
          }
        },
        "docker": {
          "enabled": false,
          "engine": "tsdocker",
          "registry": "registry.gitlab.com",
          "buildRegistries": ["registry.gitlab.com"],
          "test": true,
          "patterns": [],
          "cached": true,
          "parallel": true
        }
      }
    }
  },
  "@git.zone/tsdocker": {
    "registries": ["registry.gitlab.com"],
    "registryRepoMap": {
      "registry.gitlab.com": "myorg/myproject"
    },
    "platforms": ["linux/amd64", "linux/arm64"]
  }
}

NPM registries belong only here:

@git.zone/cli.release.targets.npm.registries

Canonical Docker destinations and repository mappings belong here. Registry values should be hosts without http:// or https://:

@git.zone/tsdocker.registries

Docker release configuration uses the project's absolute project-local node_modules/.bin/tsdocker. Install tSDocker 3.5.1 or newer in each Docker-producing project; fresh release and nonterminal resume verify matching package and binary versions plus the required protocol-v1 capabilities before use. Additional future protocol capabilities are accepted:

pnpm add --save-dev @git.zone/tsdocker@3.5.2

release.targets.docker.registry, buildRegistries, test, patterns, cached, parallel, and context become part of the immutable qualification request. noBuild must remain false or absent because digest qualification always builds. The separate release --no-build flag only disables the normal project build step and does not bypass Docker qualification.

Useful config commands:

# Show current @git.zone/cli config
gitzone config show --json

# Configure project basics, CLI behavior, and release targets interactively
gitzone config project
gitzone config cli
gitzone config release

# Validate schema, legacy keys, release targets, registries, and npm auth
gitzone config doctor

# Use opencode to repair configuration issues found by doctor
gitzone config fix

# Read the npm release target registries
gitzone config get release.targets.npm.registries

# Add an npm release target registry
gitzone config add https://registry.npmjs.org

# Set npm target access level
gitzone config access public

# Run schema migration to v2
gitzone config migrate 2

@ship.zone/szci belongs to szci, which GitZone no longer uses or reads. gitzone config migrate and gitzone format remove only an empty npmGlobalTools list, szci's default, and delete a block left empty; every other key stays. szci still reads two keys, and no migration deletes or moves them. npmRegistryUrl is the registry szci npm prepare installs from, for example in a Dockerfile that installs private packages. It is not a publish registry, even when release.targets.npm.registries lists the same URL, so no migration, the one from schema 1 included, moves or copies it into the publish registries: a registry list alone enables the npm target. npmAccessLevel is the access level szci git mirror checks. GitZone publishes with release.targets.npm.accessLevel, so the migration copies npmAccessLevel there when that key is absent and keeps it in the block: the duplicate is deliberate. gitzone config doctor names the current home of each kept key GitZone once read, for example @git.zone/tsdocker.registryRepoMap for dockerRegistryRepoMap and @git.zone/tsdocker.buildArgEnvMap for dockerBuildargEnvMap, and reports npmRegistryUrl and npmAccessLevel as szci's own. A block that holds only these two keys needs no step once release.targets.npm.accessLevel is set.

gitzone config doctor also reports unknown release.targets.* keys, which a release ignores, validates release.targets.cargo, and checks the sections of the consolidated build tools:

  • @git.zone/tsrepo takes the subsections build, test, bundle, docs, watch and sea, each shaped like the section of the single tool it replaces (@git.zone/tstest, @git.zone/tsbundle, @git.zone/tsdoc, @git.zone/tswatch, @git.zone/tssea; build has none). A subsection beside its legacy section is an error: tsrepo never merges the two. A tsrepo section in a project whose package.json does not declare @git.zone/tsrepo is a warning, because the single tools do not read it.
  • @git.zone/rsrepo takes the tsrust fields (targets, targetsByHost, locked, static, rustflags, targetDir, pruneAfterBuild, matrix, notices) plus features, toolchains and members. Keeping it beside the legacy @git.zone/tsrust section is an error.

gitzone config fix moves the legacy tool sections into their @git.zone/tsrepo subsections, but only when package.json declares @git.zone/tsrepo (as a dependency or devDependency), because older tools do not read the new key. A legacy section whose subsection already exists stays where it is, and the fix names it so you can merge the two by hand.

Package contents

gitzone format writes the files list of package.json, and nothing else should. By default it writes the list every gitzone project has always had. A project that needs different contents describes them in .smartconfig.json:

{
  "@git.zone/cli": {
    "packageFiles": {
      "sets": ["source", "dist", "assets", "config", "docs"],
      "include": ["third-party-notices.md", "licenses/**/*"],
      "exclude": ["dist_bundle/**/*", "html/**/*"]
    }
  }
}

sets picks named groups of entries; without packageFiles, or without sets, every set applies:

Set Entries
source ts/**/*, ts_web/**/*, excluding ts/tsconfig.json and ts_web/tsconfig.json
dist dist/**/*, dist_*/**/*, dist_ts/**/*, dist_ts_web/**/*
assets assets/**/*
cli cli.js
config .smartconfig.json
docs readme.md
binary With managed binaryCli assets: the installer (install.sh or assets.installer.file) and, with the npm wrapper, bin/ and its postinstall script; empty otherwise

The list carries the entries of the selected sets in this order, then include, then every exclusion: those of the sets first, then exclude, each written with a leading !. Exclusions come last because pnpm 10 and 12 apply an exclusion only after the entries that include the file. A repeated entry keeps its first position. Write exclude entries without the !.

pnpm packs some files whatever files says: package.json, every root file whose name starts with readme, license or licence (any case), the main file and every bin file. An exclusion naming one of them would have no effect, so gitzone format refuses it, as it refuses an unknown set name. Nested files such as docs/readme.md are not packed implicitly.

A release checks the package it builds:

  • preflight.packageFiles, before anything is created and only when packageFiles is configured: package.json files must equal the generated list. Otherwise run gitzone format, commit, and release again.
  • core.packNpmArtifact.entryPoints, after packing and before Git is pushed: every main, module, types/typings, bin and exports target of the packed package.json (all conditions; a * pattern must match at least one file) must be in the tarball. main also resolves the way Node does (.js, .json, .node, or a folder's index).
  • The packed size and the estimated publish body per registry are printed. The body is what pnpm publish uploads: the tarball as base64 plus the packed manifest with its readme, which is about 4/3 of the tarball size. With release.targets.npm.maxPublishBytes (registry URL to bytes; every key must be a configured registry) the release stops when a body exceeds its registry's limit (core.packNpmArtifact.publishSize). There is no default limit, because registries differ.

The two artifact checks run after the local release commit and tag exist and before anything is pushed, so a failure leaves the release where a failed build or pack leaves it: commit and tag local, intent recorded, no journal, nothing pushed. Because the commit itself causes the failure, the error does not suggest a resume; it names the steps to abandon the release (see Resuming a release that failed before its journal). Then fix the package and release again. gitzone config doctor validates packageFiles and maxPublishBytes and reports a files list that differs.

Managed Assets

Projects can opt into generated, updateable repository assets through @git.zone/cli.assets. The kind binaryCli manages installer scripts, npm binary wrappers, postinstall downloaders, and Gitea release workflows for CLI executables that tsrepo sea from @git.zone/tsrepo builds as Node.js single executable applications.

{
  "@git.zone/cli": {
    "schemaVersion": 2,
    "assets": {
      "schemaVersion": 1,
      "kind": "binaryCli",
      "cliName": "onebox",
      "displayName": "Onebox",
      "repository": {
        "host": "code.foss.global",
        "path": "serve.zone/onebox"
      },
      "installer": {
        "enabled": true,
        "installDir": "/opt/onebox",
        "binDir": "/usr/local/bin",
        "modes": {
          "default": "binary",
          "source": {
            "enabled": true,
            "commands": ["pnpm install --frozen-lockfile", "pnpm run build"],
            "executable": "cli.js",
            "executableFiles": ["cli.js", "cli.ts.js", "cli.child.js"],
            "validate": "node cli.js --version"
          }
        },
        "service": {
          "detectNames": ["onebox"],
          "refreshCommand": "onebox systemd enable",
          "startHint": "onebox systemd start"
        },
        "ensureDirs": ["/var/lib/onebox", "/var/www/certbot"],
        "preservePaths": ["/var/lib/onebox"]
      },
      "npmWrapper": {
        "enabled": false
      },
      "releaseWorkflow": {
        "compileCommand": "pnpm run build:binary",
        "packNpmArtifact": true
      }
    }
  },
  "@git.zone/tssea": {
    "nodeVersion": "26.11.1",
    "bundle": "./dist_node/main.mjs",
    "outDir": "./dist/binaries",
    "targets": [
      { "target": "linux-x64", "name": "onebox-linux-x64" },
      { "target": "linux-arm64", "name": "onebox-linux-arm64" }
    ]
  }
}

The executables come from the project's SEA configuration; GitZone keeps no target list of its own. The project declares @git.zone/tsrepo ^1.0.0 in dependencies or devDependencies and has it installed (pnpm install --save-dev @git.zone/tsrepo, then pnpm install); GitZone reads the configuration through @git.zone/tsrepo/sea/config from the project's own install, so the project's tsrepo version interprets its own configuration and GitZone has no runtime dependency on it. tsrepo's configuration rule applies: @git.zone/tsrepo.sea, else the legacy @git.zone/tssea section, and both at once is an error. A project still on the deprecated @git.zone/tssea (^1.1.0, without tsrepo) is read through @git.zone/tssea/config; it must move to tsrepo as the tsrepo readme's "Migrating from the single tools" describes, or set releaseWorkflow.compileCommand to its tssea build, because the default compileCommand runs tsrepo; gitzone format and gitzone config doctor say which step is missing, and they refuse an installed tssea outside ^1.1.0 and an outDir outside the project.

  • Asset names: each target's name is the executable's file name and its release asset name, used exactly as given. A Windows name carries its .exe. The installer maps uname to linux or macos and x64 or arm64; the npm wrapper maps process.platform:process.arch; both download releases/download/<version>/<name>.
  • Windows: install.sh installs on Linux and macOS only. Under Git Bash, MSYS2 or Cygwin it stops before any download, also with --dry-run, and names the release's Windows executables (releases/download/<version or latest>/<name>) and SHA256SUMS.txt to check them against: there it has no root user for its sudo bash usage, no systemd, and its binDir lies inside the Git installation, outside the Windows PATH. The npm wrapper does install on Windows.
  • Runtime dependencies: the executables carry the bundled application, and the npm wrapper and postinstall use Node.js built-ins only, so neither needs the project's dependencies. npm and pnpm still install every package listed there for each user of the npm package. Keep packages that are only bundled into the executable in devDependencies; releaseWorkflow.installCommand installs both, so the build is unaffected. GitZone does not move them for you.
  • Release workflow: releaseWorkflow.installCommand (default pnpm install --frozen-lockfile --ignore-scripts) installs the dependencies and releaseWorkflow.compileCommand (default pnpm exec tsrepo bundle && pnpm exec tsrepo sea build, the @git.zone/tsrepo bundle and SEA build) builds; set either when the project needs another command, for example pnpm exec tsbundle && pnpm exec tssea build in a project not yet moved from tsbundle and tssea to tsrepo. The workflow sets up Node.js 22, the runtime of its tooling (tsrepo sea runs on Node.js 22 or later and builds and smoke-runs every executable with the nodeVersion it pins, never with the Node.js that runs it; a compileCommand that runs the project's own code runs it on Node.js 22), installs dependencies, checks that the package.json version equals the tag, builds, and then checksums and uploads exactly the configured executables, the notice files tsrepo sea writes next to each, the staged installer (when the installer is enabled), the extra release assets and SHA256SUMS.txt. A missing file or a Gitea API error fails the job, and nothing else in the output directory is uploaded. A published asset is never replaced: when the release already has an asset of the same name, the job fails before uploading anything and names each such asset; delete them in Gitea before rerunning. An asset list that is not a JSON array of named assets fails the job as well, never counting as "no such asset". With packNpmArtifact it also packs and uploads the npm tarball.
  • Extra release assets: releaseWorkflow.extraAssets lists further repository files the release publishes, such as an uninstaller or the changelog: exact /-separated paths whose segments consist of letters, digits, ., _ and - and start with a letter or digit (no globs, no .., no leading / or ./, no backslash). Each file is published under its last path segment, which must differ from every other release asset (the executables, their notice files, the name of an enabled installer, SHA256SUMS.txt, and with packNpmArtifact any .tgz name); a path inside the tssea output directory is refused, since extras are repository files, not build outputs. After the build and before the release is created, the workflow checks that every extra is a regular file whose real path lies inside the checkout, then stages them; any failure ends the job with no release and no upload. Extras are listed in SHA256SUMS.txt and uploaded like every other asset. installer.extraFiles names the extras (by asset name) that install.sh installs into installDir beside the executable; each must be tracked by Git, and the installer applies the file's mode from the Git index (100755 installs as 0755, everything else as 0644), so a committed mode change shows as asset drift in gitzone format check. A name must not be the executable's file name (binary.installFileName, default cliName). Only install.sh installs extras, in binary and in source mode; the npm postinstall installs none, so a program that uses such a file must cope with its absence in an npm install.
  • Release notes: the release description is the tag's section of changelog.md, the ## <date> - <version> heading gitzone release writes, up to the next ## heading. Generic notes with the install command are used only when the changelog has no section for the version. The workflow extracts it with the managed scripts/gitzone-binary-release.mjs, which the release workflow template also writes.
  • Old releases: releaseWorkflow.keepReleases (default 0) is how many published releases stay. With N above 0, the workflow deletes, after this release's assets are uploaded, every published Gitea release older than the newest N (by creation time) together with its assets. Tags are never deleted, drafts are neither counted nor deleted, and the release of the running tag always stays. 0 keeps every release; a value that is not a non-negative integer fails gitzone format. A Gitea API error fails the job.
  • npm postinstall: with npmWrapper.enabled, the postinstall downloads releases/download/v<version>/<name> for the running platform, where <version> is the version in the package's own package.json. It runs only when the package is installed as a dependency, that is when its directory lies inside a node_modules directory (npm, pnpm, Yarn and Bun install every package, local or global, there). In the project's own checkout, for development or a release build, it skips the download, because that version is not released yet and the project builds its binaries itself. The release asset is the only source, and it is verified like the installer does: the postinstall reads the release's SHA256SUMS.txt, downloads the binary and its notice files next to the target, and moves them into place under their release asset names only when every SHA-256 matches; a mismatch, a missing entry, a missing file or missing checksums fail and install nothing. An existing binary is not downloaded again, but missing notice files are. It follows 301, 302, 303, 307 and 308 redirects (relative locations included) up to 10 hops and only to HTTPS, and gives up after 30 s without data or 15 minutes per file. Every failure stops the install with the URL, the version and the cause. The wrapper in bin/ runs the binary as a child process and, on Linux and macOS, forwards SIGTERM and SIGHUP to it while it runs. SIGINT is forwarded only when the wrapper has no controlling terminal (opening /dev/tty fails with ENXIO: under setsid, docker run without -t, or a detached: true spawn). With a terminal, a Ctrl+C reaches the binary directly through the process group, so the wrapper ignores SIGINT until the binary ends; a parent's child.kill('SIGINT') on the wrapper is then ignored as well, because Node does not name the sender: send SIGTERM, or spawn the wrapper with detached: true. The wrapper passes on the binary's exit status, and when a signal ended the binary it ends with the same signal (or 128 + its number if Node ignores it). The wrapper is the entry point for pnpm add -g and npx; under systemd or another supervisor, point the service at the installed native binary instead. A wrapper run as a systemd main process should use KillMode=mixed: with the default control-group, systemd sends SIGTERM to the binary as well and the wrapper forwards another. For the same reason, signal the wrapper's pid, not its process group: without a terminal, a group-wide SIGINT (tini -g, KillSignal=SIGINT, kill -INT -<pgid>) also reaches the binary twice.

@git.zone/cli.schemaVersion remains the CLI config schema. @git.zone/cli.assets.schemaVersion is scoped to the managed asset model.

Managed assets are applied through the existing formatter workflow:

gitzone format plan --only assets --json
gitzone format check --only assets
gitzone format --only assets --write --yes

If npmWrapper.enabled is set, gitzone format --only packagejson --write also keeps package.json bin, scripts.postinstall, and npm package files entries in sync.

The installer is published as a release asset only: the release workflow stages install.sh into the release artifact directory, lists it in SHA256SUMS.txt and uploads it with the executables. Its usage text shows <repository>/releases/download/latest/install.sh (Gitea, like GitHub, resolves latest to the newest published release; a pinned install adds --version vX.Y.Z), and the release notes show the release's own releases/download/<tag>/install.sh; Gitea serves both without an account, like the executables. No generated file points at a raw/ or src/ URL, which code.foss.global serves to signed-in users only. The release uploads the installer exactly when installer.enabled is not false. A repository that starts publishing the installer has no latest/install.sh until its next release uploads one; until then that URL answers 404. The whole script is one function that only its last line calls, so a download that ends early runs nothing.

GitZone 10 removed installer.distribution, releaseWorkflow.includeInstallerAsset and repository.branch. gitzone format (like gitzone config migrate and gitzone config fix) removes them from .smartconfig.json where removing them changes nothing: repository.branch always, since nothing reads it, installer.distribution: "releaseAsset", and an includeInstallerAsset equal to whether the installer is enabled; an object left empty goes with them. Every other value fails gitzone format and gitzone config doctor with an error that names the step: installer.distribution: "rawBranch" (a raw/branch/ URL) or another value, remove the key; includeInstallerAsset: false with an enabled installer, remove the key or disable the installer; includeInstallerAsset: true with a disabled installer, list the installer file in releaseWorkflow.extraAssets instead (a disabled installer's file name is free for an extra asset).

Set installer.service.removeLegacyUnits to a list of systemd unit names when an installer must disable and remove older service units during upgrades.

installer.versionSelection lists the ways a user picks the version (default ["latest", "version"]): with neither flag the installer takes the newest Gitea release, --version vX.Y.Z a given tag, and adding "major" enables --major N (N or vN, no leading zero), the newest release of that major. --major reads the releases API's list without drafts and pre-releases (?draft=false&pre-release=false), newest first, 50 per page, and takes the first tag that starts with vN. or N.; it reads at most 100 pages and fails when none matches. Without "major" the installer has no --major option.

A version is a SemVer 2.0 version with an optional v (v1.2.3, 1.2.3, v1.2.3-beta.1, 1.2.3+build.5); the installer adds the v of the release tag, so --version 1.2.3 installs v1.2.3. --version latest is the same as no --version. The tag the releases API names for the newest release, or for --major N, must have the same form. Anything else fails before the installer builds a download URL. Every download uses curl with HTTPS only (also on redirects), at most 10 redirects, and the postinstall's limits: 30 s to connect or without data, 15 minutes per transfer. The npm postinstall likewise refuses a package.json version that is not SemVer.

install.sh --dry-run resolves and checks the version like an installation, then checks the release without root and without touching the system: it downloads SHA256SUMS.txt, checks that it lists every file the installation downloads (the platform's executable, its notice files and the installer.extraFiles), and requests the first byte of each, printing <HTTP status> <URL> per file. It exits 0 when every file answers 200 or 206 and has its checksum entry, and 1 otherwise. In source mode it prints the clone URL and tag.

The installer installs and updates in this order, and an update that fails leaves the previous installation running:

  1. Verify before touching anything. In binary mode it downloads the platform's asset, the third-party notice files tssea wrote for it (<name>.third-party-notices.txt and .json, the target's noticeFiles) and SHA256SUMS.txt of the same release, refuses a file the release lacks or whose digest differs or is missing (with sha256sum or shasum; neither present is an error), and runs the new binary with --version. In source mode (installer.modes.source) it clones the tag and runs commands and validate, each a shell command line run with eval exactly as written; the first that fails stops the installation. Any failure here exits non-zero with the running installation and service untouched.
  2. Stop and move aside. It stops the running installer.service.detectNames units and moves installDir into a new directory beside it, installDir.previous.XXXXXX (from mktemp -d); it never overwrites the old installation in place. A backup an interrupted earlier run left behind is never reused, merged into or deleted. Data under installer.preservePaths lives outside installDir and is not touched.
  3. Install and check. It installs the binary with its notice files and installer.extraFiles, which keep their release asset names (or the source build, where every executableFiles entry must exist), into installDir, links it into binDir, creates ensureDirs, and runs the installed CLI with --version.
  4. Restart. If a service was running, it runs installer.service.refreshCommand (when set) and restarts installer.service.restartName. The refresh command is a shell command line, written into the installer as one quoted word and run with eval in a subshell, with binDir first on PATH so a bare CLI name runs the CLI just installed, also when the installer's PATH lacks binDir (a sudo secure_path can leave it out) or names another copy first. It may use the installer's variables, such as "$BIN_DIR/<cli>"; its exit status, that of its last command, decides whether the update succeeded. startHint and successHints are printed exactly as written.
  5. Clean up. Only after the restart succeeded it deletes the moved-aside installation.

If any step from 2 on fails, the installer removes the new files, moves the previous installation back, relinks it, restarts the service it stopped, and exits non-zero with the failed step and whether the previous state was fully restored. A failed first installation leaves nothing behind.

The installer links the CLI into binDir only and never picks another directory. When the PATH it runs with (under sudo, root's PATH) finds no <cliName>, or finds another copy before binDir/<cliName>, it ends with a warning on stderr that names binDir or the other copy; the installation stands and the exit status stays 0.

Migrating from denoBinaryCli

GitZone 9 refuses the removed keys with an error that names the replacement:

Removed Replacement
kind: "denoBinaryCli" kind: "binaryCli"
@git.zone/tsdeno.compileTargets @git.zone/tssea.targets (see "Migrating from tsdeno" in the tssea readme); keep each name, and write the .exe of a Windows name
binary.targets, binary.outDir @git.zone/tssea.targets, @git.zone/tssea.outDir
releaseWorkflow.assetGlobs, releaseWorkflow.include releaseWorkflow.extraAssets for further repository files (exact paths, no globs); the executables and their notice files are always released
releaseAssets releaseWorkflow
rawBranch, repository.rawBranch none: the installer is a release asset; remove the key
installer.delivery none: the installer is a release asset; remove the key
installer.defaultMode installer.modes.default
installer.source installer.modes.source
installer.modes.source.validateCommand installer.modes.source.validate
installer.service.legacyUnitNames installer.service.removeLegacyUnits
managed templates asset_deno_binary_installer, asset_deno_binary_postinstall, asset_deno_binary_release_gitea asset_binary_installer, asset_binary_postinstall, asset_binary_release_gitea

Sealed TsPack releases on Gitea

For a component that already builds and packages with @git.zone/tspack, select assets.kind: "tspackRelease". GitZone manages the tag-triggered workflow and its release scripts. Compilation stays in the component's command, and TsPack owns archive creation and verification. The project must install @git.zone/tspack 1.4.0 or later and ignore both configured output and dist_gitzone_retained/ in Git. Pin pnpm 11 or later in package.json using packageManager. The workflow installs that version directly with the pinned official pnpm/setup action before the explicit frozen dependency install. This avoids an older container-provided pnpm launcher rewriting the lockfile while selecting the project's package manager. sealedRelease.nodeVersion is the exact Node.js release (x.y.z) that installs the dependencies and runs the prepare and verify commands. The workflow installs it with the SHA-pinned official actions/setup-node v4.4.0 and checks node --version before anything else runs. It is independent of the Node.js release a SEA build tool embeds. GitZone 9 refuses the former sealedRelease.denoVersion with a migration error.

{
  "@git.zone/cli": {
    "assets": {
      "schemaVersion": 1,
      "kind": "tspackRelease",
      "sealedRelease": {
        "nodeVersion": "24.13.0",
        "outputDirectory": "dist_control_release",
        "prepareCommand": ["node", "scripts/release-control.mjs"],
        "verifyCommand": ["node", "scripts/package-control.mjs", "--release", "--reuse"]
      }
    }
  }
}

Both commands return exactly one JSON object on stdout with directory and manifestSha256. Preparation creates a clean tagged release directly beneath the configured output root and saves every input descriptor needed for reuse. Verification restores that same result without compiling or repacking; the workflow runs it right after preparation and requires the same directory and manifest digest. Commands are explicit executable/argument arrays; GitZone does not interpolate a shell. A command's stderr goes straight to the job log, so write build progress and diagnostics there and keep stdout for the JSON result. The command runs without GITHUB_TOKEN and ACTIONS_RUNTIME_TOKEN. When it exits non-zero or is killed, the release step fails with a line that names the command and its exit code or signal, below the command's own stderr. A command that writes more than 1 MiB to stdout or runs longer than 30 minutes is stopped and fails the step by name. Run gitzone format --only assets --write --yes and commit the generated files (.gitea/workflows/release.yml and three scripts in scripts/). The normal authorized gitzone release -y pushes the tag that triggers this CI flow.

The draft Gitea release is the retention. The workflow does not use Actions artifacts: Gitea scopes them to one run attempt, so a rerun could not read them, and Gitea 1.27 finalizes an upload by merging its chunks inside the request, which outlasts the artifact client's 3-minute timeout for a few hundred megabytes and then fails every retry because the merge already consumed the chunks. Release attachments belong to the tag, and the job token can write them.

  1. Discover reads the tag's release with the job token and anonymously. With no release, the job builds. A release must name its manifest digest in its description; its tspack-manifest.json attachment must hash to that digest, TsPack must accept the manifest and SHA256SUMS.txt for the tagged commit, and the attachments must be exactly the sealed set. Discovery then downloads every archive into dist_gitzone_retained/ and hashes it against the manifest. A complete set is retained and nothing is built. A draft that is not complete is rebuilt. A published release that is not complete stops the job.
  2. Prepare runs the component's prepare command, verifies the result with TsPack, runs the verify command on it, and writes the release receipt. Both commands run with SOURCE_DATE_EPOCH set to the tagged commit's time, so tools that record or embed a time build the same bytes on every attempt; a job that sets another value stops.
  3. Retain waits until Gitea has processed the pushed tag before it creates a draft, because Gitea publishes a release it finds for a tag when it processes the push (the same wait gitzone release uses; see Gitea release attachments before npm). The wait also retries page timeouts, aborts and transient network errors within its 15-minute deadline, reporting the last cause if it expires. The draft's description names the manifest digest. Uploads run one at a time; every upload reads the release again first, then the attachment is downloaded and hashed. Attachments that already match are kept.
  4. Restore moves the retained download into the output root and verifies it with TsPack against the manifest digest and the tagged commit. A resumed job does not run the component's verify command: the restored bytes are hash-bound to the digest the description names, which was written only after the verify command passed on the job that built them.
  5. Publish checks that the draft holds exactly the attachments this job hashed (Gitea stores an attachment's bytes under its UUID, and its API can only rename one, so id, name and UUID identify hashed bytes), checks the remote tag again, publishes the draft and confirms the release and its attachment list anonymously.

A published attachment is never replaced and a release is never deleted. A release build must be reproducible: a draft whose description names a manifest holds an earlier build of the same commit, and a rebuild that seals a different manifest is refused without changing the draft, naming both digests. Make the build reproducible, or delete the draft to release a new build. Within the same manifest, a draft attachment that conflicts with the sealed set (bytes an interrupted upload left, or a file outside the set) is replaced: right before each deletion the release must still read back as a draft with the token and be invisible anonymously, and the job log names the attachment and both digests. A draft that turns public meanwhile stops the job before the next change.

After an interruption, rerun the job. A rerun after a complete draft or a published release resumes from its bytes without building; a rerun after an incomplete draft rebuilds, and completes it when the rebuild reproduces the manifest the draft names. The remote tag must match the sealed source commit. Unexpected API responses, a publicly visible draft, an incomplete published release and changed source identities stop before publication.

Formatting

gitzone format is dry-run by default. That makes it safe to run in any repo.

# Preview changes
gitzone format

# Emit a machine-readable plan
gitzone format plan --json

# Fail when formatting changes or validator errors remain
gitzone format check

# Run a subset of formatters
gitzone format --only prettier,packagejson

# Apply changes
gitzone format --write

# Apply without prompt
gitzone format --write --yes

# Apply deterministic fixes, then use opencode for remaining issues
gitzone format fix

Formatters include cleanup, smartconfig normalization, dependency license checks, package metadata normalization, template updates, .gitignore, TypeScript config, Prettier, README existence checks, and configured copy operations.

The packagejson formatter writes the files list of package.json. It ships the ts/ and ts_web/ sources without their tsconfig.json: tsrepo build (formerly tsbuild) reads a folder's compiler options from <folder>/tsconfig.json, which extends the root tsconfig.json, and the package does not ship the root file. The list therefore ends with !ts/tsconfig.json and !ts_web/tsconfig.json; pnpm 10 and 12 apply an exclusion only after the entries that include the file.

The owner npm scopes are an exported constant, so no repository keeps a hand-copied list:

import { ownerNpmScopes } from '@git.zone/cli';
// ownerNpmScopes: ['@api.global', '@apiclient.xyz', ..., '@push.rocks', '@serve.zone', ...]

--only, --skip and @git.zone/cli.format.modules.only/skip accept the module names cleanup, smartconfig, license, packagejson, templates, assets, gitignore, tsconfig, prettier, readme, copy and rustfmt; any other name is refused with that list. gitzone format --write writes nothing while the plan carries an error.

The template formatter owns .vscode/settings.json and .vscode/launch.json in every project, and the Gitea workflows, CLI entry points and HTML entry files of the project types that declare them. Workflow and entry files are written from the template as a whole. The html/index.html of a website loads its fonts from https://<module.assetbrokerUrl>/fonts/fonts.css and links https://<module.legalUrl> and its /privacy page; a wcc project's page loads the same fonts. Both keys live under @git.zone/cli.module in .smartconfig.json and hold a host (host[:port][/path], without https:// or a trailing slash), for example "assetbrokerUrl": "assetbroker.example.com". When a page needs a key that is missing, empty or not a host, the plan carries an error that names it, and the page is left alone. The formatter never creates or changes a Dockerfile or .dockerignore: they describe the project's own image. gitzone template service and gitzone template website scaffold a first pnpm-based Dockerfile.

The Gitea workflows verify and never publish. npm and wcc projects get default_nottags.yaml and default_tags.yaml, service and website projects docker_nottags.yaml and docker_tags.yaml. Each runs in code.foss.global/host.today/ht-docker-node:latest: a pinned checkout that keeps no credentials, then pnpm --version, pnpm install --frozen-lockfile, pnpm build, pnpm test and pnpm audit --prod --audit-level=high --registry=https://registry.npmjs.org. The audit fails the workflow when a production dependency has a high or critical advisory; development dependencies do not ship and are not audited. It asks registry.npmjs.org because a private registry may not serve the advisory endpoint, and it runs last, so a newly published advisory never hides a test result. pnpm 12 skips an advisory you have reviewed and accepted when pnpm-workspace.yaml lists its GHSA ID under auditConfig.ignoreGhsas. No setup action installs pnpm: the image's pnpm switches to the version that package.json pins in packageManager, pnpm 10 included, so the printed version is the one that installs, builds, tests and audits. The switch downloads that version from the npm registry, so the runner needs registry access, which the install needs anyway. They use no secrets and build no images; a tag workflow verifies the tagged commit, and gitzone release publishes packages and images. The two .vscode files are merged at the top level instead: every key the template declares is refreshed, and every other top-level key — an editor setting only your project has, such as deno.enable — is kept. Two limits follow from that. A key the template itself declares is taken as a whole, so an array such as json.schemas or configurations is replaced rather than unioned, and a merged file is re-serialized with two-space indentation, so a project that keeps its own key loses hand-made formatting in those two files. A .vscode file that is not valid JSON cannot be merged and is replaced by the template as a whole. That never happens silently: the plan line reads Replace .vscode/settings.json: the current file is not valid JSON (…) instead of the usual Apply template …, and the run prints a warning that keys only your project declares are lost. Both appear in gitzone format, gitzone format check and gitzone format plan --json, so you see them before confirming a write.

A projectType: "rust" repository is formatted for Cargo:

  • rustfmt runs rsrepo fmt --check (rsrepo resolved as for releases: the project's node_modules/.bin/rsrepo when package.json declares @git.zone/rsrepo, otherwise rsrepo on PATH) and plans each file rustfmt would change; --write runs rsrepo fmt once. A failing check without a formatting diff, such as a missing rustfmt or an unparsable file, is a plan error. Other project types skip the module.
  • The .gitignore standard also ignores Cargo's root /target/.
  • license reports an error when the repository root carries no license text (license, license.md, LICENSE-MIT, COPYING and the like, any case).
  • packagejson leaves a package.json alone, and smartconfig does not ask for module.npmPackagename.

gitzone format fix intentionally lives outside the default format path. Normal format runs stay deterministic; the fix command uses opencode only after deterministic formatters have done what they can.

Development Services

gitzone services manages local Docker-backed services for development projects.

Supported services:

Service Lifecycle/log aliases
MongoDB (NoSQLDB engine) mongo, mongodb
ObjectStorage (S3-compatible) objectstorage, s3
Elasticsearch elasticsearch, es

Service-selection commands such as set, enable, and disable also accept elastic; lifecycle and log commands do not.

# Start configured services
gitzone services start

# Enable specific services non-interactively
gitzone services set mongodb,objectstorage

# Check status
gitzone services status

# Machine-readable status, including connection strings and data sizes
gitzone services status --json

# Print MongoDB Compass connection string
gitzone services compass

# Migrate legacy mongod data into NoSQLDB explicitly (start does it automatically)
gitzone services migrate mongodb --yes

# Show logs
gitzone services logs mongo 50

Service config is stored in .nogit/env.json. Newly created config files use owner-only permissions; writes are atomic and reject a stale in-memory snapshot rather than overwriting a concurrent change. MongoDB (NoSQLDB), ObjectStorage, and Elasticsearch data is stored in .nogit/nosqldbdata, .nogit/objectstoragedata, and .nogit/esdata, so it stays out of Git.

The mongodb service runs the NoSQLDB engine from the digest-pinned code.foss.global/lossless.zone/database 2.0.0 image (@lossless.org/nosqldb 10.7.3) as <project>-nosqldb. It speaks the MongoDB wire protocol, so the .nogit/env.json contract (MONGODB_URL, MONGODB_HOST, MONGODB_PORT, MONGODB_USER, MONGODB_PASS, MONGODB_NAME, MONGODB_AUTH_ENABLED) is unchanged and consumers need no code change. The wire port is published as 127.0.0.1:MONGODB_PORT only. The engine's management UI is not published; its password MONGODB_ADMIN_PASSWORD is generated per project, never printed, and redacted from logs and services config --json. Startup refuses the default admin passwords and an admin password reused as MONGODB_PASS, hardens .nogit/nosqldbdata to the engine user with mode 0700, recreates an owned container by immutable ID when its image, port, data bind, DBST_* environment, restart policy, command, entrypoint, user, or health check drifted, and waits for an authenticated readiness round trip through @lossless.org/client/nosqldb. Transactions run on the single node without a replica set.

ObjectStorage uses S3_PORT for its local S3 API and S3_UI_PORT for its management UI. S3_REGION defaults to us-east-1; S3_ADMIN_PASSWORD is a separate randomly generated password for the UI's admin user. Startup rejects empty credentials, admin/admin S3 defaults, the default admin password, and an admin password reused as either S3 credential. gitzone services config --json replaces those three ObjectStorage credential values with "***", as it does the credential-bearing platform names listed under "serve.zone platform environment" below. It prints every other field as stored, including the MongoDB and Elasticsearch passwords and the credential-bearing MONGODB_URL and ELASTICSEARCH_URL, so treat the complete output as sensitive.

GitZone runs a digest-pinned ObjectStorage image with both the S3 API and management UI published on loopback. S3_HOST, S3_ENDPOINT, S3_USESSL, and the other generic S3 consumer fields remain user-controlled; service startup always reconciles the local managed instance through 127.0.0.1 and its configured local ports.

Before creating or recreating an ObjectStorage container, or starting an owned stopped container, GitZone uses one fenced root helper to restore recursive ownership and set .nogit/objectstoragedata itself to mode 0700. An exact container caught in Docker's restart loop is stopped by immutable ID before that hardening and restart; if hardening fails, it remains safely stopped. An already-running canonical container is not modified.

GitZone derives new bucket names from the project name using S3 naming rules. When loading older configuration it repairs only the exact generated <project>-documents value if that value is invalid; custom bucket names are never rewritten. services start s3 validates the bucket and credentials, recreates an owned container when its pinned image, ports, data bind, controlled product environment, restart policy, command, entrypoint, user, or health check drifted, waits for management readiness, then creates and verifies the bucket through an authenticated S3 client. A GitZone-label mismatch is an ownership conflict and blocks mutation rather than being treated as repairable drift. Container setup, management readiness, and the S3 operations share one deadline; SmartBucket cleanup has its own bounded close. Docker mutations use the container's immutable ID after exact-name discovery. Outside the explicit restart-loop recovery above, GitZone stops a pre-existing container on failure only after that invocation's start returned successfully. A newly created ID remains invocation-owned across an uncertain start and can be stopped safely; an uncertain pre-existing start is left adoptable rather than risking stopping another concurrent invocation. A canonical container created but not yet started remains stopped for a safe retry.

serve.zone platform environment

.nogit/env.json also carries the environment names a serve.zone database and objectstorage binding injects (see "Capability environment" in the serve.zone app-integration guide), so a serve.zone app runs locally against gitzone services without a hand-edited environment. They are derived from the GitZone fields on every start, reconfiguration, and write; edit the GitZone fields, never the derived names, which are overwritten. Other keys you add to the file are preserved.

Platform name Value
MONGODB_URI, MONGODB_URL, MONGO_URL one identical URI: mongodb://MONGODB_USER:MONGODB_PASS@MONGODB_HOST:MONGODB_PORT/MONGODB_NAME?authSource=admin&directConnection=true
MONGODB_HOST, MONGODB_PORT the GitZone fields of the same name
MONGODB_DATABASE, MONGO_DBNAME MONGODB_NAME
MONGODB_USERNAME, MONGO_DBUSER MONGODB_USER
MONGODB_PASSWORD, MONGO_DBPASS MONGODB_PASS
AWS_ENDPOINT_URL the endpoint origin resolved from S3_ENDPOINT (see below), e.g. http://localhost:29000; the scheme's default port is omitted
S3_ENDPOINT_HOST the host of that origin, as written in S3_ENDPOINT; an IPv6 address without brackets
S3_PORT, S3_REGION, S3_BUCKET the GitZone fields of the same name
S3_USE_SSL "true" or "false", the scheme of that origin
AWS_REGION S3_REGION
AWS_S3_FORCE_PATH_STYLE "true"
S3_ACCESS_KEY, S3_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID S3_ACCESSKEY
S3_SECRET_KEY, S3_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY S3_SECRETKEY

Two values differ from the platform deliberately:

  • S3_ENDPOINT keeps the value you set, by default the bare host localhost, where the platform delivers the endpoint origin. Read AWS_ENDPOINT_URL for the origin; it means the same locally and on the platform.
  • The local MongoDB credential is the root user in admin, so the URIs authenticate with authSource=admin where the platform names the application database. The path names the database in both. MONGODB_URL gained directConnection=true in the same release, matching the standalone NoSQLDB node; its value is otherwise unchanged.

S3_ENDPOINT is never rewritten, and every non-empty value loads. The origin is resolved from it in one of three forms:

  • a bare host or IP address (localhost, ::1): the scheme comes from S3_USESSL and the port from S3_PORT;
  • host:port or [ipv6]:port (minio.local:9000): its port wins over S3_PORT, and the scheme comes from S3_USESSL;
  • an origin, http(s)://host[:port] (https://s3.example.test): a complete origin, so its scheme wins over S3_USESSL and its port, or the scheme's default port, over S3_PORT.

The host is kept as written, so IPv4 shorthand such as 127.1 and IPv4-mapped IPv6 addresses are not rewritten. User info, a path, a query, a fragment, a scheme other than http/https, and a port outside 1-65535 are not part of an origin and are not carried over; an endpoint without a host, such as :9000, takes its host from S3_HOST. S3_PORT stays the managed ObjectStorage's local API port: GitZone reconciles the local instance through 127.0.0.1 and that port whatever S3_ENDPOINT says, so when S3_ENDPOINT carries its own port or is an origin, S3_PORT is not the port of AWS_ENDPOINT_URL.

While MongoDB authentication is disabled, the URIs carry no credentials and the four credential names (MONGODB_USERNAME, MONGO_DBUSER, MONGODB_PASSWORD, MONGO_DBPASS) are absent; MONGODB_USER and MONGODB_PASS are kept for when it is enabled again. gitzone services config --json replaces every platform name that carries a credential (MONGODB_URI, MONGO_URL, the password pair, and the six S3 key names) with "***"; MONGODB_URL and MONGODB_PASS keep their earlier, unredacted output.

Migrating legacy MinIO state

Persisted minio and s3 service selections are rewritten to the canonical objectstorage value. S3_CONSOLE_PORT is migrated to S3_UI_PORT when the values do not conflict. The runtime migration also adds the separate admin password and region fields and repairs only the exact invalid bucket name that older GitZone versions generated. Legacy registry and data-marker entries are retained as preserved migration evidence, not converted into active ObjectStorage ownership. Every selected migration store is preflighted before the first write. Closed-schema service selection, marker, and registry stores reject malformed, conflicting, foreign, or future state without rewriting it. Runtime config validates and migrates its known fields while preserving unknown application fields.

If <project>-minio, a registry-retained alternate legacy container name, or .nogit/miniodata still exists, ObjectStorage startup stops before mutating the ObjectStorage container. In an untargeted services start, an earlier enabled service such as MongoDB may already have started. GitZone never reuses, removes, cleans, or prunes legacy MinIO state because its disk layout is not compatible with ObjectStorage. Recovery is explicit:

  1. Export every required bucket with the existing MinIO tooling.
  2. Stop and rename the legacy container, then move .nogit/miniodata to a preserved backup location.
  3. Run gitzone services start s3 to create the canonical service.
  4. Import through an external S3 client and verify the required objects before disposing of the backup.

An existing .nogit/objectstoragedata directory without a valid ownership marker blocks creation of a new container and direct cleanup. One exception is an existing container whose immutable identity and exact canonical GitZone labels prove that directory belongs to this project; startup or removal repairs the missing marker before proceeding. An invalid or foreign marker always blocks mutation. Without that exact container proof, preserve or move the directory, verify its provenance outside GitZone, start a fresh canonical service, and import required objects through S3. Do not synthesize a marker for unverified data.

Migrating legacy MongoDB (mongod) state

Releases before NoSQLDB ran mongo:7.0 as <project>-mongodb on .nogit/mongodata. The persisted-state migrations move the registry's containers.mongo claim to legacy.mongoContainer and the marker's mongod directory to a safeToPrune: false legacy entry; neither is ever treated as NoSQLDB ownership.

gitzone services start (and the explicit gitzone services migrate mongodb, which asks first unless --yes is given) migrates the data automatically when ownership is proven: the container carries the exact legacy GitZone labels for this project (or has no GitZone labels and exactly one legacy registry claim), runs mongo:7.0, and binds exactly .nogit/mongodata at /data/db. Data whose container is gone is proven by the marker's legacy entry or a legacy registry claim.

The migration journals every step in .nogit/.gitzone-mongodb-migration.json:

  1. Both images are present before anything is touched.
  2. Freeze: the legacy container is stopped (by immutable ID, with a 60-second clean-shutdown grace) before anything reads its data, so no application write can land after the snapshot.
  3. A temporary reader, <project>-mongodb-migration-<token>, opens .nogit/mongodata with the legacy container's exact image ID. It runs standalone (no --replSet, so the stored replica-set configuration is never consulted) with recoverFromOplogAsStandalone, which replays the oplog like the replica-set member would and then keeps the node read-only; data that never ran as a replica set is opened as a plain standalone. The reader has no network, no published port, restart policy no, and no access control: only short-lived fenced helpers that join its network namespace can reach it.
  4. From the reader, an inventory (every non-system namespace with document count and index names) and a mongodump --archive are taken. MongoDB views are refused before the dump with an error naming every view: NoSQLDB has no general views. The reader is then removed.
  5. The NoSQLDB container is created on a new .nogit/nosqldbdata with no network, no published port, and restart policy no, and mongorestore restores everything except admin.*, config.*, and local.* with --stopOnError. Credentials reach the tools only through the environment and a private config file, never argv.
  6. The NoSQLDB inventory must match the legacy inventory exactly. The restore-phase container is stopped, and only then does the journal move from running to committing.
  7. The restore-phase container is replaced by the published service container (loopback port, unless-stopped) on the same data, the legacy container is removed by immutable ID together with its anonymous /data/configdb volume, and the journal records completed. The bind-mounted .nogit/mongodata is never deleted. An interrupted commit resumes on the next start.

Invariant: while the journal is running, no application has ever been able to reach the NoSQLDB data, so it contains only migration writes. Any failure before the commit therefore rolls back safely: the reader and the NoSQLDB container and its data directory are removed, the legacy container is started again by its immutable ID if it was running, and the error carries the redacted tool output. An interrupted migration is rolled back on the next start. Other features NoSQLDB does not support (for example collations, text or geo indexes) surface as restore or verification failures rather than being dropped.

Every attempt writes its evidence to its own .nogit/mongodb-migration/<timestamp>-<token>/: the helper scripts (inventory.js, readiness.js), the inventories (source-inventory.json, target-inventory.json), the tool logs (source-inventory.log, dump.log, target-readiness.log, restore.log, target-inventory.log), and dump.archive. Each file exists only if its step ran. A rollback deletes only dump.archive, a derived copy of data that stays in .nogit/mongodata, and keeps the scripts, inventories, and logs of the failed attempt. After a completed migration, the whole directory, dump.archive included, is kept. .nogit/mongodata and .nogit/mongodb-migration/ are never deleted by any GitZone command, including clean and prune. Remove them manually once the migrated data is confirmed. The journal stays as a permanent completion record, so a later clean never re-imports the old data. If ownership cannot be proven, startup stops before any Docker mutation and prints the manual recovery steps. Projects that never run the new CLI keep their running legacy container untouched. gitzone services stop and stop -g may stop a proven-owned legacy container; nothing else acts on it.

Consuming a service programmatically

gitzone services status --json emits only JSON on stdout, so a test suite or script can read a live connection string without parsing human output:

gitzone services status --json | jq -r '.services.mongodb.connectionString'

The ObjectStorage status key is .services.objectstorage; the former .services.minio key no longer exists. Preserved predecessor evidence is reported separately under .legacy. minioDataDirectory, minioDataExists, mongoDataDirectory, mongoDataExists, mongoMigrationDirectory, and mongoMigrationDirectoryExists are always emitted; minioContainer and mongoContainer are present only when container evidence exists. Consumers must treat this as a breaking response-schema change rather than interpreting legacy evidence as an active service.

Cleanup levels

Cleanup is tiered, from fully resumable to irreversible:

Command Containers Data Notes
gitzone services stop kept (stopped) kept fully resumable
gitzone services remove removed kept resumable; --yes to skip the prompt
gitzone services clean removed removed irreversible; needs a typed yes or --yes
gitzone services prune see below see below machine-wide; dry run unless --apply

clean and prune first persist an exact deletion intent, then atomically rename the canonical directory to a tokenized sibling quarantine before deleting any contents. Native deletion is attempted there. If container-owned files remain, GitZone uses a short-lived root container scoped to that quarantine; Docker's --privileged mode is not used. The helper reserves one deterministic name per bind target, carries a random invocation label, is created stopped, and is inspected by immutable ID before execution or cleanup. Old stopped helpers, whether still created or already exited, can be recovered only after their complete runtime envelope, bind target, age, and immutable ID are proven; running, fresh, or noncanonical occupants block the operation.

A helper carries exactly one read-write bind of its target. Images such as mongo:7.0 additionally declare their own VOLUME destinations, for which Docker attaches anonymous volumes GitZone never requested; those are accepted only where Docker proves them image-declared and anonymous, namely the local driver, a random 64-character volume name, and a destination the container's own image configuration declares outside the bind destination. A named volume, a second bind, a read-only target bind, or a volume at any other destination leaves the helper unproven and blocks the operation. Whenever a helper is proven — after a successful run, when an old helper is reclaimed, and on the rollback path of a failed run — it is removed together with its anonymous volumes; a named volume is never removed this way. A helper whose closing inspection refuses it or times out is left in place and reported as cleanup that could not be confirmed; it keeps blocking the operation until a later run proves it.

If interruption or helper failure leaves bytes, the persisted intent lets the next clean or prune --apply resume the same quarantine. A partial failure therefore never leaves a corrupt directory at the canonical service path. Startup refuses to create fresh service data while such an intent remains.

Service start, stop, container removal, and data removal share one interprocess lock per project and service. Startup refreshes runtime config and project activity inside that lock, while prune rechecks activity and stopped as well as running service containers after acquiring it. Concurrent lifecycle commands therefore serialize rather than deleting a directory or container while another command prepares, mounts, starts, or stops it.

Reclaiming space across projects

Service data is per project and survives container removal, so it accumulates. gitzone services prune reports what every registered project holds and what can be reclaimed. It is read-only unless --apply is passed:

# Report only: what exists, what is reclaimable, and why
gitzone services prune

# Change the inactivity threshold (default 30 days)
gitzone services prune --stale-days 90

# Actually reclaim, non-interactively
gitzone services prune --apply --yes

A project is only a candidate when there is positive evidence it is finished with: its directory is gone, or it has been inactive past the threshold with no container running. Anything ambiguous — an unlabeled container claimed by more than one project, an unreachable Docker daemon, a directory still mounted by a running container — is reported and skipped rather than reclaimed. Containers are identified by an exact canonical GitZone label envelope. An unambiguous registry claim is used only for older Elasticsearch containers with no git.zone.* labels at all. NoSQLDB and ObjectStorage have no unlabeled predecessor fallback, and their data is reclaimable only with marker proof; partial, missing, or conflicting GitZone labels fail closed. Removal revalidates and uses the container's immutable ID, never its mutable name, so pruning cannot touch a same-name replacement. If Docker becomes unavailable, registry claims are preserved as well as containers and data.

Legacy MinIO and mongod containers are reported as preserved migration resources and are never prune candidates. A legacy MinIO registry reference is kept permanently. A legacy mongod registry reference is dropped together with its project's registry entry once the project directory is gone and no container with that name remains, because it can then identify nothing; while such a container exists, the entry is kept so the container stays identifiable. .nogit/miniodata, .nogit/mongodata, and .nogit/mongodb-migration are deliberately outside the prune allowlist; current-project status reports their presence, while machine-wide prune leaves them untouched.

MongoDB authentication

NoSQLDB runs with SCRAM-SHA-256 authentication enabled; the root user is MONGODB_USER/MONGODB_PASS in the admin database. Authentication can be disabled per project for clients that cannot complete a SCRAM handshake:

gitzone services auth mongodb off
gitzone services start mongo

This is opt-in and never implicit. The database is always published on 127.0.0.1 only, and the combination of no authentication with a non-local MONGODB_HOST is refused outright. Transactions continue to work, and gitzone services status reports the mode. Changing the mode recreates the container with the matching engine configuration.

The setting is recorded in .smartconfig.json, so it is committed and a fresh clone or CI run reproduces it without any manual step:

{
  "@git.zone/cli": {
    "services": ["mongodb"],
    "serviceOptions": {
      "mongodb": { "auth": false }
    }
  }
}

serviceOptions is a sibling of services, never a richer services value. services must stay a flat array of canonical lowercase strings because @git.zone/tsdeploy derives a workload's requiredCapabilities from it and rejects any other shape.

A committed declaration takes precedence over .nogit/env.json, so a stale local file cannot silently diverge from what the repository declares. When nothing is declared, an existing local value is preserved. When neither exists, authentication is enabled. Because a declaration affects everyone who clones the repository, services status states where the setting came from:

⚠️  Auth: DISABLED, declared in .smartconfig.json (applies to every checkout)

An older CLI that predates serviceOptions ignores the key and starts MongoDB with authentication enabled — it degrades to the secure default, never the insecure one.

Checking which version is running

gitzone --version prints the bare version on the first line, followed by the path it resolved from. A stale copy in a legacy pnpm global root can otherwise make it look like an older version is installed when it is not:

gitzone --version
# 3.2.1
# resolved from: /home/you/.local/share/pnpm/store/v11/links/@git.zone/cli/3.2.1/…

gitzone --version --json
# {"version":"3.2.1","resolvedFrom":"…"}

gitzone tools update also removes inert copies of managed packages left behind in legacy global roots, provided the active root already supplies them and no command shim still points there.

Approved Testing Domains

gitzone testing uses the testing API in dcrouter 19.1.1 or later. An operator first enables an explicit testing-zone policy, provisions a machine registration, and approves each exact hostname requested by that registration. Approved grants survive credential rotation. Wildcards, parent domains, arbitrary TXT records, and production routes are outside this command's authority.

Inject GITZONE_TESTING_BASE_URL and GITZONE_TESTING_TOKEN from your environment or secret manager. Admin commands select GITZONE_TESTING_ADMIN_TOKEN instead. The URL must be an HTTPS origin; plain HTTP is accepted only on loopback for isolated tests. Credentials are never accepted as command arguments or saved to project/global configuration. --token-stdin reads at most 8192 bytes from a non-terminal stdin and requires the selected environment credential to be absent.

gitzone testing --help
gitzone help testing --json
gitzone testing whoami --json
gitzone testing zone list --limit 25 --json
gitzone testing grant request --zone ZONE_ID --hostname alice.testing.example.com --idempotency-key grant-alice-1 --json
gitzone testing grant status --grant GRANT_ID --json
gitzone testing cert ensure --grant GRANT_ID --idempotency-key cert-alice-1 --wait --json
gitzone testing cert status --job JOB_ID --json

Mutation keys are supplied explicitly. Repeat the same key and payload after a lost response; use a new key only for a new operation. Certificate requests reuse usable cached material. --wait follows queued/running/retry-wait jobs while respecting their polling and retry times. --timeout-ms bounds acquisition and requests (default five minutes, maximum one hour). A local deadline or signal does not cancel durable server work. Poll the returned operation ID or repeat the original idempotency key to find its outcome. Unknown or abandoned outcomes require operator recovery and never trigger automatic mutation resubmission.

Run a development server with disposable certificate files:

gitzone testing cert run --grant GRANT_ID --idempotency-key cert-alice-1 -- node server.js --port 8443

The child receives GITZONE_TESTING_CERTIFICATE_FILE, GITZONE_TESTING_PRIVATE_KEY_FILE, and GITZONE_TESTING_HOSTNAME. Its environment excludes the parent's GITZONE_TESTING_* values, including both credentials. The directory has mode 0700 and both files have mode 0600. GitZone invokes the program directly, preserves every argument after --, returns its exit status, and removes the directory after exit or termination. SIGINT/SIGTERM are forwarded to the child; a child that does not stop is killed after five seconds. JSON output is rejected for cert run because child stdout is inherited. Certificate renewal is on demand: this command obtains material once; restart it to obtain renewed material when due. It does not save a permanent certificate cache.

DNS operations are restricted to records owned by the approved grant. Use new for creation and the current record revision for replacement/deletion:

gitzone testing dns upsert --grant GRANT_ID --record-key web-v4 --revision new --type A --value 192.0.2.10 --ttl 60 --idempotency-key dns-web-1 --wait --json
gitzone testing dns list --grant GRANT_ID --json
gitzone testing dns delete --grant GRANT_ID --record-key web-v4 --revision 1 --idempotency-key dns-delete-1 --wait --json
gitzone testing dns status --mutation MUTATION_ID --json

Admin enrollment and rotation require a caller-preopened writable non-terminal descriptor of at least 3. Supply it through the parent secret-manager integration; GitZone writes exactly the new token plus a newline to that descriptor, and only registration metadata to ordinary output. No credential can be retrieved again after a lost delivery; list registrations and rotate the relevant credential.

# Descriptor 3 must already be connected to the caller's secret store.
gitzone testing admin registration create --name developer-alice --expires-at 2027-01-01T00:00:00Z --idempotency-key enroll-alice-1 --secret-fd 3 --json
gitzone testing admin registration list --json
gitzone testing admin registration rotate --registration REGISTRATION_ID --generation 1 --expires-at 2027-01-01T00:00:00Z --secret-fd 3 --json
gitzone testing admin zone put --domain DOMAIN_ID --revision new --enabled true --record-types A,AAAA,CNAME --min-ttl 60 --max-ttl 3600 --max-grants 25 --json
gitzone testing admin grant list --state pending --json
gitzone testing admin grant review --grant GRANT_ID --revision 1 --action approve --reason 'Approved development hostname' --json

admin registration revoke and admin grant review --action revoke require a revision and reason. Revocation blocks future access and schedules cleanup of proven-owned DNS records; already delivered keys cannot be recalled. Operator recovery remains revision checked and audited:

gitzone testing admin recovery list --kind certificate --state indeterminate --json
gitzone testing admin recovery status --kind certificate --operation JOB_ID --json
gitzone testing admin recovery action --kind certificate --operation JOB_ID --revision 3 --action recheck --reason 'Inspect interrupted issuance' --idempotency-key recovery-1 --json

For DNS recovery, --operation may identify a mutation or a grant's retained cleanup operation. Only actions listed by the server are supported. Abandonment retains unknown effects and their reservations; it does not make them safe to repeat. Lists accept --after and --limit (25 by default, at most 100) and return nextAfterId. Exit codes are 0 for success/admitted work, 1 for failure or an unresolved terminal operation, 2 for invalid input, and 130/143 for interruption.

Templates

Start new projects with built-in scaffolds:

gitzone template npm
gitzone template service
gitzone template website
gitzone template wcc
gitzone template rust

Templates are rendered through SmartScaf and then can be normalized with gitzone format. The TypeScript templates (npm, service, website, wcc) develop with @git.zone/tsrepo: they declare it as their one development tool and call tsrepo build, tsrepo test, tsrepo bundle, tsrepo watch and tsrepo docs instead of tsbuild, tstest, tsbundle, tswatch and tsdoc (tsrepo watch asks for its preset, such as website or element, on the first run and stores it in .smartconfig.json); test files import tap from @git.zone/tsrepo/tapbundle, and a scaffolded cli.ts.js runs through @git.zone/tsrepo/run. The rust template scaffolds a Cargo workspace for @git.zone/rsrepo and gitzone releases; see Rust workspaces and the cargo target. It runs cargo generate-lockfile after writing the files, so commit the resulting Cargo.lock.

Meta Repositories

Use gitzone meta when one workspace coordinates multiple repositories.

gitzone meta init
gitzone meta add frontend https://example.com/org/frontend.git
gitzone meta update
gitzone meta remove frontend

Other Utilities

Docker resources

gitzone docker prune reports Docker containers and volumes created by git.zone tooling, and reclaims them only when asked:

gitzone docker prune                    # report only
gitzone docker prune --apply            # remove stopped tool-owned containers
gitzone docker prune --volumes          # include tool-owned volumes in the report
gitzone docker prune --volumes --apply --yes

Scope is an allowlist: only resources labeled git.zone.tool=<tool> and git.zone.safe-to-prune=true are ever considered. Anything unlabeled is invisible to the command. Running containers and attached volumes are never removed, and images are never removed at all. Volumes hold persisted data, so they are excluded unless --volumes is passed and require a typed yes or --yes on top of --apply.

This command deliberately cannot prune the whole machine. To do that, run docker directly so the blast radius is explicit and yours.

Other utilities

# Open GitLab CI settings or pipelines for the current repo
gitzone open ci
gitzone open pipelines

# Deprecate an old npm package interactively
gitzone deprecate

# Preview an explicit deprecation across both registries
gitzone deprecate --package @example/old --replacement @example/new --registries https://registry.npmjs.org,https://mirror.example.test --plan

# Preview a deprecation for a package without a successor
gitzone deprecate --package @example/old --registries https://registry.npmjs.org,https://mirror.example.test --message "@example/old is no longer maintained; use the platform fetch API instead." --plan

# Prepare a project for local work
gitzone start

# Generate a short unique ID
gitzone helpers shortid

For explicit deprecation, replace --plan with -y to apply the reviewed operation. --message supplies migration instructions; otherwise the message names the replacement. A package without an honest successor is deprecated without --replacement; --message is then required and should say what users do instead, and the plan shows replacement: none (--json: "replacement": null). Interactive mode accepts an empty replacement and then asks for the message. Deprecation applies to every version, prereleases included: GitZone passes pnpm deprecate the bare package name, never a range such as *, which would skip prereleases. GitZone verifies that the old package exists on all registries, and that the replacement, when named, has an active latest version there, before issuing any change, invokes pnpm deprecate with literal arguments, and verifies the old versions' metadata after each registry operation. Every metadata read carries a unique cache-busting query, because the CDN in front of npmjs ignores no-cache request headers. Because npmjs serves an accepted deprecation only after replication, verification waits up to ninety seconds per registry, probing with doubling backoff from two seconds up to twenty and reporting each wait. If the window ends first, GitZone stops before the next registry and says that rerunning is safe. A rerun skips any registry where every version already has the exact requested message, so accepted deprecations are not submitted again. --json prints the plan without changing it.

Developing GitZone

From this repository, cli.ts.js runs the TypeScript sources through the project's @git.zone/tsrepo/run. cli.js runs the built dist_ts output. Child GitZone processes retain the same entry point as their parent.

After committing and verifying a CLI correction on clean, integrated main, maintainers can use the source entry point to release that correction when the globally installed CLI is affected by the defect:

node cli.ts.js release --plan
node cli.ts.js release -y

This runs the configured release transaction, including its preflight and publication targets. The usual release authorization and checks still apply.

Troubleshooting

Format only previews changes:

gitzone format --write

Release says there is nothing to release:

# Make sure commits have populated the Pending changelog section
gitzone commit

Packing fails while reading an excluded local service-data directory:

Use a clean release checkout with dependencies installed from the committed lockfile. pnpm's packlist traversal can visit local data before applying the package's files list; .npmignore does not prevent that traversal when files is present. Keep service data and its permissions intact.

Inspect the release journal before retrying. An installed journal must be resumed with its original artifacts. A failure after the release tag but before journal installation has not started final publication; gitzone release resume <version> -y prepares the journal from the recorded intent and publishes. A later source correction can be released as a new version from the clean checkout.

A release step fails on a Git, pnpm or tSDocker command:

The error names the step, then the command and how it ended, then the end of its error output on one line:

Unable to inspect main at the configured Git push destination. Step: publication state check. `git ls-remote` timed out after 180 s and was terminated by SIGTERM; gave up after 8 attempts over 1560.4 s

The retries bound each step, not the release: a remote that hangs rather than refusing can hold one ls-remote step for about 26 minutes (8 × 180 s plus 120 s of waits) and one fetch or push for about 42 minutes (8 × 300 s plus waits, and a push re-reads the remote between attempts).

Every git ls-remote of a release — the preflight before the tests, the check after them, the check before each publication step and the Git target's own probe — runs under release.targets.git.remoteTimeoutSeconds (default 180, at most 3600) per attempt; git fetch and git push keep their five-minute bound per attempt. The preflight reads the remote before the configured tests run, so a remote that is down fails the release before a long test run; the check immediately before the push stays authoritative.

Transient remote failures are retried, so a Git server that drops connections does not end a release. A timeout, an ssh connection closed by the remote host (Connection to <host> closed by remote host., Connection closed by <address> port <n>, kex_exchange_identification: Connection closed by remote host) or refused by it (ssh: connect to host <host> port <n>: Connection refused), a reset, timed-out or unexpectedly ended connection (`Connection reset by

port `, `Connection timed out during banner exchange`, `send-pack: unexpected disconnect while reading sideband packet`), and an HTTP gateway error (502, 503, 504) count as transient; refused authentication, denied permission, a missing repository, a rejected update and Git's own answers are final at once. A refused connection is retried because a restarting or overloaded server refuses too; a wrong port therefore fails only once the attempts run out. Remote reads (`ls-remote`, and `fetch` for inspection or verification) are simply repeated. A push is never repeated blindly: after a transient failure gitzone reads the remote again, and that read is retried like any other. When the pushed refs are already there, the push is done; when the remote is unchanged, it pushes again under the same lease; when the read still fails after all its attempts, or finds other refs, it stops and the usual verification decides. Each push of the Git target is journaled as its own attempt. A step makes at most eight attempts, waiting 2, 4, 8 and 16 s and then 30 s three times, two minutes in all. Every retry is logged as a warning that names the step, the command, the classified reason and the attempt. When the attempts run out, the error names the release step, the per-attempt bound, and the attempts and time spent. A failure after the release tag is resumable with `gitzone release resume -y`. Check the remote with the same command, for example `git ls-remote refs/heads/main`, and retry once it answers promptly. An exit code comes with Git's own message, such as `does not appear to be a git repository` for a wrong push URL. The quoted output is shortened to its last 500 characters, and credentials in URLs (`https://user:password@host` becomes `https://***@host`), npm `_authToken` settings and `Authorization` headers are replaced by `***`.

Docker services fail to start:

docker info
gitzone services status
gitzone services reconfigure

Config looks outdated:

gitzone config migrate 2
gitzone config show --json

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
the main git.zone cli
Readme
31 MiB
Languages
TypeScript 97%
JavaScript 2%
Shell 0.9%
HTML 0.1%