---
title: Dev & Release Branch Workflow
slug: dev-workflow
status: active
owner: hazem
updated: 2026-06-17
refs:
  - moon-erp-be (BE repo, remote tahadeveloper/moon-erp)
  - moon-erp (FE repo, remote hazemhamdytaha/moon-erp-angular)
related:
  - moonstack-update
---

## Context
How we branch, build, and release. Two repos: **BE** = `moon-erp-be` (GitHub `tahadeveloper/moon-erp`), **FE** = `moon-erp` (GitHub `hazemhamdytaha/moon-erp-angular`). Both live on the moonui build host; GitHub remotes carry embedded tokens (don't print them).

## Branches (as of 2026-06-17)
- **`main`** = the release source / stable line. Holds EVERYTHING latest (LIS + MoonStack + middleware + Ahmed's webstore). Keep it the newest.
- **`hazemdev`** = the branch Hazem works on (dev/integration). **Branched from `main`** on both repos. Build from it for testing/beta; merge `hazemdev` → `main` when ready.
- `lis/lab-updates` = the OLD work branch — now **behind main** (retiring it). Use `hazemdev` going forward.

## The flow
1. Work + commit on **`hazemdev`**.
2. Release a **canary** from `hazemdev` (or `main`) to channel **beta** → a test client (moontest, `MOONSTACK_CHANNEL=beta`) updates → verify.
3. When good: **merge `hazemdev` → `main`** (clean FF if main hasn't diverged; else a merge commit — main may carry other devs' commits, e.g. webstore).
4. Release **stable** from `main`.

## Releasing — the web page (easiest) + CLI (for Claude)
- Page: `https://moonui.elbaset.com/moonstack-release.php` → Version, Type, **Channel (stable/beta)**, **Build source (current server / from git)** + **Git ref** (set to `hazemdev` for test, `main` for stable). See [[moonstack-update]].
- CLI: `php artisan moonstack:ship <v> --channel=beta [--source=… --frontend=…]` or `scripts/moonstack-release-from-git.sh <v> <ref> <channel>`.

## ⚠️ Build-from-git includes ONLY committed work
The "from git" build CLONES the ref → **uncommitted changes in the live working tree are NOT in the release.** Commit WIP to `hazemdev` before releasing it. (To merge a release, commit first.)

## Prerequisites — what a build/release host needs (ANY user/env)
A host that cuts releases needs:
- **PHP ≥ 8.2** (`/usr/local/bin/ea-php82` or `php`).
- **composer** — a binary on PATH (`/usr/local/bin/composer`, cPanel path…) OR `composer.phar` in the app root. **It REQUIRES `HOME`/`COMPOSER_HOME`** to run (the FPM/non-login shell often has neither).
- **node + npm + npx** (the FE `ng build`).
- **git** (build-from-git clones BE+FE; remotes must be set for URL auto-resolve).
- The **signing private key** so packages are signed (build side, keep secret).
- The BE repo (this app) + FE repo checked out, with their GitHub remotes configured.

## Environment variables (the full set)
| Var | Side | Purpose / default |
|---|---|---|
| `MOONSTACK_PRIVATE_KEY` | build | RSA key to **sign** packages (pairs with the embedded public key). Keep secret (CI secret, never in repo). |
| `MOONSTACK_RELEASE_DIR` | build | where packages + `versions.json` are written. Default `/home/moonui/public_html/moonstack`. |
| `MOONSTACK_RELEASE_URL` | build | public base URL of the release dir. Default `https://moonui.elbaset.com/moonstack`. |
| `MOONSTACK_FRONTEND_DIST` | build | built Angular dir bundled by **"current server"** builds. Default `/home/moonui/public_html/app`. |
| `MOONSTACK_UPDATE_URL` | client | the `versions.json` the updater polls (point a test client at a separate beta manifest to isolate). |
| `MOONSTACK_CHANNEL` | client | `stable` (default) or `beta` — which channel this install tracks. |
| `MOON_LIVE_APP_DIR` / `MOON_LIVE_FE_DIR` | git-build | override BE/FE repo paths. BE auto-derives from the script's location; FE defaults to `/home/moonui/public_html/moon-erp`. |
| `MOON_BE_REPO` / `MOON_FE_REPO` | git-build | override repo URLs. Default: resolved from the live repos' `git remote get-url origin` (so no tokens in the page). |
| `MOON_PHP` / `MOON_TYPE` / `MOON_DELTA_FROM` | git-build | php binary / release type / delta-from version. |
| `HOME` / `COMPOSER_HOME` | build | composer needs them; `release-from-git` defaults `HOME` to the app dir's parent. |

The `scripts/moonstack-release-from-git.sh` is **portable**: it derives `LIVE_APP_DIR` from its own location and `HOME` from that, resolves composer across known paths, and prepends `/usr/local/bin` to PATH — so it runs from any user/host without edits (override via the env vars above).

## Gotchas (learned the hard way)
- **composer not on the web user's PATH** (FPM non-login shell) → `command not found`. Fixed: explicit lookup + PATH prepend.
- **composer needs `HOME`/`COMPOSER_HOME`** → "HOME or COMPOSER_HOME must be set". Fixed: script exports them.
- **bare `npm`/`npx` 'command not found'** in the FPM-exec shell even with /usr/bin on PATH. Fixed: resolve node/npm/npx to FULL paths + put node's dir on PATH (npm/npx are JS run via `#!/usr/bin/env node`).
- **🚫 BIGGEST ENV CONSTRAINT — the web user is in CageFS, NO node, shell disabled.** On this host the `moonui` account is jailed (CageFS): system binaries in `/usr/bin` `/usr/local/bin` are NOT visible (that's why composer falls back to the in-home `composer.phar` and `node` isn't found). **So "build from git" via the web page CANNOT run `ng build` (no node for the jailed user).** → **Page releases must use "current server"** (no node — packages the prebuilt `/app` + composer.phar). **Reproducible "from git" builds must run where node IS available**: as root via CLI (`scripts/moonstack-release-from-git.sh`), a CI runner, or after installing a CloudLinux/cPanel Node.js selector for the account. The FE is normally built as root (`/usr/bin/node`), then deployed to `/app`.
- **build-from-git ships ONLY committed work** — uncommitted WIP in the live tree is NOT released. Commit to `hazemdev` first.
- **The release page publishes to the SHARED stable manifest** (no separate dir) → a beta there makes OLD stable clients briefly see "no update" until promote. For a clean canary use a separate beta manifest (`MOONSTACK_RELEASE_DIR`/`MOONSTACK_UPDATE_URL`). See [[moonstack-update]].
- **Editing the page/scripts as root makes them root-owned** → suexec 500. `chown <account>:<account>` after.
- **🔁 Release notes DUPLICATE / don't clear:** `ship` promotes `docs/moonstack/CHANGELOG.md` `[Unreleased]` → a dated heading. If that file isn't writable by the release user (e.g. root-owned after a manual edit), the promote **silently no-ops** → `[Unreleased]` never clears → notes re-attach + duplicate every release. Fix: `chown <account> docs/moonstack/CHANGELOG.md`. (ship now WARNS on this instead of failing silently — `8eff69ca4`.) **Rule: chown any file ship writes after editing it as root.**
- **🟥 from-git `composer install` produced NO `vendor/` → build aborts — TWO independent root causes, both now fixed** (2026-06-18, fully traced + verified end-to-end). The `composer install` step in `scripts/moonstack-release-from-git.sh` failed silently (no vendor) and the symptom changed depending on which `php` composer's shebang picked, which made it confusing. The two causes:
  - **(A) `package:discover` boots Laravel → Moon-Central phone-home 403 "Unauthorized — wrong secret".** composer.json `post-autoload-dump` runs `artisan package:discover`, which **boots Laravel during `composer install`**; in a fresh clone with no valid license `.env`, **Ahmed's Moon-Central license code phones home and gets 403 "wrong secret"**, failing before `vendor/` is produced. **Fix:** `--no-scripts` on the `composer install` (skips `package:discover`; the optimized-autoload dump is core, not a "script", so it still runs → vendor present; Laravel rebuilds its package manifest on the client's first boot + nwidart uses the shipped `modules_statuses.json`).
  - **(B) 🔑 THE REAL BLOCKER — composer ran under a NON-CLI SAPI php.** `/usr/local/bin/composer` is a phar with shebang `#!/usr/bin/env php`. In the release page's **FPM/login-shell context**, `env php` resolves to the **CGI/FPM SAPI** php, and composer **aborts**: `"Composer cannot be run safely on non-CLI SAPIs with register_argc_argv=On. Aborting."` (preceded by a stray `Content-type: text/html` CGI header). This is why an *isolated* `runuser -u moonui … /usr/local/bin/composer install` SUCCEEDED for me (minimal env → `env php` = CLI) but the **page-run failed** — the bug only appears in the FPM/login-shell PATH. (It's also the likely true origin of the "403 wrong secret" symptom — an FPM-SAPI php running the phar gets routed through the app.) **Fix:** never run composer via its own shebang — resolve the phar/binary into `$COMPOSER_BIN` and invoke it as **`COMPOSER="${PHP} ${COMPOSER_BIN}"`** where `$PHP` = `MOON_PHP` = `/usr/local/bin/ea-php82` (a guaranteed **CLI SAPI**, verified `PHP_SAPI=cli`). `php` transparently skips the phar's shebang line.
  - **✅ BOTH fixes are in `scripts/moonstack-release-from-git.sh`** (CLI-php at the COMPOSER resolution, `--no-scripts` at the install call) and **PROVEN end-to-end 2026-06-18**: a full from-git release of **2.2.16** ran `… — using /usr/local/bin/ea-php82 /usr/local/bin/composer …` → "92 installs … Generating optimized autoload files" (no 403, no SAPI abort, vendor present) → `ng build` → `ship --source --sign` → **both `moon-erp-v2.2.16.zip` (updater) AND `moon-erp-v2.2.16-setup.zip` (docroot-ready installer) published** + 6 deltas + `versions.json latest=2.2.16` with `setup_url`. **Lesson for any future composer-from-web step: ALWAYS invoke composer as `<cli-php> <composer.phar>`, never bare — and `--no-scripts` to avoid the license phone-home.**
- **chown after editing BE files as root:** files edited by the agent are root-owned → `ship` (runs as `moonui`) then **can't write `docs/moonstack/CHANGELOG.md`** ("could NOT write… [Unreleased] not cleared" → notes duplicate), and root-owned source is messy. **Always `chown moonui:moonui` edited BE files** (+ run `local-deploy.sh`).
- **✅ Docroot-ready installer — SHIPPED 2026-06-18 in 2.2.16** (was: "release ZIP is raw Laravel at webroot → `<domain>/moonstack-setup.php` 404 + `.env` web-exposed"; see [[moonstack-update]] + [plan](../plans/moonstack-docroot-ready-plan.html)). **Two-artifact model** (both built by `moonstack:ship` for EITHER source mode, via `App\MoonStack\Packaging\InstallerWrapper`):
  - **`moon-erp-v<V>.zip`** — the **updater** package (un-wrapped, raw Laravel). Existing installs UPDATE with this (applied at `base_path()` / FE at `public/app`). Unchanged.
  - **`moon-erp-v<V>-setup.zip`** — the **fresh-install** package. Top-level = **only `.htaccess` + `core/`**. Everything (Laravel root incl. `vendor/`, `artisan`, `public/`, `public/app/`) lives under `core/`; the root `.htaccess` rewrites all → `core/public/` and denies `.env`/composer/artisan/sqlite. **Install = drop the zip in `public_html`, extract, open `/moonstack-setup.php`** — no docroot change, core kept private. `Publisher::publishSetup()` adds `setup_url`/`setup_sha256`/`setup_size` to the `versions.json` release entry.
  - Installer hardening shipped alongside: `Installer::linkStorage()` (storage symlink during install) + writes both the canonical (`storage/moonstack/installed`) and legacy (`storage/installed`) markers; `RequirementsChecker` now fails loudly if `vendor/autoload.php` is missing (truncated extract) instead of passing green then dying at boot.

## Current status / resume point (2026-06-18)
- ✅ **From-git release pipeline WORKS end-to-end** (clone → composer `${PHP} ${COMPOSER_BIN} … --no-scripts` → `npm ci` → `ng build` → `ship --source --frontend --sign` → publish + deltas + **setup.zip wrap**). Proven by **2.2.16** (and earlier 2.2.11-beta).
- ✅ **2.2.16 PUBLISHED (stable, latest)** from a clean `main` checkout — both `moon-erp-v2.2.16.zip` (updater) **and `moon-erp-v2.2.16-setup.zip` (docroot-ready installer)** + 6 deltas, signed, `versions.json latest=2.2.16` with `setup_url`. **Both build modes (from-git + current-server) now produce a working build AND a docroot-ready install** — the `InstallerWrapper` runs inside the shared `ShipCommand`, so it fires for either `--source` mode.
- ✅ **Composer-from-web fully fixed + committed** — the two root causes (package:discover phone-home + non-CLI SAPI) are documented above; fix = `${PHP} ${COMPOSER_BIN} … --no-scripts`. **Committed to `hazemdev` as `9315a80d6`** (`fix(moonstack): run from-git composer under explicit CLI php`); the same push fast-forwarded `origin/hazemdev` to include the docroot-ready commit `02775d913` it was missing, so **`origin/hazemdev` = `origin/main` + the CLI-php fix** (hazemdev ahead of main by 1; `main` picks it up at the next hazemdev→main release-merge). The live `lis/lab-updates` tree still carries the same edit uncommitted (keeps this server's releases running) — reconcile/commit it there separately if needed. Branch rule reminder: **work on `hazemdev`, release from `main`** ([[feedback_be_push_branch]]).
- ✅ **2.2.11-beta PUBLISHED** (signed, deltas from 2.2.6–2.2.10) on the shared manifest, channel beta.
- ✅ Release page UX fixed (status badge + elapsed + **re-attaches after refresh**). Notes-duplication fixed (chown + ship warn).
- ✅ CageFS disabled for `moonui` (web user can now reach node/composer). `main` + `hazemdev` = latest (everything merged + all release-tooling fixes).
- ✅ **moontest = the canary** (`MOONSTACK_CHANNEL=beta`, on 2.2.6).
- ⏳ **NEXT: run the canary** — update moontest to 2.2.11-beta from its Updates screen → verify the full client cycle (download delta → verify sig → backup → migrate → **sync-reference seeders** → **health-check** → finalize). Then **promote 2.2.11 → stable** (elmadina + stable clients get it; elmadina currently sees "no update" because `manifest.latest`=2.2.11/beta — benign until promote).
- ⏳ Open ideas: built-FE on git (a `fe-dist` branch) vs "current server" (fast, no node) vs CI; the **uncommitted WIP** (report-footer/printed-by — BE 2 + FE 13, NOT in git → not in any release until committed to hazemdev).

## Fresh install on a NEW client (cPanel) — runbook
The decade-old gap: the package is built for **docroot = `<approot>/public`**, but a cPanel docroot is `public_html`. Two ways until the `.htaccess`-shim fix ships (see [[moonstack-update]]):
1. **Recommended (planned default):** extract the full `moon-erp-v<ver>.zip` into `public_html` (verify the ~164MB zip extracts FULLY — `vendor/` must be present, ~11k files; a truncated extract = no `vendor/` = boot dies), drop a root `.htaccess` that rewrites all → `public/` (`RewriteCond %{REQUEST_URI} !^/public/` + `RewriteRule ^(.*)$ public/$1 [L]`), then open `https://<domain>/moonstack-setup.php` (the MoonStack installer — NOT Ahmed's `/install.php`; the root may 302 to Ahmed's — always use `moonstack-setup.php`). Wizard: requirements → DB → admin.
2. **Manual docroot repoint:** point the domain docroot to `…/public` (edit `/var/cpanel/userdata/<u>/<domain>{,_SSL}` documentroot + `/scripts/rebuildhttpdconf` + restart httpd) — needs root/WHM.
- Install writes the marker `storage/moonstack/installed` + `storage/moonstack/version.json` (`app_version` = source of truth). Update entry = `public/moonstack-update.php` (delta-first). **prod.elbaset.com** is the current fresh-install canary (cPanel user `prod`).

## Merging to main (no-FF, without disturbing the live tree)
main can diverge (other devs push to it). To merge `hazemdev` → `main` safely when it's NOT a fast-forward, use an isolated worktree so the live moonui tree isn't switched:
```
git worktree add --detach /home/moonui/.merge-tmp origin/main
cd /home/moonui/.merge-tmp && git merge origin/hazemdev --no-edit -m "Merge hazemdev into main"
git push origin HEAD:main
cd <repo> && git worktree remove /home/moonui/.merge-tmp --force
```
(Check conflicts first: `git merge-tree --write-tree origin/main origin/hazemdev` — exit 0 = clean.)
