Chapters

DOC-04 / Technical reference · Chapter 09

Deployment & infrastructure of Synedre OS

This chapter describes the release pipeline of Synedre OS and its tenants, detailing the asymmetry between `./deploy` (preprod, AI-driven) and `./ship` (production, gated by the `auto_ship_allowed` fleet), as well as associated mechanisms such as drift auto-apply, post-deployment smokes, and founder exceptions without preprod.

Deployment & infrastructure

This section describes the release pipeline for Synedre OS and its tenants: the asymmetry between the two main entry points, the configuration-driven dispatcher, the off-VPS build pattern followed by archive transfer, the special case of the mothership self-deploy, the single inventory source, the secrets doctrine, and the commit-before-deploy rule.

1. ./deploy vs ./ship asymmetry

Two entry points coexist at the repository root. They do not target the same environment and do not have the same owner.

./deploy ./ship
Target Pre-production (or live runtime of the mothership / PROD for founder sites with no pre-prod) Production
Owner AI / worker / cascade, systematic Gated by the fleet: AI/tick if the tenant allows automatic ship, human otherwise
Git Auto-commit of dirty state, stays on preprod branch Switches to main → pull → merge preprod (theirs strategy) → push, then return to preprod guaranteed by an EXIT trap
Dirty safeguard Silent auto-commit; hard refusal if already on main Blocking: refuses if the working tree is dirty, unless --allow-dirty is set
Schema drift check Single hardwired pre-prod target; any other tenant → explicit skip. Bypassable via --skip-drift Two targets (central hub + tenant prod DB if known). No --skip-drift equivalent: central hub drift is strictly blocking
Automatic drift application The drift engine generates and applies missing idempotent DDL statements in a single transaction (CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN IF NOT EXISTS). Active by default; can be disabled on a per-run basis. Applied before any build or reload. Not applicable (drift is applied manually before the ship)
Post-deployment smoke Functional smoke, followed by a non-blocking visual smoke. Each can be disabled independently. PM2 environment check (false-positive guard) then functional smoke including JSON content checks
DB migrations — Displays pending .sql files according to scope; non-blocking, not auto-applied — no runner is connected
Closing step — Offers to close test-type worksites after the ship

Doctrine:

  • ./ship <tenant> targets production, gated by the fleet inventory table (field allowing or disallowing automatic ship). At night, an unattended ship additionally requires green QA evidence.
  • ./deploy <tenant> is always triggered by the AI, systematically and without prompting — including on the mothership where ./deploy rebuilds the live runtime (no dedicated pre-prod).
        ┌─────────────┐   ./deploy <tenant>    ┌──────────────┐
  AI →  │  preprod     │ ────────────────────→  │  pre-prod /   │
        │  branch      │   (auto, N times)       │  live runtime │
        └─────────────┘                          └──────────────┘
                │
                │  Pre-prod review validated
                ▼
        ┌─────────────┐   ./ship <tenant>      ┌──────────────┐
 ship → │ merge preprod│ ────────────────────→  │  PRODUCTION   │
        │   → main     │   (fleet-gated)         │              │
        └─────────────┘                          └──────────────┘

Exception — founder's personal public site: ./deploy is refused for this tenant — the site lives directly on the CodeMyShop VPS and has no pre-prod. Any change goes through ./ship only.

Exception — founder tenants with no pre-prod (synedre, a hosting tenant, codemyshop): ./deploy on these tenants points directly to production. The usual boundary deploy = pre-prod does not hold for them — the AI triggers ./deploy but it modifies production. This is intentional: no dedicated pre-prod exists for founder sites. ./ship remains available (preprod → main merge + full ceremony) and is also gated by the fleet.

1b. ./ship pipeline detail (numbered steps)

./ship <tenant> runs the following steps in order:

  1. Option parsing: --allow-dirty (bypasses the dirty safeguard), --no-close (skips the post-ship step).
  2. Blocking dirty safeguard: checks Git working tree cleanliness; exits with an error if dirty, unless --allow-dirty is set.
  3. EXIT cleanup trap: return to preprod guaranteed even on error; a checkout failure is reported loudly (to avoid getting stuck on main).
  4. Step 0 — Root artifact cleanup: removes root-owned output directories left by previous deployments that would otherwise block writes.
  5. Step 0b — Central hub schema drift check: blocking, with no bypass possible. Step 0c: tenant prod DB drift if the target is known.
  6. Step 1 — preprod → main merge: checkout main, pull, merge with theirs strategy, push to origin.
  7. Step 2 — DB migrations: see §1c below.
  8. Step 3 — Agent audit: non-blocking.
  9. Step 4 — Prod deployment: call to the generic dispatcher with the tenant and the --all flag.
  10. Step 5 — Prod smoke: PM2 environment check before the functional smoke. The smoke includes HTTP and JSON content checks.
  11. Step 6 — Post-ship: offers to close test-type worksites (skippable via --no-close).

--skip-drift asymmetry: ./deploy accepts this option to bypass the drift check in pre-prod (urgent CI hotfix). ./ship has no equivalent — central hub drift is strictly blocking there: shipping to production on a divergent schema is never allowed.

1c. DB migrations during ship

The ship displays pending .sql files but never applies them automatically: the automatic runner has been disabled and no PostgreSQL runner is connected. The block is purely informational and non-blocking — it prints the commands to be executed manually.

A tenant → migration scope mapping prevents mothership migrations from being mixed with tenant migrations:

Tenant (argument) Scope → migrations/<scope>/ folder
Mothership / central hub mothership
E-commerce tenant A Dedicated scope A
E-commerce tenant B Dedicated scope B
Hosting tenant Dedicated scope
Founder personal site Dedicated scope
CodeMyShop Dedicated scope
all Special case: aggregates all scopes
Other Fallback: scope = tenant name

Special case all: aggregates all .sql files from every migration sub-folder, excluding _applied/ and applied/ sub-folders (already-run migrations). Other scopes simply list the .sql files in the corresponding folder.

2. ./deploy and ./ship routing by tenant

The dispatcher resolves each tenant to a dedicated script or to the generic deployment dispatcher.

Tenant (argument) ./deploy → ./ship →
Mothership (synedre-os, default) Health lock check then dedicated mothership deployment script (--prod --all), or host-build variant if HOST_BUILD=1 Same dedicated mothership script (--prod --all)
Founder personal site Refused — no pre-prod Generic dispatcher (--all)
Synedre public site Generic dispatcher → direct PROD Generic dispatcher ⚠️ — PM2 check potentially blocking if the tenant is not declared in the check script (latent bug)
CodeMyShop Generic dispatcher → direct PROD (no dedicated pre-prod anymore); smoke enabled Generic dispatcher
CodeMyShop demo Generic dispatcher → demo staging —
E-commerce tenant A Generic dispatcher → --target=preprod; smoke disabled (pre-prod behind basic auth) Generic dispatcher without --target → prod
E-commerce tenant B Generic dispatcher; smoke enabled ⚠️ Falls through to the default case → legacy Ansible script likely to error (no known prod VPS). Direct aliases for this tenant trigger a "No prod" message.
Hosting tenant Generic dispatcher → PROD; smoke enabled Generic dispatcher
all 5 parallel background jobs: mothership, CodeMyShop (PROD), e-commerce tenant A (pre-prod), e-commerce tenant B, hosting tenant. Post-jobs smoke across all targets. ⚠️ The all smoke tests the CodeMyShop demo, not the prod storefront. 3 parallel jobs: mothership + founder site + CodeMyShop. Smoke across these three targets.
--list / -l Displays the fleet inventory table contents (see §6) —
Other (unknown tenant) Fallback: Ansible script → pre-prod Fallback: Ansible script → prod

Convergence toward a single dispatcher: since the deployment pattern was standardised and legacy scripts were purged, all remote tenants except the mothership go through the generic dispatcher, for both ./deploy and ./ship. The mothership retains its dedicated script because self-deploy is architecturally distinct (local Docker build, no network transfer).

Known inconsistency — ./deploy all: the deployment targets the CodeMyShop storefront in production, but the subsequent smoke only tests the demo (staging). The codemyshop.com storefront is not verified in this path — a point to fix in the all mode smoke loop.

The YAML-driven dispatcher

The standard deployment pipeline — used for all remote tenants except the mothership itself — is driven by a central bash script that receives the tenant name, an optional target, and behaviour flags as arguments. Its complete specification is maintained in a dedicated YAML schema document, regularly enriched with canonical examples covering Docker, PM2, and multi-target variants.

Configuration file resolution

At invocation, the dispatcher resolves the YAML file to use according to straightforward logic:

  • If the tenant designates the mothership, the dispatcher attempts to load a dedicated configuration file — this branch is documented but inert in practice: no matching file exists on disk. Mothership deployment is in fact handled upstream by a separate autonomous pipeline (self-deployment), never by this dispatcher.
  • For any other tenant, the file loaded is the deploy.yaml (or deploy.<target>.yaml if a target is specified) located in the configuration directory of the tenant in question.

Note: The short name synedre (without suffix) designates an ordinary client tenant and follows the standard tenant path — it does not trigger the mothership branch.

Pipeline steps

  1. Argument parsing: the dispatcher interprets the optional target (with the mandatory = separator), the full-run flag (mandatory), and the cache-clear flag (optional).
  2. YAML validation and resolution: a Python validator loads the YAML file, checks the consistency of all sections, and emits shell environment variables that the dispatcher evaluates. Any validation error causes immediate termination.
  3. Standard flag parsing: extraction of the CLEAN_CACHE option and the full-run flag.
  4. Startup banner display and deployment timer initialisation.
  5. Schema drift check: if the drift section is present in the YAML, the dispatcher compares the actual state of the database against the expected schema.
  6. Automatic drift application: missing idempotent DDL statements (CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN IF NOT EXISTS) are generated and applied in a single transaction, before any build or reload. This behaviour is active by default and can be disabled on a per-run basis via a dedicated environment variable.
  7. Background hook launch: notably the active-agent audit.
  8. i18n route generation (pre-build): if the seed_i18n section is present, the dispatcher queries the tenant's category and localised metadata tables (via SSH/Docker) to produce the JSON file of localised route segments consumed by the build engine. This step is non-blocking: if the database is unreachable, the build falls back to hard-coded values.
  9. Nuxt build: front-end compilation.
  10. i18n seed (if applicable): population of translation data post-build.
  11. Source maps (if the corresponding hook is enabled): source map upload.
  12. Packaging: creation of a tar archive in the local temporary directory.
  13. Background Git push (if the hook is enabled).
  14. Archive upload to the client VPS via secure copy.
  15. Remote reload, according to the variant declared in the YAML:
    • pm2: graceful reload or forced restart.
    • docker: service restart via Docker Compose.
  16. Recurring task installation (if the cron section is present): the tenant's scheduled tasks are written to the crontab of the SSH user on the client VPS — never on the mothership. Each block is marked with named idempotent delimiters: at each deployment, the old block is removed and rewritten. An empty list triggers a full cleanup of the block. Doctrine: recurring tasks run as close as possible to their database, on the machine hosting the service.
  17. Background hook wait (Git push, agent audit).
  18. Health check loop: polling the health URL until a valid response is obtained or the maximum timeout expires.
  19. End banner display with total elapsed time.

Structure of the deploy.yaml file

Each tenant has a YAML file describing its complete pipeline. The Python validator checks the consistency of all sections before any execution. The main sections are:

Section Required Description
name Yes Kebab-case deployment identifier (regex ^[a-z0-9][a-z0-9_-]*$).
build Yes Local client path (local_client, required) and Node environment (node_env, default production).
ssh Yes Remote host (required), SSH user (default ubuntu), optional key.
remote Yes Deployment variant (pm2 or docker) and associated parameters.
health Yes Health URL (required) and maximum wait timeout (default 45 seconds).
drift No Configuration for schema drift checking and automatic application.
seed_i18n No Parameters for i18n route generation and seeding.
hooks No Optional hooks: agent audit, source map upload, Git push.
cron No List of recurring tasks to install on the client VPS (key, schedule, command, comment).

Per-variant consistency guardrails

The validator enforces mutually exclusive constraints depending on the chosen variant:

  • pm2 variant: the PM2 application name is required; Docker-specific fields (container, subdirectory) are forbidden.
  • docker variant: the container name is required; PM2-specific fields are forbidden.

Validation of the cron section

Each recurring task entry is validated individually: the key must be in kebab-case, the time expression must contain exactly five crontab fields, and the command cannot be empty. The section is emitted even when empty — an empty array signals the installer to fully clean the tenant's crontab block on the remote VPS.

Configuration example

Below is the structure of a representative deploy.yaml file for a tenant using the PM2 variant:

name: <tenant-name>
build:
  local_client: codemyshop/tenants/<tenant-name>
  node_env: production
ssh:
  host: <client-vps-address>
remote:
  variant: pm2
  dir: /var/www/codemyshop/app/codemyshop/tenants/<tenant-name>
  pm2_app: <pm2-app-name>
  pm2_remote_user: <pm2-user>
health:
  url: https://<tenant-domain>
hooks:
  git_push: <tenant-name>

Pattern: build on the mother ship, transfer and remote reload

The central invariant for deployments to a remote VPS is: the VPS never compiles. The application bundle is compiled on the mother ship, compressed into an archive, transferred, extracted and then reloaded on the target. The deployment log explicitly displays BUILD HOST mode from startup.

  ┌──────────────────────── MOTHER SHIP (host) ───────────────────────┐
  │ 1. npm install --prefer-offline (monorepo root delta)             │
  │ 2. npx vitest run product-urls.test.ts   (URL invariants)         │
  │ 3. npx nuxi build <local-client>          → .output/              │
  │ 4. tar -I pigz -cf /tmp/<name>-build.tar.gz -C <client> .output  │
  └────────────────────────────────┬──────────────────────────────────┘
                                    │ secure transfer (SCP)
                                    ▼
  ┌────────────────────────── CLIENT VPS ─────────────────────────────┐
  │ 5. mv .output .output_old        (rollback ready)                 │
  │ 6. tar -I <decompressor> -xf /tmp/<archive>                      │
  │ 7. pm2 reload <app> --update-env   |   docker compose restart <c> │
  │ 8. if online → rm .output_old   else → display restore command +  │
  │    exit 1                                                          │
  └────────────────────────────────┬──────────────────────────────────┘
                                    │
                                    ▼  HTTP 200 poll (configurable max delay)
                              health check

Step details

  • Nuxt compilation: purge of the output directory (and cache if the --clean flag is set), offline dependency installation without audit, then a blocking smoke test on product URL shape invariants. If the incremental build fails, a second attempt with a purged cache is launched automatically.
  • Compression: the archive is produced with pigz if available, with automatic fallback to gzip. The compression target is the sole .output directory of the relevant client.
  • Transfer: the archive is copied to the VPS temporary directory via SCP, then deleted locally after upload.
  • Rotation and extraction on the VPS: the existing output directory is renamed to .output_old before extraction, enabling immediate rollback. A CommonJS compatibility fix is applied on the fly to the extracted tree (removal of a database driver sub-module that causes tree-shaking conflicts).
  • Graceful reload (PM2): the PM2 reload command preserves active connections and injects updated environment variables. An optional symlink to a static file directory can be created to avoid file resolution errors on the back-office side. A hard reload (delete + restart + PM2 save) is used on first deployment or when the configuration explicitly requires it.
  • Reload via Docker Compose: after extraction, the application container is restarted; the presence and running state of the container are verified before proceeding.
  • Health check: a loop polls the target URL until it receives HTTP 200, or triggers a failure once the maximum delay is reached (45 seconds by default).
  • Visual framing: start and end banners display the deployment mode and a phase-by-phase timed summary.
  • Scheduled task installation: a dedicated installer configures per-client cron jobs on the remote VPS (see scheduled configuration step §3).

Rollback

.output_old is retained throughout the reload period and purged only if the process comes back online. In case of failure (process or container not detected as active), the script only displays the suggested restore command and exits with error code 1 — the restore is manual and deliberate, the script never executes it automatically. The environment variable SHIP_KEEP_ROLLBACK=1 forces retention of .output_old even on success.

Post-deployment smoke: three validation layers

The post-deployment smoke script includes two sub-levels of verification, to which a third visual level was added from mid-2026.

  1. HTTP checks: a curl request is sent to each configured URL. A 5xx code is blocking; a 4xx code generates a non-blocking warning. The general loop covers a set of configured tenants; certain tenants are checked individually via the ./ship command. Covered URLs include notably the root, public governance pages (/constitution, /charter, /conduct), the agent list (/agents) and associated API endpoints (/api/agents, /api/automates).
  2. JSON content checks: beyond the HTTP code, the shape of the JSON returned by the navigation API is validated. This layer was introduced following an incident where an application returned HTTP 200 for two days with a JSON payload containing an error flag — navigation was empty and the footer displayed raw untranslated keys. Without this level, SSR data-driven breakages passed invisibly behind a nominal HTTP status.
  3. Visual smoke (non-blocking): a visual verification agent takes a screenshot via Playwright and returns a multimodal verdict. If no visual verification configuration exists for the relevant client, the step is skipped with no latency. This level is active by default and can be disabled via the environment variable SY_HUOYAN_SMOKE=0.
    python3 -m <visual-smoke-module> <tenant-identifier>

False-positive prevention: remote process environment variable verification

An HTTP 200 health check is not sufficient to validate a deployment. A documented incident illustrates the risk: a PM2 process redeployed with no database connection variables whatsoever returned HTTP 200 for two days, because the page produced a valid HTML skeleton — but all data-driven SSR routes (navigation, footer, reviews, translations) were returning 500 silently.

To prevent this scenario, the ./ship command runs an environment verification script before the HTTP smoke. This script connects via SSH to the VPS and inspects the critical variables declared in the remote PM2 process environment. The return codes are:

  • 0 — all critical variables are present;
  • 1 — variables are missing (blocking);
  • 2 — SSH connection impossible or process not found (blocking).

Targets not managed by PM2 (mother ship, isolated smoke environments) are ignored with a return of 0.

Pre-build safeguard: lockfile consistency check

Before any deployment of the main site, a control script detects two blocking drifts in the lockfile before any npm install or compilation:

  1. Presence of a CSS minification tool version below the required threshold — missing transitive PostCSS plugins cause a silently broken build.
  2. Nuxt framework version below the required stability threshold — risk of drift with the bundler.

The presence of version overrides in the root manifest generates a non-blocking warning. Any detected error produces an explicit corrective message and cancels the deployment with exit 1. The suggested fix is deletion of the lockfile and a clean reinstall of dependencies.

Mother-ship self-deployment

Network boundary: no client staging on the mother ship

The mother ship is the sensitive machine that hosts the orchestration, the Hub, secrets and internal services. It must not host or publish any client or prospect site, including behind Basic Auth authentication or an X-Robots-Tag: noindex directive. These measures do not remove the network entry point or the risk of lateral movement from a CMS or its container toward the internal Docker network.

Temporarily exposed client staging environments live exclusively on a dedicated buffer server, before migration to the client's dedicated VPS. A guard script enforces a closed allowlist on the Nginx configuration: only the Hub and its application upstream are permitted. This guard is blocking at the pre-commit level and can also audit the runtime state with the --runtime flag.

Self-deployment mechanism

The mother ship's main site does not go through the build-host / upload pattern described above. It is a local self-deployment: the mother ship compiles inside its own application container. The ./deploy command triggers a live production rebuild of the control cockpit, with no additional git ceremony.

Specifics relative to remote tenants:

  • Local transfer by Docker copy: instead of an SCP transfer, a minimal source archive (application code and dependency manifests only) is copied into the container via docker cp. Cache and build output directories are explicitly excluded from the archive. Inside the container, the client's build cache is set aside before extraction and then restored — this is the main performance lever: the build is incremental, not a cold build.
  • npm cycle optimisation: a sentinel based on the lockfile fingerprint is maintained inside the container. If the lockfile has not changed since the last deployment, dependency installation is skipped.
  • Build inside the container: Nuxt compilation runs inside the running container. The previous version remains available online throughout the build. No PM2-style process manager is used in this pipeline: the container starts the Node server produced by the build directly.
  • Atomic rotation: after the build, the container is cleanly stopped, the output directory is copied to the host, then the container is recreated via docker compose up --force-recreate, forcing a re-read of the environment file (fix for a documented incident involving variables not reloaded on a simple restart).
  • Build UUID health check: the health check is not limited to HTTP 200. The expected build UUID (read from the output manifest on the host side) is compared to the one returned by the container's health endpoint. This guarantees that the new build is actually active, and not the previous one served by a partial hot-reload.
  • Independent source mode: the mother ship's site no longer extends the shared CodeMyShop base. The transferred archive is reduced to the strict minimum (size reduction from approximately 19 MB to approximately 3 MB).
  • Accelerated variant: the variable HOST_BUILD=1 activates an alternative path that performs the build on the host and then performs an atomic swap inside the container, with a time saving of approximately 35%.
  • Full ceremony via ./ship: the ./ship command adds the complete sequence (branch merge, push, Hub drift check, agent audit, production smoke, workstream closure) but ultimately calls the same self-deployment script.

Topology Inventory — Single Source of Truth

The entire fleet topology (list of client VPS, SSH access, database containers) is centralized in a single reference table hosted in the mother-ship database. Any other source — architecture files, notes, static documentation — may be stale; this table alone is authoritative.

Querying the inventory

The ./deploy --list command queries this table and displays the current topology as an aligned tabular output. The sort order places central infrastructure first, followed by critical environments, then tenants in alphabetical order.

The significant columns of the table are as follows:

Column Role
Tenant identifier Unique logical key for the tenant within the fleet
Environment production, staging, infra, legacy, or audit
Address and domain SSH entry point and public URL of the VPS
SSH access Username and SSH key path (stored in the table, never hard-coded)
Associated database Container, database name, username, and the name of the environment variable carrying the password — never the value itself
Web runtime Application container, presence of PrestaShop, presence of Nuxt
Business criticality Criticality indicator, associated MRR, subscribed plan
Automatic ship allowed Boolean indicating whether a production deployment may be triggered without manual confirmation ⚠️
Deployment codename Identifier used to construct the exact deployment command
Client codename Foreign key into the federated client registry (spine 2026)

Special cases in the active inventory

The active fleet contains around a dozen entries. A few cases warrant particular attention to avoid miscategorization:

  • Two tenants carrying purpose = 'production' correspond to ancillary projects currently dormant — orphaned productions, not legacy or audit environments. They must not be aggregated under a generic label.
  • One tenant appears as a double entry: one row for its active Nuxt version in production, another for its legacy version without Nuxt. These two rows must be handled separately.
  • One tenant previously in audit status has been decommissioned (set to active = 0): its directory and legacy configuration files have been removed; client history is retained in the federated client registry.

Usage doctrine before any deployment

Before executing ./deploy or ./ship, the procedure requires consulting the tenant record in the management dashboard in order to copy the exact command associated with its deployment codename. This rule prevents targeting errors between environments.


Secrets Doctrine — Five Scope Levels

Each secret has exactly one canonical file. The presence of the same key in two distinct files is treated as a configuration bug, not an acceptable form of redundancy.

The five levels

# Reference file Scope Git-versioned
1 Per-tenant environment file Variables for a single tenant (read by its application container and deployment descriptor) No
2 Mother-ship core environment file Cockpit and central application container No
3 Mother-ship host environment file Host scripts: scheduled tasks, Python automation, SSH access to client VPS No
4 Shared environment file (out-of-repository) Cross-cutting keys: AI provider API keys, API encryption key, master SMTP configuration No (out-of-repository)
5 .env.example files Public open-source templates — variable names only, never values Yes

Load order and precedence rule

At central application container startup, files are loaded in the following order: shared file (level 4) → core file (level 2) → host file (level 3). When a key is present in both level 2 and level 3, level 3 (host) takes precedence. Consequently, the same key must never be defined at both levels simultaneously.

Note: The internal documentation provides a summarized three-layer view (infrastructure / core / tenants). The full five-level doctrine additionally details the cross-project shared file and the example templates. In case of discrepancy, the five-level doctrine prevails.

Absolute no-leak rule

No plaintext secret may appear in any versioned file. When a git-tracked file must reference a secret, it cites only the environment variable name and the .env* file that carries it — never the value. For example, a tenant deployment descriptor references the name of the variable holding the database password; the topology inventory table stores the name of the environment variable carrying the password, not the password itself.


Pre-Deployment Commit Rule

A past incident demonstrated that a locally uncommitted change can be silently overwritten during a deployment: the synchronization mechanism performs a git checkout on the target directory, which reverts any unversioned file to its repository state. In the documented case, a URL fix applied in production but never committed was lost, causing four hours of downtime.

Behavior by command

  • ./deploy (preprod / tenants): if the working tree contains uncommitted changes, a silent automatic commit is created ("chore(auto): pre-deploy snapshot <ISO timestamp>") and pushed in the background — except if the current branch is main, in which case the deployment is hard-rejected.
  • ./ship (production): any dirty working tree is blocking — the command halts with a message requiring a commit first. A --allow-dirty flag exists for intentional hotfixes only.

Complementary safeguards

  • A Git cleanliness check script runs before every deployment. It returns three states: clean tree, dirty tree, or directory outside a repository. Its scope is bounded by a --target option: a change in mother-ship files does not block a tenant deployment, and vice versa.
  • An end-of-session hook blocks session closure if the working tree is not clean. The operating rule is that the agent commits; the human operator never manually types a Git add or commit command.

Appendix — Deployment System Reference Files

Component Role
Preprod deployment entry point Root command ./deploy — routing to the correct tenant or mother-ship preprod
Production deployment entry point Root command ./ship — fleet consent management (auto_ship_allowed) and full production ceremony
Descriptor-driven dispatcher Reads each tenant's YAML deployment descriptor, validates its schema, and orchestrates steps: build, SSH, drift, i18n, health, hooks, cron
Deployment helpers library Shared functions: build, packaging, upload, reload, health check, console banners
YAML descriptor parser Parsing and validation of a tenant's deployment file; exports deployment variables for shell scripts
Mother-ship self-deployment (standard) Self-deploy procedure for the central application container: in-container build, stop, copy, restart
Mother-ship self-deployment (accelerated) Variant with host-side build and atomic swap — reduces downtime window
Git cleanliness checker Pre-deployment safeguard: detects uncommitted changes, scoped by target
Lock consistency checker Mother-ship pre-deployment safeguard: detects version drift in the dependency lock file
HTTP smoke probe Verifies HTTP responses and post-deployment content/JSON across the full active tenant fleet; also covers the mother-ship and CodeMyShop
PM2 environment checker Validates consistency of environment variables injected into PM2 processes before the production smoke probe
Deployment descriptor schema Full specification of the YAML format with canonical examples
VPS operations runbook SSH, PM2, Docker procedures, certificate renewal
Secrets doctrine Reference for the five secret levels and no-leak rules
Topology inventory table Single source of truth for fleet topology (mother-ship database)

⚠️ Warning: The documentation file covering the general workflow is partially outdated. It still describes a decommissioned preprod environment and a replaced ship script. In case of conflict between documentation and code, the code is authoritative: the ./deploy, ./ship commands and the mother-ship self-deploy script are the sources of truth for the current pipeline.