# Deploy Conjugate to OVH without SSH

For a walkthrough focused only on staging, read the [step-by-step staging setup guide](staging-setup.md). It includes the GitHub settings, first-deployment checkbox sequence, everyday usage, troubleshooting and a reusable coding prompt. This page is the technical reference; production setup is separate.

For the schema reconstructed from the production dump and safe key initialization, see [staging database setup](staging-database.md). Normal migrations never generate or rotate an application key.

Use **GitHub → Actions → OVH backend → Run workflow**. GitHub builds the selected code and Composer dependencies, uploads a ZIP and a job through SFTP/FTPS, then **staging executes that specific job through an authenticated HTTPS request**. GitHub reports success only after the staging job reports `succeeded`. No staging cron, SSH shell, deployment SSH key, or server-side Composer installation is needed.

**Production is unchanged:** it still queues jobs for OVH cron and supports FTP/FTPS/SFTP. A production upload means queued, not deployed; run `status` after cron processes it. OVH shared-hosting cron runs at most once per hour and this worker handles one job per invocation. [OVH cron instructions and limits](https://docs.ovhcloud.com/en/guides/web-cloud/web-hosting/cron-tasks).

The HTTPS runner is deliberately **staging-only**. It runs inside a web request, not a detached background process. OVH documents PHP execution limits of 120 seconds without PHP-FPM and 165 seconds with PHP-FPM; provider/proxy limits may also apply. Large releases and long migrations may not fit. A timeout is **unknown/interrupted**, never success or permission to retry a migration blindly. [OVH PHP limits](https://docs.ovhcloud.com/en/guides/web-cloud/web-hosting/hosting-technical-specificities).

The deployment branch is `codex/ovh-deployment`, in the separate `conjugador-deployment` worktree. Publishing the workflow to the repository's default branch (`main`) makes its manual UI available. Your unrelated local work in the original checkout is separate.

## What is needed

| Item | How to obtain it |
| --- | --- |
| Transfer protocol, hostname, port and username | The working FileZilla connection, or OVH → Hosting plans → your hosting → FTP–SSH |
| Transfer password | Your existing FTP/SFTP account password; store directly in GitHub Secrets |
| Production/staging transfer paths | The full paths shown in FileZilla, e.g. `/api.appconjugate` and `/apidev.appconjugate` |
| PHP major.minor version | Staging `health` operation / OVH web configuration; match GitHub to it. Production cron must also match its web PHP |
| Production URL and desired staging URL | OVH Multisite/domain configuration |
| Access to configure website roots (and production cron) | Your own OVH Manager login; no OVH API key is needed |
| Staging HTTPS runner token | Generate locally with `openssl rand -hex 32`; put the result directly in GitHub Secret `STAGING_RUNNER_TOKEN` |
| DB names/users/passwords and existing `.env` | OVH Databases and the production `.env`; keep secrets on OVH |
| GitHub repository access | An authenticated account able to push workflow files and manage repository Actions settings |

Prefer `sftp` or explicit `ftps` when your plan supports it. SFTP uses the SSH file-transfer subsystem but does **not** require SSH shell access. OVH distinguishes FTP/SFTP access from SSH shell access. [OVH protocol settings](https://docs.ovhcloud.com/en/guides/web-cloud/web-hosting/enable-sftp).

The `ftp` option supports ordinary FTP when that is the available service; its password and uploads are unencrypted. There is no automatic downgrade from FTPS/SFTP to FTP. FTPS verifies TLS certificates. SFTP verifies server host keys and authenticates with the account password, without private keys.

Paths are transfer-service paths, which may differ from filesystem paths. They must end with the exact environment folder name below. A user jailed directly to `/` inside an environment needs its transfer-path mapping adapted before use; do not guess an absolute server path.

## Environment layout

| Target | Root directory | Expected database host |
| --- | --- | --- |
| Production | `api.appconjugate` | `taxiboucmyconjug.mysql.db` |
| Staging | `apidev.appconjugate` | `taxiboucmydev.mysql.db` |

Both environments receive identical code. Each uses its own persistent `.env`, storage and database. A branch named `staging` is optional: select a branch, tag or full SHA when deploying. Promote the tested SHA to production.

```text
apidev.appconjugate/
  .deploy/
    worker.php                # private worker; direct HTTP access denied
    http.php                  # authenticated staging HTTP implementation
    bridge.php                # trusted public entry template, not application code
    http-token.sha256         # one-way digest; actual token stays in GitHub Secrets
    incoming/                 # completed ZIP uploads
    queue/                    # pending request JSON files
    processing/               # claimed requests; never automatically replayed
    results/                  # actual completion/failure reports
    runner-status.json        # last worker heartbeat
  runner-public/              # temporary staging web root before Laravel exists
    _deploy.php               # authenticated bridge, no Laravel dependency
  shared/
    .env                      # created privately; deployment never transfers it
    storage/                  # uploads, Apple keys, logs, sessions, file cache
  releases/
    <request-id>/             # application + vendor + persistent symlinks
      public/_deploy.php      # same staging bridge injected by trusted tooling
  current -> releases/<request-id>
```

Website roots must eventually be `apidev.appconjugate/current/public` and `api.appconjugate/current/public`. For staging bootstrap only, use `apidev.appconjugate/runner-public`. **Never serve the environment root**, `.deploy`, `shared`, or `releases`. `.deploy/.htaccess` denies direct web access; the private worker refuses direct HTTP execution. Staging's tiny public bridge authenticates before including the worker. Application archives cannot supply or replace this reserved `public/_deploy.php` entry point. Production receives no public deployment bridge.

The executing PHP runtime (staging web PHP / production cron PHP) needs ZIP, curl and the application's required extensions, plus permission to create symlinks, rename files and write storage/cache. It runs as your hosting account; root access is not needed. Confirm the website can serve the symlinked public directory. Preserve `.ovhconfig` outside releases where it applies to your sites; it is not included in the ZIP.

## GitHub settings

In **Settings → Secrets and variables → Actions → Variables**, configure:

| Repository variable | Value |
| --- | --- |
| `OVH_TRANSFER_PROTOCOL` | `sftp`, `ftps` (explicit TLS), or `ftp` |
| `OVH_TRANSFER_HOST` | FTP/SFTP hostname, without a protocol prefix |
| `OVH_TRANSFER_PORT` | Actual port; if blank, defaults to 22 for SFTP or 21 for FTP/FTPS |
| `STAGING_TRANSFER_USER` | Staging transfer username |
| `PRODUCTION_TRANSFER_USER` | Production transfer username; may be the same hosting account |
| `STAGING_DEPLOY_PATH` | Working SFTP path: `/home/taxiboucmy/apidev.appconjugate` (path only, no `sftp://` URL) |
| `PRODUCTION_DEPLOY_PATH` | Transfer path ending in `/api.appconjugate` |
| `OVH_PHP_VERSION` | Matching web/cron/build PHP version, e.g. `8.2` only if actually configured and compatible |

In **Secrets**, configure:

| Repository secret | Value |
| --- | --- |
| `STAGING_TRANSFER_PASSWORD` | Staging FTP/SFTP account password |
| `STAGING_RUNNER_TOKEN` | New independent random 64-character lowercase hexadecimal token; **not** the transfer password or Laravel `APP_KEY` |
| `PRODUCTION_TRANSFER_PASSWORD` | Production FTP/SFTP account password |
| `OVH_TRANSFER_KNOWN_HOSTS` | Verified OpenSSH known-hosts entry; **SFTP only** |

Old `OVH_SSH_*`, deployment private-key, server Composer-path and server PHP-executable settings are no longer used. For SFTP, obtain the host key from a trusted existing connection or verify its fingerprint with OVH. A fresh `ssh-keyscan` is collection, not authentication. [Host-key verification](https://man.openbsd.org/ssh-keyscan.1).

For SFTP, `OVH_TRANSFER_KNOWN_HOSTS` must contain a complete OpenSSH entry with three fields: the exact server hostname, key type (such as `ssh-ed25519`), and base64 public key. A hostname, FTP URL, or `SHA256:` fingerprint alone is not sufficient. Use the verified key for the hostname actually configured in `OVH_TRANSFER_HOST`; do not substitute a key from a different OVH cluster. Nonstandard SFTP ports use `[hostname]:port` as the entry's hostname.

The transfer step reports its connection phase and distinguishes a missing/malformed trusted key, a changed server key, authentication failure, SFTP subsystem failure, and an inaccessible SFTP directory. Raw protocol exceptions and passwords are not included in these diagnostic messages. Keep host-key verification enabled. `SSHException` from an older version of the transfer script does not identify the specific failure or mean that SSH shell access is required. Publish the updated `scripts/deploy/transfer.py` to the workflow's default branch to get these diagnostics; this client-side update does not require running an Artisan command or updating the OVH worker.

Enter passwords directly into GitHub Secrets, never chat or Git. Repository operators who can edit/run workflows are trusted to upload application code and execute Artisan jobs. This workflow uses repository settings, so it does not depend on paid GitHub Environment protections. GitHub Actions usage remains subject to your account's allowance.

For the new token, run `openssl rand -hex 32` in your own local terminal, copy its output into **Settings → Secrets and variables → Actions → Secrets → New repository secret**, name it `STAGING_RUNNER_TOKEN`, and do not paste it into chat. `install-runner` uploads only its SHA-256 digest to OVH; normal requests send the token in an HTTPS header. Keep existing transfer settings. No production token or new URL variable is needed: the staging origin is fixed to `https://apidev.appconjugate.com`.

## First staging deployment

1. Use FileZilla to create `apidev.appconjugate/shared/storage` and `apidev.appconjugate/releases`. Create `shared/.env` privately using `.env.example` as a checklist. Supply the actual staging DB name, username and password; the `*.mysql.db` value is a hostname, not necessarily the DB name.
2. Set `APP_ENV=staging`, `APP_DEBUG=false`, `APP_URL=https://apidev.appconjugate.com` (without `/api`), `DB_CONNECTION=mysql`, and `DB_HOST=taxiboucmydev.mysql.db`. Generate a new staging `APP_KEY` once and preserve it. Do not set `DATABASE_URL` or `DB_URL` overrides.
3. Keep cache/session storage local initially (`CACHE_DRIVER=file`, `SESSION_DRIVER=file`), use a distinct session cookie, `QUEUE_CONNECTION=sync`, `MAIL_MAILER=log`, and disable production notifications. If Redis/cloud storage is used, give staging separate prefixes/databases/queues/buckets. Use staging OAuth callbacks, store sandbox settings and appropriate service credentials. Apple sign-in keys belong under `shared/storage/app/keys/`.
4. Publish the deployment-only changes to `main`, add `STAGING_RUNNER_TOKEN`, then run **install-runner**, environment **staging**, **Use workflow from: main**. Run this again even if the older CLI-only runner is already installed. It uploads the private runner and `runner-public/_deploy.php`, not Laravel, and does not deploy production. If DeployHQ automatically deploys every merge, review its trigger before merging.
5. In OVH → Hosting plans → your hosting → **Multisite**, edit **only** `apidev.appconjugate.com` and temporarily set its root folder to `apidev.appconjugate/runner-public`. Keep HTTPS enabled. This isolated folder contains the authenticated bridge, not private configuration. A normal visit to `/` can return 403/404 at this point; Laravel is not installed. [OVH website-root configuration](https://docs.ovhcloud.com/en/guides/web-cloud/web-hosting/multisites-modify-domain).
6. Run **health**, environment **staging**, in GitHub Actions. Expect `status=ready`, `environment=staging`, `protocol=1`, and ZIP/curl available. Set `OVH_PHP_VERSION` to the reported major.minor version. This uses HTTPS immediately; no cron and no Artisan command are needed. An ordinary browser visit without the token returns 403. If health returns 403 with the correct token, verify the web runtime exposes `HTTPS=on` or `1`; do not trust arbitrary forwarded headers or disable TLS checks to work around it.
7. With `shared/.env` ready, run **deploy**, environment **staging**, `ref=main` (or the intended SHA). For this first deployment only, uncheck **verify_http** because the domain still serves the bootstrap folder. Enable **migrate** if the new staging database needs its schema. GitHub uploads and executes the job immediately, then reports `succeeded` or fails with an ID to inspect. Do not submit a duplicate if the response times out.
8. After `succeeded`, change **only the staging domain** root to `apidev.appconjugate/current/public`. Run **health** again; the same endpoint works from the new release. Then deploy the **same full SHA**, now with **verify_http enabled**, to verify the actual Laravel serving path. HTTP checks cannot be disabled once `current` exists. Test the staging API and use a separate staging app build/API URL for mobile testing.

Do not create a staging deployment cron. If one was already created, disable it after any running job completes and review old queued jobs before switching. The updated CLI worker will not drain staging's queue while `http-token.sha256` exists. Old queued IDs are never executed merely by calling `health`; an explicit POST selects exactly one ID. Staging also needs verb/conjugation reference data; migrations alone may not populate it. Use reviewed seeders or a sanitized data import.

The `.env` and private storage must be readable by PHP but not public. Use mode `600` for `.env` if the web runtime and cron use the same account. Protect production keys, tokens and personal data when preparing staging test data.

## Daily use

| Operation | What it does |
| --- | --- |
| `deploy` | Builds code + locked packages; staging executes over HTTPS, production queues for cron |
| `artisan` | Same execution path, using the JSON argument list against the current release |
| `status` | Reads private job results over SFTP/FTPS/FTP; blank ID means latest, `runner` means staging health / production heartbeat |
| `health` | Authenticated live staging runtime check, even before Laravel exists; does not run jobs |
| `initialize-key` | Staging only: initialize a missing/blank private APP_KEY before a first release, or preserve an existing valid key; never rotates keys |
| `install-runner` | Installs/updates tooling; staging includes the HTTPS bridge, production remains private cron |

Examples for `artisan_arguments`:

```json
["migrate:status"]
["migrate", "--force"]
["cache:clear"]
["queue:restart"]
["db:seed", "--class=YourReviewedSeeder", "--force"]
```

Commands execute through Laravel's console kernel in the worker PHP request/process, not a shell. Use full command names and an array of literal arguments; shell operators, `cd`, pipes and shell commands are not supported. This is noninteractive, not a remote terminal. Long-running queue workers/Tinker sessions are unsuitable for this workflow. Commands such as `migrate:fresh` can erase data when explicitly requested; choose your command and environment carefully. Output is stored privately and displayed only by the `status` operation with `show_output=true`; HTTP responses never include command output. Do not print credentials.

`AuthController` reads `env()` directly, so configuration caching is disabled. The runner blocks `config:cache`, `optimize`, `key:generate` and `--env` overrides. Each release starts with fresh bootstrap caches and runs `package:discover` on OVH. No environment-specific config/route/view caches are built on GitHub.

Install/update packages locally with Composer, commit `composer.json` and `composer.lock`, and deploy that commit. Composer runs on GitHub for every deployment; redeploy the same SHA to rebuild dependencies. There is no live-server `composer-install` operation. The repository currently tracks built public CSS/JS and the Mix manifest; rebuild and commit those assets when changing the Laravel frontend. No Node runtime is required on OVH.

GitHub CLI can trigger the same UI operations:

```bash
gh workflow run ovh-backend.yml --ref main -f environment=staging -f operation=deploy -f ref=main -f migrate=true
gh workflow run ovh-backend.yml --ref main -f environment=staging -f operation=artisan -f 'artisan_arguments=["migrate:status"]'
gh workflow run ovh-backend.yml --ref main -f environment=staging -f operation=status -f request_id=REQUEST_ID
```

For local use, Python 3.11+ is required. Export the same variables as the workflow, including `DEPLOY_EXECUTION=https` and `STAGING_RUNNER_TOKEN` for staging (`DEPLOY_EXECUTION=cron` for production), and run `python3 scripts/deploy/transfer.py`. For a deployment, first set `DEPLOY_SOURCE`, `DEPLOY_PACKAGE` and the matching PHP/Composer runtime, then run `bash scripts/deploy/build.sh`. Only SFTP needs `pip install -r scripts/deploy/requirements-sftp.txt`. No remote shell is opened.

## Staging URLs (optional direct use)

GitHub calls these for you. All require the header `X-Conjugate-Deploy-Token: <your staging token>` over HTTPS; a URL alone does not authorize anything.

| Method / URL | Purpose |
| --- | --- |
| `GET https://apidev.appconjugate.com/_deploy.php?action=health` | Runtime/extension/readiness check, no Laravel bootstrap |
| `GET https://apidev.appconjugate.com/_deploy.php?action=status&id=REQUEST_ID` | Read a specific job's status, without console output |
| `POST https://apidev.appconjugate.com/_deploy.php?action=run` | Execute one already uploaded job; JSON body `{"id":"REQUEST_ID"}` |

There is no command, migration name, token, or shell script in a trigger URL. `run` rejects GET and bodies containing anything except `id`; commands and packages must already have been uploaded through authenticated file transfer. The HTTPS client validates certificates and refuses redirects, including redirects to another HTTPS hostname. Responses use `Cache-Control: no-store`. Keep this endpoint out of CDN/page caches and do not log its authentication header.

If using an HTTP client yourself, set the token as a private header value, not a query parameter. Possession of the token alone does not allow uploading new jobs, but it can execute an existing queued job, so keep it private. To rotate it, update the GitHub secret and run staging **install-runner** again while no job is running. No changes to the application `.env` are needed. To revoke HTTP access urgently, remove/rename only `.deploy/http-token.sha256` through FileZilla **after disabling any staging deployment cron**; otherwise a remaining cron could drain queued jobs.

## Production cutover

After staging is verified, back up production's database, `.env`, and persistent files. Preserve its existing `APP_KEY`. Record the currently deployed SHA from DeployHQ and arrange a maintenance window for the website-root/storage cutover.

Inside `api.appconjugate`, create `shared/` and `releases/`. Copy the existing production `.env` into `shared/.env`, setting `APP_ENV=production` and `APP_DEBUG=false` while retaining its DB credentials and keys. Copy its storage into `shared/storage`, including uploads and Apple keys. Install the runner and configure a separate production cron task.

In OVH → Hosting plans → your hosting → **More → cron**, use the script path `api.appconjugate/.deploy/worker.php` relative to the hosting account. Select the same PHP version as the website, hourly execution, and enable it. Do not put `php`, arguments or a URL in the path. Production `status` with `request_id=runner` retrieves its heartbeat. Do not configure production during staging setup.

Disable DeployHQ's automatic trigger before this system takes over. Deploy the currently live SHA with `migrate=false` and `verify_http=false` for the initial preparation. Until the website root is switched, the original installation continues serving requests. Check actual completion via `status`.

During maintenance, pause writers and synchronize files changed since the storage copy. Switch the website root to `api.appconjugate/current/public`, update any existing application cron/worker paths, and test production. Keep the original installation and backups until verified. A subsequent deployment of the same SHA with HTTP checks enabled verifies this new serving path. The deployment runner does not replace your application's own scheduler; OVH's hourly limit also applies when planning application scheduled work.

## Failure recovery and storage

Uploads use temporary `.part` names followed by rename; a job is queued only after its complete ZIP. The server verifies the ZIP checksum, file paths, metadata, actual PHP version and platform requirements, environment/DB settings, and DB connectivity before activation. It then optionally migrates and atomically switches `current`. HTTP checks verify the release ID and a `200` response from Laravel's home page.

Pre-activation failures leave the active code untouched. HTTP failure restores the previous code (or removes the first unverified link). **Database migrations are never automatically rolled back.** Use schema changes compatible with old and new code and maintain backups. A terminated process can leave a partially completed operation; a code link alone does not tell you whether a migration ran.

Each request expires after 24 hours and executes at most once automatically. A filesystem lock serializes operations; Laravel boots for one job per PHP request/process. Repeating a POST for an existing result returns that result, including failure/interruption, without rerunning the operation. A busy lock or an unfinished processing marker prevents starting another job. GET status/health never executes work.

If GitHub loses the HTTPS response (timeout, connection loss, 5xx), use **status with the same request ID**. It reads private files through file transfer even if the HTTPS endpoint is unavailable. The PHP request may still be running, or it may have terminated. A hard kill can leave `running` in the result file, so also check `health` and `.deploy/processing`; do not interpret a green upload or an HTTP error as proof that no migration ran. The client never automatically repeats POST or submits a replacement job. If status says `queued` and there is no active/unfinished operation, an operator may deliberately POST that same ID after fixing connectivity.

### Safe HTTPS diagnostics and how to retest

The transfer client now identifies the failing **HTTPS action**, a bounded error
category, and its processing phase. For example:

```text
Staging HTTPS health failed [connection_reset; phase=connect]. Health is read-only; no deployment or Artisan job was submitted by this health request. No automatic retry was made.
```

This example illustrates the format, not a confirmed cause of a particular live
failure. Phase `connect` covers opening the connection (including TLS and waiting
for headers); later phases are `response_headers`, `response_body`, `decode`, and
`validate`. A connection reset during `response_body` is distinguished from a
failure before the response was opened.

| Category | Meaning / next check |
| --- | --- |
| `tls_certificate` | Certificate verification failed; inspect the certificate/chain and runner trust store, without disabling TLS verification |
| `tls_error` | Other TLS error; compare failing and successful runner/network paths |
| `dns_error` | Name resolution failed from that runner |
| `timeout` | The request timed out; use the phase to locate where |
| `connection_reset`, `connection_closed`, `connection_refused`, `connection_aborted` | Distinct socket/connection failures; not proof that Laravel or the payment code failed |
| `network_error`, `url_error` | Other transport failure without a more specific typed cause; no raw exception text is exposed |
| `http_status_403` (or another numeric HTTP status) | An HTTP response was received; a 403 can indicate authentication/HTTPS enforcement or an upstream rejection |
| `unexpected_content_type` | Response was not JSON; inspect runner routing/proxy behavior privately, without dumping HTML or headers into logs |
| `invalid_json`, `invalid_encoding`, `incomplete_response`, `http_protocol_error` | Response could not be read/decoded correctly |
| `response_too_large`, `invalid_response_shape`, `environment_mismatch`, `request_id_mismatch`, `invalid_response` | Response failed a format, size or identity check; it is not accepted as success |

Only fixed diagnostic labels, an HTTP status number and a format-validated request
ID are emitted. Tokens, credentials, headers, raw exception messages and response
bodies remain excluded. Existing 30-second health/status and 210-second run
timeouts, TLS verification, redirect refusal and single-attempt behavior are
unchanged. No automatic retry is added, including for deployment/Artisan POSTs.

**Health versus execution:** `health` is read-only and does not submit a command.
Staging deploy/Artisan preflight calls this same health endpoint *before* generating
and uploading a new job. If that preflight fails, no new job was submitted by that
attempt. Existing unrelated jobs may still be running. By contrast, a failed `run`
response cannot establish whether the uploaded job executed; its diagnostic keeps
the instruction to retrieve **status using the exact original request ID**.

`DEPLOY_REQUEST_ID` is an input for inspecting an existing request. Leaving it
empty on a new deploy/Artisan run is normal. Look for the generated `Request ID:`
log line and the run Summary; do not mistake the empty input for proof that no
request was generated.

To use this client-only update:

1. Review and publish `scripts/deploy/transfer.py`, its regression tests and this
   guide to the repository's workflow/default branch. The workflow checks out
   tooling from that branch; its deploy-only `ref` input does not select tooling
   for a health run. Do not use **Re-run jobs** on the old workflow revision.
2. Start a **new** workflow run: environment **staging**, operation **health**,
   request ID blank, using the updated default branch.
3. Share only the new diagnostic line or the successful health report. No runner
   token is needed in chat. A health retry is read-only; do not rerun a deployment
   or Artisan job merely to obtain diagnostics.
4. If health is ready, resume the intended inspection deliberately. If it fails,
   use its category to select the next check; do not change SSL, database or
   authentication settings by guesswork.

This change does **not** require `install-runner`, a backend application deploy,
an APK rebuild, new secrets or a server `.env` edit. Review any independent
push-triggered deployment automation before publishing the commit. Purchase,
iOS, Android, rollback/version gating, database and production cron logic are
untouched. More specific diagnostics do not themselves fix the underlying
connectivity/response failure.

For interruption, inspect the result, private Laravel/OVH logs, database state and active release. After confirming no process remains active, move the specific reviewed marker out of `.deploy/processing` using FileZilla; do not requeue it automatically. Submit a new operation deliberately if appropriate. Production cron may need re-enabling if OVH disabled it after repeated runner failures. If staging work consistently exceeds web-request limits, stop using the HTTPS path for that work and plan a suitable execution mechanism; this implementation does not bypass OVH limits.

To roll back after a successful deployment, deploy a known-good full SHA with `migrate=false`. This uses the current `.env` and database; production remains subject to cron delay. Old releases and incoming ZIPs are retained for diagnosis; monitor disk space and remove only explicitly selected inactive releases/archives. Retain request results as replay protection. No recursive deletion of an existing installation or automatic release pruning is performed.

## Verification

Install [actionlint](https://github.com/rhysd/actionlint/blob/main/docs/install.md) to check GitHub Actions expression contexts; parsing YAML alone does not validate them.

```bash
actionlint .github/workflows/ovh-backend.yml
python3 -B -m unittest discover -s tests/Deployment -p 'test_*.py' -v
bash -n scripts/deploy/build.sh
php -l scripts/deploy/worker.php
php -l scripts/deploy/http.php
php -l scripts/deploy/bridge.php
php tests/Deployment/worker.test.php
php tests/Deployment/http.test.php
```

PHP tests need ZIP/curl support. Set `DEPLOY_TEST_VENDOR` to an existing compatible vendor directory for additional real-Laravel CLI and HTTP integration tests. Tests use temporary directories, mock external transfers/database preparation, and start a loopback-only PHP HTTP server with a test-only TLS-termination simulation. They do not contact OVH or modify a real database. Real credentials, SFTP, TLS termination/headers, PHP request limits, Apache rules and web-root symlink support still require validation on staging. The bootstrap bridge works without Laravel; the HTTP tests also verify authentication/method checks, targeted execution, replay protection, locks/interruption, output redaction and real Artisan execution after the release-root cutover.

`schema.test.php` is a separate opt-in integration suite that creates disposable databases in an isolated MySQL 8 container. Follow [its isolation instructions](staging-database.md#verification); never run it against an application database.

Install `scripts/deploy/requirements-sftp.txt` in a local virtual environment to include the real Paramiko host-key parsing regression test. It tests complete, incomplete, malformed, hashed and wrong-host entries without making a network connection.
