Guides · Guides
Deploy from GitHub
Build your app's front end in your own repository and have every push to main publish it at your site's readable address — https://habit-tracker.manybases.online, derived from the project name at the first deployment, or claimed before with claim_site_subdomain. The derived https://proj-<id>.manybases.online keeps serving too. No key is stored anywhere: the workflow proves which repository it runs in, and Manybases accepts the upload because that repository is linked to your project.
Four gestures. The first is a human's, once per account; your agent makes the other three with its tools — linking and obtaining the workflow have no dashboard form any more.
1 · Connect GitHub to your account#
On the dashboard, Integrations → GitHub → Connect GitHub. Install the Manybases GitHub App on your account or organization if it is not installed yet, and authorize. For an organization, you must be one of its active admins. The App only reads which repositories exist — it never reads your code, writes a file, sets a secret or touches your workflows.
On GitHub, pick All repositories so your agent can deploy the repositories it creates, without you adding each one. Trade-off: your agent can then link any repository on that account — and anyone who can push to a linked repository can deploy its app.
This is the consent that everything below relies on: the App's installation defines which repositories can ever deploy on this account. Details: Integrations.
2 · Link the repository to the project#
With the agent's link_repo tool, which takes the repository's numeric id (the id GitHub returns, never owner/name) — or a direct PUT /v1/projects/:pid/github/repository with the account key. A project has one linked repository; linking another replaces it, and the response names the one that lost the right. The project's Deployments tab shows which repository is linked, and is where a human unlinks it.
If the repository is not covered by the installation, the link is refused with a 404 whose hint carries the GitHub URL that fixes it: add the repository to the installation (or switch it to all repositories), then link again. Nothing you pass in the call can repair that refusal.
3 · Add the workflow#
Ask the agent for it with get_deploy_workflow, which returns the file below pre-filled with your project id and says whether the project is linked yet — or copy it from here and fill in PROJECT_ID yourself. Save it as .github/workflows/manybases-deploy.yml. It builds with npm ci && npm run build and uploads whatever lands in dist; change BUILD_DIR if your tool writes elsewhere (out for a Next static export, dist for Vite and Astro).
name: Deploy site to Manybases
# Runs on every push to main, and on demand from the Actions tab.
#
# NOT on pull_request, on purpose: a pull request opened from this same
# repository can ask for the same deployment token, so it would deploy its
# own build over your live site before anyone reviewed it.
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
# Writing this block switches this job's built-in token to a closed
# list: anything not named here is denied. Both lines are needed.
permissions:
# actions/checkout reads your repository with that token. Without
# this line the very first step of the job fails.
contents: read
# And this is the line that lets the runner ask for a deployment
# token. Without it, the two variables read below do not exist.
id-token: write
env:
PROJECT_ID: proj_XXXXXXXXXXXXXXXXXXXXXXXXXX # your project id
API: https://api.manybases.com
# The folder your build writes into. Change it to match your tool:
# vite and astro use dist, next with static export uses out.
BUILD_DIR: dist
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
# Two literal commands, never a command held in a variable:
# running a variable is an injection waiting to happen.
- run: npm ci
- run: npm run build
- name: Upload the built site to Manybases
run: |
# Nothing to store anywhere. GitHub signs a short-lived token that
# names this repository, and Manybases accepts it because you
# linked that repository to this project.
#
# It is asked for HERE, inside the step that uses it, and never
# handed to a later step: a value written to a step output or to
# the job environment lands on the runner disk and stays readable
# by everything that runs after it. This one lives in a shell
# variable that dies with this step.
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then
echo "::error::this job cannot ask for a token - check that the permissions block above still says id-token: write"
exit 1
fi
# jq ships on ubuntu-latest. If you changed runs-on, make sure
# curl and jq exist there, or this stops on the check below.
answer=$(curl -sS -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://api.manybases.com")
TOKEN=$(printf "%s" "${answer}" | jq -r ".value" 2>/dev/null || true)
if [ -z "${TOKEN}" ] || [ "${TOKEN}" = "null" ]; then
echo "::error::GitHub did not return a deployment token - check the permissions block above, and that curl and jq exist on this runner"
exit 1
fi
# Masked before it can reach a log by any path. The answer that
# carries it is never printed either.
echo "::add-mask::${TOKEN}"
cd "${BUILD_DIR}"
# One multipart part per file. The part filename carries the path
# inside the site, and the part content type is what will be served.
# curl -F on a regular file computes and sends Content-Length, which
# the API requires: it refuses a chunked body outright.
args=()
n=0
while IFS= read -r -d '' f; do
rel="${f#./}"
case "${rel}" in
*.html) mime=text/html ;;
*.css) mime=text/css ;;
*.js|*.mjs) mime=text/javascript ;;
*.json) mime=application/json ;;
*.svg) mime=image/svg+xml ;;
*.png) mime=image/png ;;
*.jpg|*.jpeg) mime=image/jpeg ;;
*.webp) mime=image/webp ;;
*.avif) mime=image/avif ;;
*.gif) mime=image/gif ;;
*.ico) mime=image/x-icon ;;
*.woff2) mime=font/woff2 ;;
*.woff) mime=font/woff ;;
*.txt) mime=text/plain ;;
*.xml) mime=application/xml ;;
*.webmanifest) mime=application/manifest+json ;;
*.map) mime=application/json ;;
# Anything else is served as bytes. Add a line above if your
# site needs a type this list does not cover.
*) mime=application/octet-stream ;;
esac
args+=(-F "f${n}=@${f};filename=/${rel};type=${mime}")
n=$((n + 1))
done < <(find . -type f -print0)
if [ "${n}" -eq 0 ]; then
echo "::error::no files found in ${BUILD_DIR} — did the build run?"
exit 1
fi
echo "uploading ${n} file(s) to project ${PROJECT_ID}"
# The body goes to a temporary file OUTSIDE this folder: we are
# standing inside the directory that is about to be published, and
# anything written here becomes a page of your site.
answer_file=$(mktemp)
# And the response headers go to a second one. That is where the
# request id lives: the body of a refusal does not carry it, and
# without it you have nothing to give support - or to match against
# the Request id column of the Deployments screen.
headers_file=$(mktemp)
# Read BOTH the status and the body: on a refusal the API names the
# offending file, and a job that only checked curl's exit code would
# go green on a 413.
set +e
code=$(curl -sS -X PUT "${API}/v1/projects/${PROJECT_ID}/site" \
-H "Authorization: Bearer ${TOKEN}" \
"${args[@]}" \
-D "${headers_file}" -o "${answer_file}" -w "%{http_code}")
rc=$?
set -e
if [ "${rc}" -ne 0 ]; then
echo "::error::curl failed (exit ${rc})"
exit 1
fi
cat "${answer_file}"
echo
# The header name is case-insensitive and the line ends with CR on
# HTTP/1.1, so both are handled here rather than trusted.
request_id=$(tr -d "\r" < "${headers_file}" | awk 'tolower($1)=="x-request-id:"{print $2}')
echo "manybases request id: ${request_id}"
if [ "${code}" != "200" ]; then
echo "::error::Manybases refused the deployment (HTTP ${code}, request id ${request_id}) — see the message above"
exit 1
fi
echo "site deployed"Three things the workflow does that are worth keeping if you edit it: it never runs on pull_request (a pull request from the same repository could ask for the same token and deploy its build over your live site); the permissions block is what lets the runner ask for a token, and both lines are needed; the token is requested inside the step that uses it and never written to an output or the job environment.
Replacing an older workflow that held an account key in a repository secret named MANYBASES_ACCOUNT_KEY? Nothing reads that secret any more; delete it from the repository once this workflow is in place. Leave the key itself alone unless you are sure nothing else uses it — an account has a single one, and the Account screen reminds you, next to Revoke, that revoking it also disconnects an MCP connector.
4 · Push#
Every push to main rebuilds and replaces the site; you can also run the workflow by hand from the Actions tab. The run's log ends with the address the site is served at, and a line manybases request id: ….
When the upload is refused, the log shows the API's message and the request id, and the Deployments screen shows the same attempt in its history — filter on Refused attempts — with that id:
| The run says | It means |
|---|---|
404 — project not found | The repository this run is in is not the one linked to the project, the link was removed, the installation behind it was removed on GitHub, or the project id in the workflow is wrong |
413 — a file is too large | 5 MiB per file, 10 MiB per site, 500 files. The message names the file |
400 — a path is rejected | A path with .., a backslash, or a duplicate |
401 | The token could not be verified — check the permissions block |
A refusal leaves the previous site intact: the deposit is atomic.
After the first deploy#
- The site is public the second the upload succeeds — no password, no allow-list, no staging URL.
- The project's app URL is filled in with the site's address if it was empty. It is never replaced when it is set.
- No copy is kept. To roll back, run the workflow again on an earlier commit: the repository is the only place the site exists.
- The history on the Deployments tab (or
list_site_deployments) lists deployments, removals, refused attempts and subdomain claims with their request id. It is not guaranteed complete: refusals are recorded under a per-IP rate limit, so a burst can leave one out.
Stopping a repository from deploying#
- Unlink the project from its Deployments tab, or disconnect the GitHub account from Integrations → GitHub: immediate, checked on every upload.
- Uninstalling the App on GitHub is noticed within about ten minutes; until then a push still replaces the site.
- The deployed site keeps serving in every case. To take it down,
DELETE /v1/projects/:pid/sitewith the account key.
What this does not protect#
Anyone who can push to the linked repository can deploy the project's site — that is GitHub's model, not ours, and the link is on the repository, not a branch. Keep the installation to the repositories that should deploy, and be careful with an old repository that has outside collaborators.
Two other ways to deploy#
- The agent's
deploy_sitetool, for a hand-written page: at most 30 files and 100 KB, because every byte travels through the conversation. - A direct
PUT /v1/projects/:pid/sitewith the account key, from any CI or a laptop: one multipart part per file, the part's filename being the path in the site. See Sites.
