Skip to content

Forge API v2 migration + GitHub-built static binaries - #2

Merged
ngunyimacharia merged 32 commits into
mainfrom
feat/forge-api-v2-migration
Jul 30, 2026
Merged

ngunyimacharia merged 32 commits into
mainfrom
feat/forge-api-v2-migration

Conversation

@ngunyimacharia

@ngunyimacharia ngunyimacharia commented Jul 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Migrates forge-previewer from the deprecated Forge API v1 to API v2 using laravel/forge-sdk ^4.1, upgrades the application stack, and replaces the committed PHAR with self-contained release binaries built by GitHub Actions.

Implements docs/specs/2026-07-27-forge-api-v2-migration-design.md.

Changes

Upstream sync

  • Merge the two missing commits from ryangjchandler/forge-previewer: commit composer.lock and remove InspireCommand.
  • Preserve this fork's --timeout option and config('app.timeout') default.
  • Commit composer.lock going forward.

Runtime and dependency upgrades

  • Require PHP 8.5, Laravel Zero 12.1, Forge SDK 4.1, Termwind 2.4, Pest 4.7, and Mockery 1.6.12.
  • Migrate the bootstrap to Laravel Zero's fluent application builder.
  • Update the PHPUnit configuration and test scaffolding.
  • Rename HandlesOutput::fail() to bail() to avoid a collision with Illuminate\Console\Command::fail().

Forge API v2 migration

  • Scope every Forge API call to an organization using the new --org option or FORGE_ORG environment variable.
  • Use lazy pagination for list endpoints so every page is scanned.
  • Update site creation, deployment scripts, environment updates, deployments, commands, and resource deletion for Forge SDK v4.
  • Create and delete certificates per domain, using dns-01 for wildcard domains and http-01 otherwise.
  • Fix database-user deletion so it deletes the user rather than the stale database variable.
  • Change the default PHP version from php81 to php84.
  • Write CI output to $GITHUB_OUTPUT instead of using the deprecated ::set-output command.

Forge API v2 does not accept DNS-provider credentials when creating certificates. The existing --route-53-key and --route-53-secret options remain for CLI compatibility, but credentials must now be configured in Forge for the dns-01 challenge.

CI and distribution

  • Run Pest on PHP 8.5 for pushes and pull requests.
  • Build a PHAR and then a static musl binary when a v* tag is pushed.
  • Smoke-test the binary natively and in Ubuntu 20.04 before attaching binaries and checksums to the GitHub release.
  • Stop committing builds/forge-previewer and use the source entry script for Composer installations.
  • Document binary and source installation, organization configuration, wildcard certificates, and current CI examples.

Verification

  • ./forge-previewer list
  • ./forge-previewer deploy --help
  • ./forge-previewer destroy --help
  • Missing-organization error handling
  • Pest test suite: 3 tests passed
  • composer validate
  • Workflow YAML validation
  • Reflection check for all 24 Forge SDK methods used by the application

The static-binary release pipeline requires a real v* tag, and a live deploy/destroy test requires Forge credentials. Both remain gated by their respective external prerequisites.

@ngunyimacharia
ngunyimacharia force-pushed the feat/forge-api-v2-migration branch from 1a60477 to 7c46dad Compare July 27, 2026 15:21
ngunyimacharia and others added 2 commits July 28, 2026 13:12
app('git.version') shells out to git at boot; inside a PHAR the cwd is a phar:// path so proc_open fails and the packaged binary cannot start. Bake the tag in as a literal at build time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Box's requirements checker rejects the embedded PHP without iconv (required by symfony/polyfill-mbstring). Add it to the static-php-cli extension set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ngunyimacharia

Copy link
Copy Markdown
Member Author

Release pipeline verified end-to-end via v1.0.0-beta.1 (tag → release.yml, all 3 jobs green, both smoke tests incl. ubuntu:20.04, assets published + checksums verified, binary runs standalone reporting v1.0.0-beta.1).

The beta run caught two bugs that would have shipped a non-booting binary — both fixed here:

  • fix(ci): stamp release version… — app('git.version') ran git describe at boot with cwd = phar://…, crashing proc_open.
  • fix(ci): add iconv extension… — Box's requirements checker rejected the embedded PHP without iconv (needed by symfony/polyfill-mbstring).

Still outstanding (needs real credentials): a live-Forge deploy→destroy against a test org/server.

ngunyimacharia and others added 20 commits July 28, 2026 14:55
…ation errors

Forge API v2 validation requires www_redirect_type (the OpenAPI schema marked it optional, but the live API rejects createSite without it) — default to 'none' for preview subdomains. Also catch Laravel\Forge\Exceptions\ValidationException in both commands and print the field-level errors instead of the opaque 'The given data failed to pass validation.'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ested error bag

Forge API v2 requires letsencrypt.key_type on createCertificate (default ecdsa). Also fix bailValidation: the SDK passes the full {message, errors} body, so flatten the nested errors bag recursively instead of interpolating arrays into strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anaged databases)

Servers using an external database return 404 on database/schemas and database/users. safelyIterate() treats a NotFoundException on a list endpoint as empty so destroy still cleans up the remaining resources and deletes the site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olerate cert conflicts

If createSite returns a 'domain has already been added' 422 (e.g. a prior partial/failed create that our site scan missed), look the site up and continue provisioning (setup-command, env, cert, deploy) instead of aborting and leaving it half-configured. Also tolerate a ValidationException on createCertificate (a reused site may already have one). Extract flattenValidationMessages into HandlesOutput.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rovisioning

Forge returns a 422 ('recently created, wait 60s') when updating the .env of a just-created site. Retry the read-modify-write (bounded by --timeout) until provisioning completes, for both the --edit-env and database env-injection paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enabling push_to_deploy in createSite makes Forge auto-deploy the instant the repo finishes cloning — before the deployment script and environment are configured — running Forge's default script and racing/blocking our own deployment. Create the site with push_to_deploy=false, run the explicit deployment (correct script + env), then enable push-to-deploy so future pushes still auto-deploy (skipped when --no-quick-deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ wedges)

forge-previewer always runs a deployment immediately after creating the site, which installs dependencies. Asking Forge to also install them during creation is redundant and runs an uncontrollable parallel composer install that, on servers hitting composer's download race, wedges and blocks our deployment. The deployment (with its serialized composer + shared-env restore) is the single source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Site creation (repo clone + directory setup) is async. createDeployment was firing before the site directory existed, so the deploy script's first command (cd <site dir>) failed instantly (2s). Poll the site until SiteStatus leaves creating/installing before running setup/cert/deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o a concern

Move safelyIterate, findSiteByName, and a 404-safe findPrimaryDomainId
into App\Commands\Concerns\FindsSiteResources so deploy and destroy stop
maintaining divergent copies of the same Forge lookup loops.
- Only tolerate certificate 422s that indicate the certificate already
  exists; any other validation error (unconfigured DNS provider for
  dns-01, invalid domain, rate limit) now fails the deploy instead of
  exiting 0 without HTTPS.
- Bail with a clear error when waitForSiteInstalled hits its deadline
  while the site is still installing, instead of racing the install.
- Narrow the install-wait catch from Throwable to NotFoundException and
  TimeoutException so permanent errors surface immediately.
- Tolerate 404 when listing scheduled jobs and databases, matching the
  destroy command (servers without a managed database service 404 on
  those endpoints).
- Drop the generic 'could not be updated' phrase from the provisioning
  retry match; it could retry permanent validation errors for the full
  timeout. Document the observed Forge messages the remaining phrases
  come from.
Forge API v2 has no field for DNS-provider credentials, so
--route-53-key/--route-53-secret are accepted but never forwarded.
Requiring them alongside --wildcard forced users to pass inert values
and broke the documented wildcard workflow example. Remove the
requirement and mark both options as deprecated in the help output.
…assification

- Hoist enablePushToDeploy out of the --no-deploy gate: a site created
  with --no-deploy would otherwise never get push-to-deploy, because
  later runs find the existing site and never re-enter the create path.
- Treat any settled SiteStatus as ready in waitForSiteInstalled. Only
  creating/installing mean the install is running; requiring a literal
  'installed' made reused sites (deployed/never-deployed) spin until
  the deadline and fail. Throw on 'failed'; drop 'error', which is not
  a SiteStatus value.
- Introduce ProvisioningFailedException and throw it from the deploy
  abort paths instead of bare RuntimeException. Guzzle transport
  exceptions extend RuntimeException, so the previous catch in handle()
  misclassified transient HTTP failures as clean abort conditions.
- Catch the SDK TimeoutException in handle(): createDatabase waits
  internally for the schema to install and throws it on expiry, which
  previously crashed with a raw stack trace.
…ing aborts

Log when the primary domain record cannot be resolved instead of
silently skipping certificate deletion, and convert
ProvisioningFailedException into a clean bail like the deploy command.
Forge can return a message-only 422 with a present-but-empty errors
array; flattening only the bag discarded the sole human-readable detail.
Every other paginated lookup in the trait tolerates a 404 as an empty
collection; make the site lookup consistent.
The tag is spliced into a sed script and a single-quoted PHP string; a
tag containing | or & would corrupt config/app.php or abort the sed
expression. Restrict tags to [A-Za-z0-9._-] before stamping.
- Abort with a clear message when the site status turns to removing or
  uninstalling mid-wait (a concurrent destroy) instead of treating a
  tearing-down site as ready.
- Convert an unexpected mid-deploy 404 (site or server deleted
  concurrently, e.g. from enablePushToDeploy) into a clean bail instead
  of an uncaught exception.
- Retry the deployment-script update while the site is still
  provisioning, matching the env-file writes that hit the same
  'recently created' cooldown.
- Sanitize isolated_user: Forge requires ^[a-z][-a-z0-9_]*$ (max 32),
  so prefix slugs that start with a digit and clamp the length —
  --isolate previously failed for branch names like 123-fix-thing.
Treat a 404 on any individual delete (scheduled job, database, database
user, certificate, site) as already-removed so a retried or concurrent
destroy completes cleanly, and bail with a clear message on any other
unexpected 404 instead of crashing with a stack trace.
Push-to-deploy is enabled after the first deployment (and independently
of --no-deploy); the certificate is requested during site creation.
…rrors

- Run waitForSiteInstalled on the found-existing path too: a reused
  site can be mid-install (concurrent deploy) or mid-teardown
  (concurrent destroy), the same races already handled on the create
  path.
- Gate push-to-deploy on the site's actual quickDeploy state instead of
  a created-this-run flag: a first run that bailed after site creation
  permanently lost push-to-deploy on retry.
- Suffix a short hash when clamping isolated_user to 32 characters so
  long branch names that share a 32-char prefix don't collide on the
  same isolation user.
- Bail cleanly on ForbiddenException, RateLimitExceededException, and
  FailedActionException instead of crashing with a stack trace.
Move the site lookup inside the guarded block so a 403 or transient
failure while listing sites produces a formatted error, and add the
same ForbiddenException/RateLimitExceededException/FailedActionException
handling as the deploy command.
… boot

config/app.php resolved app('git.version') eagerly, which shells out to
git describe. Inside a PHAR the cwd is a phar:// path and proc_open
fails, so a PHAR built via the documented 'box compile' steps crashed
on every invocation before any command ran. Fall back to 'unreleased'
and document the stamping step for versioned manual builds; the release
workflow still bakes the real tag in before compiling.
A site can report status 'failed' from an earlier run (failed install
or deployment). Re-provisioning it is the established recovery path, so
only treat 'failed' as fatal for freshly created sites; reuse paths log
and continue instead of blocking every retry.
@ngunyimacharia
ngunyimacharia merged commit 401e52e into main Jul 30, 2026
4 checks passed
@ngunyimacharia
ngunyimacharia deleted the feat/forge-api-v2-migration branch July 30, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants