# Deployments — Improvement & Growth Plan

> Package: capell-app/deployments · Kind: package · Tier: premium · Product group: Capell Operations · Bundle: operations · Status: Draft

## 1. Snapshot

Deployments stores per-repository Git provider connections (GitHub / GitLab / Bitbucket) and publishes Composer requirement changes back to the connected repository as either a direct commit or a pull request (optionally auto-merged). It exposes two admin surfaces (`DeploymentConnectionPage`, `DeploymentConnectionWidget`), three OAuth callback routes (`routes/oauth.php`), a single model/table (`DeploymentConnection` / `deployment_connections`), and the `PublishesComposerChanges` contract intended to be consumed by other packages' install flows. Domain logic lives in five Actions (`ConnectDeploymentAction`, `PublishComposerRequirementAction`, `PrepareComposerRequirementCommitAction`, plus two OAuth-state Actions) delegating to three `GitProviderContract` implementations behind `GitProviderFactory`. Dependencies are light: `capell-app/admin`, `capell-app/core`, `lorisleiva/laravel-actions`, `spatie/laravel-data`, `spatie/laravel-package-tools`.

Current marketplace summary: _"Connect a Git repository once, then install or update Capell extensions from admin workflows that publish composer.json changes as reviewed pull requests."_ Manifest declares the committed marketplace card plus **2** functional screenshots under `docs/screenshots/` (`deployment-connection-page.png`, `-dark.png`), and `docs/screenshots.json` now maps to those committed pre-connection OAuth entry-point captures.

## 2. Improvements (existing functionality)

- **Dashboard widget visibility is gated** — `DeploymentConnectionWidget::canView()` mirrors `DeploymentConnectionPage::canAccess()`, unauthenticated users and non-permitted admins cannot resolve the active connection, and Livewire coverage asserts the repository coordinate is not rendered to unauthorized viewers — `src/Filament/Widgets/DeploymentConnectionWidget.php`, `tests/Feature/Filament/DeploymentConnectionWidgetTest.php`. (Shipped)
- **Dashboard widget is registered intentionally** — `DeploymentsServiceProvider::registeringPackage()` registers `DeploymentConnectionWidget` on the System Health dashboard when the package is enabled, and provider registration coverage asserts the widget is present alongside the page — `src/Providers/DeploymentsServiceProvider.php`, `tests/Feature/DeploymentsServiceProviderTest.php`. (Shipped)
- **OAuth client misconfiguration is surfaced in the UI** — provider connect buttons are disabled until both repository coordinates and the relevant provider client id are configured, with translated disabled reasons such as "GitLab OAuth is not configured." The page no longer exposes a broken authorize URL for unconfigured providers, and coverage asserts the disabled states — `src/Filament/Pages/DeploymentConnectionPage.php`, `resources/views/filament/pages/deployment-connection.blade.php`, `tests/Feature/Filament/DeploymentConnectionPageTest.php`. (Shipped)
- **Repository coordinates are captured before OAuth** — `DeploymentConnectionPage` now requires repository owner/name input before building provider OAuth URLs, persists those coordinates in the one-time OAuth state, and every callback consumes that state before creating a `DeploymentConnection`. The old fictional `repoName: 'app'` callback placeholder is gone, and callback coverage asserts the stored GitHub/GitLab/Bitbucket owner/name values — `src/Filament/Pages/DeploymentConnectionPage.php`, `src/Actions/OAuth/*`, `src/Http/Controllers/OAuth/*`, `tests/Feature/OAuth/OAuthControllersTest.php`. (Shipped)
- **Centralise HTTP timeout** — providers hard-code `->timeout(10)->connectTimeout(5)` (e.g. `GitHubProvider::client()` line ~255) while `config('capell-deployments.http_timeout')` exists and is honoured only by the OAuth controllers. Read the config value in the provider clients so the budget is configurable in one place — `src/Services/GitProvider/GitHubProvider.php`, `GitLabProvider.php`, `BitbucketProvider.php`. (S)
- **Simplify double `base64_decode`** — `GitHubProvider::getFile()` calls `base64_decode($rawContent, strict: true)` twice inside a ternary; decode once into a variable and branch on it — `src/Services/GitProvider/GitHubProvider.php`. (S)
- **Bound publisher fails loudly when connection choice is ambiguous** — `DeploymentsServiceProvider` binds `PublishesComposerChanges` to the single active deployment connection only when exactly one active connection exists. With multiple active connections it throws a `LogicException` telling the consuming workflow to call `PublishComposerRequirementAction` with an explicit `DeploymentConnection`, and coverage locks that behavior — `src/Providers/DeploymentsServiceProvider.php`, `tests/Feature/GitOps/PublishComposerRequirementActionTest.php`. (Shipped)
- **Shipped 2026-06-07: OAuth expiry is persisted for expiring providers.** GitLab and Bitbucket callbacks now pass `expires_in` into `ConnectDeploymentAction`, which writes `token_expires_at` alongside encrypted access/refresh tokens. This gives provider clients a real expiry boundary for refresh decisions. — `src/Http/Controllers/OAuth/GitLabCallbackController.php`, `src/Http/Controllers/OAuth/BitbucketCallbackController.php`, `src/Actions/ConnectDeploymentAction.php`

## 3. Missing Features (gaps)

Manifest `capabilities`: `["deployments", "deployments-admin", "deployments-install-policy", "deployments-publish-history", "deployments-publish-idempotency", "deployments-token-refresh"]`. Manifest `surfaces`: `["admin"]`.

- **Console command workflow** — there is no `src/Console` directory, no class `extends Command`, and `capell.json.commands` is `{install:null,setup:null,demo:null,doctor:null}`. The manifest no longer advertises the unbacked `console` surface or `deployments-console` capability; add them back only with real commands such as `deployments:publish {package} {constraint}`, `deployments:status`, or `deployments:doctor`. (M)
- **Shipped 2026-06-07: OAuth token refresh is wired for GitLab and Bitbucket.** `RefreshProviderTokenAction` refreshes expired GitLab/Bitbucket tokens with the stored encrypted refresh token, persists rotated access/refresh tokens and expiry, and provider clients invoke it before API calls. GitHub remains unchanged because its OAuth tokens are not modelled with refresh expiry in this package. — `src/Actions/RefreshProviderTokenAction.php`, `src/Services/GitProvider/GitLabProvider.php`, `src/Services/GitProvider/BitbucketProvider.php`
- **Shipped 2026-06-07: release / deploy tracking and version history.** Composer requirement publishes now write `deployment_publications` rows with package, constraint, branch, commit SHA, PR reference, dry-run flag, and status. Direct commits and PR branch commits call `getDeployStatus()` when a commit SHA exists, and the Deployment Repository page shows a recent publishes/status table for each active connection. — `database/migrations/2026_06_07_090000_02_create_deployment_publications_table.php`, `src/Actions/RecordDeploymentPublicationAction.php`, `src/Actions/RefreshDeploymentPublicationStatusAction.php`, `src/Actions/PublishComposerRequirementAction.php`, `src/Filament/Pages/DeploymentConnectionPage.php`, `resources/views/filament/pages/deployment-connection.blade.php`
- **Shipped 2026-06-08: cancel pending pull-request installs.** `CancelDeploymentPublicationAction` consumes provider `closePullRequest()`, marks the publish record `cancelled`, and the Deployment Repository page exposes the action for pending PR-based publishes only. Direct-commit reverts remain future depth.
- **Shipped 2026-06-08: health-gated auto-merge.** Pull-request publishes now read `getDeployStatus()` for the PR head commit before enabling auto-merge. Passing checks enable auto-merge, while pending/failing checks leave the PR open and record the current status for the recent-publishes table.
- **Maintenance-mode / deploy-hook coordination** — for a CMS "Operations" bundle package, there are no deploy hooks (pre/post-publish callbacks), no maintenance-mode toggling around a direct-commit deploy, and no event emission (`subscriberManager`/`AdminEventRegistry`) when a publish succeeds or fails so other packages (Diagnostics, Insights) can react. (M)
- **Changelog generation for published changes** — the PR body is a hard-coded one-liner (`PublishComposerRequirementAction`); generating a richer changelog/diff summary (what changed in `composer.json`, links to the extension) is a differentiator over a raw API commit. (S)
- **Shipped 2026-06-06: per-connection install policy is selectable before OAuth.** `DeploymentConnectionPage` now exposes the three `InstallPolicy` options beside repository owner/name, persists the selected policy in OAuth state, and the callback passes it to `ConnectDeploymentAction`. Existing behavior remains the default (`PullRequestAutoMerge`), while `DirectCommit` and `PullRequestManual` are now reachable from admin connection setup. — `src/Filament/Pages/DeploymentConnectionPage.php`, `src/Actions/OAuth/*`, `src/Actions/ConnectDeploymentAction.php`
- **Webhook ingestion for deploy/PR status** — polling `getDeployStatus()` is the only model; a provider webhook receiver to update publish status asynchronously would round out the "deployments" story. (L)

## 4. Issues / Risks

- **Composer publishing owner is explicitly external** — `grep` across the package repo still shows no production consumer outside Deployments, so Marketplace/extension-install workflows remain the external owner for invoking `PublishesComposerChanges`. The package now documents that boundary in `README.md` and `docs/overview.md`: simple install flows resolve the contract through the container, while multi-repository workflows must select a `DeploymentConnection` and call `PublishComposerRequirementAction` explicitly. The remaining commercial risk is integration timing in the external Marketplace/install package, not an unowned in-package contract — `src/Contracts/PublishesComposerChanges.php`, `src/Actions/PublishComposerRequirementAction.php`, `src/Providers/DeploymentsServiceProvider.php`.
- **Dead contract surface is reduced** — `getDeployStatus()` is now consumed by Composer publish history/status recording, but `getPullRequest()` and `closePullRequest()` remain declared on `GitProviderContract`, implemented in all three providers, and unit-tested with no production caller. Build rollback/cancel-pending-install to use them or trim the unused surface — `src/Contracts/GitProviderContract.php`, `src/Actions/PublishComposerRequirementAction.php`, `src/Actions/RefreshDeploymentPublicationStatusAction.php`.
- **Shipped 2026-06-03: health check is real.** `DeploymentsHealthCheck::runDiagnostics()` now verifies the connection storage table and at least one configured Git provider OAuth client id, reports only provider labels, and exposes aggregate pass/fail state for Diagnostics. Token freshness remains a separate OAuth-refresh feature rather than part of the current health check.
- **Manifest settings gap** — `database.settings:false` and `settings:[]` are correct (no settings), but the package reads several `CAPELL_*` env vars (`config/capell-deployments.php`) that aren't surfaced as configurable settings anywhere — `capell.json`.
- **Multiple-active-connection ambiguity is reduced but not fully modelled** — the bound publisher now refuses to choose among multiple active connections and tells callers to pass an explicit `DeploymentConnection`; the dashboard widget still displays the first active connection as a summary affordance. A richer multi-connection admin model remains useful, but Composer publishing no longer silently targets an arbitrary repository — `src/Providers/DeploymentsServiceProvider.php`, `src/Filament/Widgets/DeploymentConnectionWidget.php`.
- **Shipped 2026-06-06: publish idempotency and direct-commit dry-run.** `PublishComposerRequirementAction` now uses deterministic `capell/add-extension-<slug>` branches for pull-request policies, asks the provider for an existing open PR on that branch before writing, and returns the existing PR result instead of creating duplicate branches/PRs. Direct-commit publishes accept `dryRun: true`, prepare the composer patch, and return a dry-run result without committing to the default branch. — `src/Actions/PublishComposerRequirementAction.php`, `src/Contracts/GitProviderContract.php`, `src/Services/GitProvider/*Provider.php`
- **Secret handling — mostly good, two notes** — tokens are excluded from `$fillable` and stored via `EncryptedString` cast with `forceFill` (well-documented in the model and `ConnectDeploymentAction`); OAuth token responses are redacted before logging (`redactTokenResponse`). However: (1) the cast is named `*_encrypted` but the model decrypts on access, so `$connection->access_token_encrypted` returns plaintext and is passed straight into `->withToken(...)` — the column name is misleading and invites accidental logging of the attribute; (2) there is no encryption-key-rotation/re-encrypt path. — `src/Casts/EncryptedString.php`, `src/Models/DeploymentConnection.php`, `src/Services/GitProvider/GitHubProvider.php`.
- **Test gaps** — coverage is good for Data/enums/casts/OAuth-state/providers (`BitbucketProviderTest`, `GitLabProviderTest`, `GitHubProviderTest` faked-HTTP), OAuth callback repository persistence, page access/mutation gates, OAuth-config disabled states, widget authorization/rendering, and the bound publisher's multi-connection failure. Remaining gaps: no idempotency test for double-publish, no token-refresh coverage, no publish-history/status consumer coverage, and no active-connection/OAuth-to-PR screenshot capture. — `tests/Feature/Filament/DeploymentConnectionWidgetTest.php`, `tests/Feature/Filament/DeploymentConnectionPageTest.php`, `tests/Feature/GitOps/PublishComposerRequirementActionTest.php`.
- **Performance budget** — `capell.json` sets `adminQueryBudget: 40` and `frontendRenderBudgetMs: 0` (correct — no frontend surface). The page issues `DeploymentConnection::where('is_active',true)->get()` up to three times per render (`getConnections()` called in `@foreach` and again in `count(...)` in the blade), plus `Schema::hasTable` each call. Memoise `getConnections()` to stay well within budget — `resources/views/filament/pages/deployment-connection.blade.php`, `src/Filament/Pages/DeploymentConnectionPage.php`.
- **i18n** — generally good: page, widget, OAuth errors, and enum labels all use `__('capell-deployments::...')`. The hard-coded PR title/body in `PublishComposerRequirementAction` ("Add extension …", "Auto-generated by Capell…") and the commit message are **not** translatable — acceptable for Git artefacts, but worth a note if localisation of repo output is desired.

## 5. Marketplace & Selling

**Current `summary`:** _"Connect a Git repository once, then install or update Capell extensions from admin workflows that publish composer.json changes as reviewed pull requests."_ This now leads with the admin outcome instead of package plumbing, and the `composer.json` description uses the same buyer-facing framing.

**Improved 1-sentence summary:**

> Connect a Git repository once, then install or update Capell extensions from admin workflows that publish `composer.json` changes as reviewed pull requests.

**Improved 3–4 sentence description:**

> Deployments lets site operators connect GitHub, GitLab, or Bitbucket repositories from the Capell admin and publish Composer requirement changes without server shell access. OAuth-backed connections store encrypted tokens, remember the repository owner/name, and let extension install flows open pull requests, auto-merge them, or make direct commits according to policy. It is the Operations bundle bridge between Marketplace installs and reviewed repository changes.

**Screenshot / media gaps:** Manifest advertises the committed marketplace card plus 2 real screenshots (light + dark), and `docs/screenshots.json` maps to those captures. The two functional captures show the pre-connection OAuth entry point; there is still no screenshot of an active connection and no GIF/flow of the OAuth-to-PR journey. Do not claim active-connection or full OAuth-flow media coverage until those assets are committed.

**Pricing / tier / bundle positioning:** `tier: premium`, `bundle: operations`. Premium is defensible _only if_ the differentiators ship — today the live feature set (store a token, render a connection, open a PR with no history/rollback/health gate) is closer to a mid-tier utility. Strengthen with deploy history + rollback + health-gated auto-merge (Section 3) to earn "premium." It pairs naturally inside the Operations bundle alongside **Diagnostics** (publish health surfaced as a real check) and **Migration Assistant** (both are in this package's `docs/Read Next`).

**Cross-sell:** Depends on `capell-app/admin` + `capell-app/core` only. Natural cross-sell to the (presumed) marketplace/extension-install package that should consume `PublishesComposerChanges`, and into the **diagnostics** and **migration-assistant** Extension Suites — e.g. Diagnostics reads a real `DeploymentsHealthCheck`, Migration Assistant triggers a publish when a migration requires a new package.

**Differentiators / value props / target buyer:** Differentiator = "install extensions via a reviewed PR, not SSH." Value props = no-CLI installs, encrypted tokens, choice of direct-commit vs PR-auto-merge vs manual review, multi-provider. Target buyer = agency/operator running client Capell sites who wants to add paid extensions without giving every editor shell access.

**Keywords/tags:** `deployments`, `composer`, `pull-request`, `github`, `gitlab`, `bitbucket`, `oauth`, `extension-install`, `git-integration`, `operations`, `auto-merge`, `repository-connection`.

## 6. Prioritized Roadmap

| Item                                                                                                    | Bucket  | Effort | Impact | Section ref |
| ------------------------------------------------------------------------------------------------------- | ------- | ------ | ------ | ----------- |
| Gate `DeploymentConnectionWidget` with `canView()` (stop repo-identity leak)                            | Done    | S      | High   | §2, §4      |
| Register the widget or delete it + its `screenshots.json` entry                                         | Done    | S      | Med    | §2, §4      |
| Resolve manifest mismatches: drop unbacked `console` surface/capability, fix screenshot count           | Done    | S      | High   | §4          |
| Confirm/wire a real consumer of `PublishesComposerChanges` (or document the external owner)             | Done    | M      | High   | §4          |
| Fix `repo_name => 'app'` placeholder; add repo selection to the page                                    | Done    | M      | High   | §2          |
| Implement a real `DeploymentsHealthCheck` (connection storage + OAuth-config readiness)                 | Done    | M      | High   | §4          |
| Shipped 2026-06-07: OAuth token refresh (`RefreshProviderTokenAction`, persist expiry/refresh token)    | Done    | M      | High   | §3, §2      |
| Surface OAuth-misconfiguration in the page UI + test it                                                 | Done    | S      | Med    | §2, §4      |
| Make bound publisher connection-aware; handle >1 active connection                                      | Done    | M      | Med    | §2, §4      |
| Shipped 2026-06-06: Expose `InstallPolicy` choice in the UI (unlock `PullRequestManual`)                | Done    | S      | Med    | §3          |
| Shipped 2026-06-07: Publish-history table + "recent publishes/status" panel (consume `getDeployStatus`) | Done    | L      | High   | §3, §4      |
| Shipped 2026-06-06: Idempotency: dedupe open PRs for the same package; dry-run for `DirectCommit`       | Done    | M      | Med    | §4          |
| Shipped 2026-06-08: Cancel pending pull-request installs (consume `closePullRequest`)                   | Done    | M      | Med    | §3, §4      |
| Shipped 2026-06-08: Health-gated auto-merge before enabling PR auto-merge                               | Done    | M      | High   | §3          |
| Deploy hooks + success/failure events                                                                   | Later   | M      | High   | §3          |
| Provider webhook ingestion for async deploy/PR status                                                   | Later   | L      | Med    | §3          |
| Capture active-connection + OAuth-to-PR media after real assets exist                                   | Blocked | S      | High   | §5          |