Build a launch pad: one push gives an app its own repo, Worker and subdomain

Claude Code builds a monorepo where pushing one app folder makes a GitHub Action create its repo, deploy it to its own subdomain and wire auto-deploy, refusing anything it didn't create.

Claude Code advanced Tested Opus 5.5 · Nov 2026May be dated

The prompt

Run it in a Claude Code session at the root of a new, empty, private repository in the org that will own the apps. App code lives on its branches, so a public launch pad would publish private apps. Claude writes the pipeline; you merge it into main, add the secrets and GitHub Apps, then ship the first app from a branch.

Fill in the blanks4 placeholders · empty ones stay as {{…}}

Example

Input: a tabletop run in a new private repository, northwind-apps/shipyard, that holds only a short README.md. Claude wrote the files outside the repo and had no network access to Cloudflare or GitHub. The values:

  • github_org: northwind-apps
  • domain: northwind.app
  • templates: web (Vite, React and TypeScript) and static (one HTML page in public/, no build step)
  • directory_repo: northwind-apps/www, file data/catalog.json, rows of name, title, description, url and shipped

Output (trimmed to the refusals in scripts/ship.sh):

  # b. Refusals. Read-only, fail closed: an API error stops the run, it never counts as "free".
  #    They exist because wrangler deploy, run non-interactively, silently takes over a custom domain
  #    or DNS record that something else holds.
  step "Refusals"
  local repo_state worker_exists=false host_worker zone_id records
  http GET "$GH_API/repos/$REPO"
  case "$HTTP_CODE" in
    404) repo_state=absent ;;
    200)
      http GET "$GH_API/repos/$REPO/contents/ship.json"
      if [ "$HTTP_CODE" = 200 ] && [ "$(jq -r '.type // empty' <<<"$HTTP_BODY")" = file ]; then
        repo_state=ours
      elif [ "$HTTP_CODE" = 404 ]; then
        http GET "$GH_API/repos/$REPO/commits?per_page=1"
        case "$HTTP_CODE" in
          409) repo_state=empty ;;   # a repo with no commits holds nothing to overwrite
          200) fail "github.com/$REPO already exists and the launch pad didn't create it (no ship.json at its root). Nothing was changed. Pick another name: scaffold a new app with scripts/new-app.sh and move the code there." ;;
          *)   fail "GitHub API error listing commits of github.com/$REPO ($HTTP_CODE: $(gh_err)). Nothing was changed. $(hint "$PAT_SECRET can read all $OWNER repositories.")" ;;
        esac
      …
  # The deploy would move the hostname off the Worker that serves it now.
  [ -z "$host_worker" ] || [ "$host_worker" = "$APP" ] \
    || fail "$HOST is already served by the Worker \"$host_worker\". Nothing was changed. Pick another name."
  # A Worker with this name that isn't on its hostname belongs to something else; the deploy would replace its code.
  if [ "$worker_exists" = true ] && [ "$host_worker" != "$APP" ] && [ "$repo_state" != ours ]; then
    fail "A Worker named \"$APP\" already exists, doesn't serve $HOST and has no launch-pad repo. Nothing was changed. Pick another name."
  fi
  …
    # An old record (say a CNAME to a previous host) would be replaced by the deploy without a word.
    [ -z "$records" ] || fail "$HOST has DNS records but no Worker: $records. Nothing was changed. If nothing uses them, delete them (Cloudflare dashboard → $DOMAIN → DNS → Records) and re-run; otherwise pick another name."

The refusals run read-only before the first write, every one ends with "Nothing was changed.", and an unexpected status from GitHub or Cloudflare stops the run instead of counting as free. Beyond the brief, Claude also made the build step refuse a custom build block in wrangler.jsonc, which would run the app's commands next to the secrets, and its offline test script passed all 97 checks.

Why it works

  • Privilege lives in CI, and only in the deploy step. The session needs nothing but a branch push. The build step runs the app's own npm scripts, so it gets no secrets, though a malicious dependency can still tamper with the shared runner; full isolation needs a separate deploy job. The workflow also runs the branch's own scripts, so push access to the launch pad is the real privilege: keep the app org separate from anything else.
  • One ownership marker, checked before the first write. ship.json at a repo's root is how ship and retire tell their own work from everything else. The refusals matter because a non-interactive wrangler deploy claims a custom domain or DNS record that something else holds without asking, so a reused name would hijack a live site rather than fail. Allowing exactly one route keeps a second hostname from slipping past them, and treating an API error as "taken" stops a flaky call becoming a takeover. Retire repeats the same checks behind a typed confirmation.
  • Push before deploy, never force. The subtree push fast-forwards the app repo's main, so after the first ship the app repo can become the source of truth. Once it has moved on, the rejected push stops the run before old launch-pad code redeploys over newer work.
  • The session can read its own result. watch.sh uses only GitHub REST, finishes inside a 10-minute command timeout, prints the failing step and the annotation that names the fix, and exits with a code the session can act on. Side effects (the directory row, Previews, a slow certificate) only warn; the one failure after deploy is unwired production auto-deploy, reported with its fix once the site is live. The SKILL.md drives that loop from the session side.

Variations

  • Adopt an existing repo. Land ship.json and a deploy config that agrees with it on the repo's main through a pull request. Then use git subtree add to pull the repo, history and all, into apps/<name>, and confirm that splitting that subtree gives back exactly the repo's main commit. The first ship push then has nothing to fast-forward, and the repo keeps its name and history.
  • Add a backend. A --backend flag layers a container service on the web template. The same Action deploys it with keyless OIDC sign-in rather than a stored key, the Worker forwards /api/* to it, and a label on the service is the ownership marker for the refusal and retire checks. Secrets go from a secret manager into Worker secrets through stdin in CI, never through a person or a log.
  • Generalised from a launch-pad repo that ships small web apps.

Install as a skill

This entry comes with a SKILL.md. Claude loads it whenever a request matches its description.

  • Claude Code: unzip into .claude/skills/ in your project, or ~/.claude/skills/ for every project.
  • Claude apps: upload the .zip under Customize → Skills → + → Create skill → Upload a skill. Skills need code execution, which is under Settings → Capabilities.