From d20d4ea5c67cc7c53869f22d7824a94ecc4d7a2f Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:07:58 -0700 Subject: [PATCH] Build fresh installations from the minimal self-hosted configuration --- deploy/stage-install.sh | 15 +++++++++++++ docs/QUICKSTART.md | 8 +++++++ install.sh | 35 +++++++++++++++++++---------- scripts/install-stage.test.mjs | 40 ++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 deploy/stage-install.sh create mode 100644 scripts/install-stage.test.mjs diff --git a/deploy/stage-install.sh b/deploy/stage-install.sh new file mode 100644 index 0000000..d91ba2c --- /dev/null +++ b/deploy/stage-install.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Stage the minimal deployment and only the image's runtime build inputs. +set -euo pipefail +source_dir=${1:?source checkout required} +stage_dir=${2:?empty staging directory required} +[[ -d "$stage_dir" && -z "$(ls -A "$stage_dir")" ]] || { echo 'Staging directory must be empty' >&2; exit 1; } +cp "$source_dir/teploy.example.yml" "$stage_dir/teploy.yml" +cp "$source_dir/teploy.install.yml" "$stage_dir/teploy.install.yml" +cp "$source_dir/Dockerfile" "$source_dir/.teployignore" "$stage_dir/" +cp -R "$source_dir/deploy" "$source_dir/dist" "$stage_dir/" +mkdir "$stage_dir/web" +cp -R "$source_dir/web/dist" "$source_dir/web/src" "$stage_dir/web/" +for file in index.html tsconfig.json vite.config.ts neutron.config.ts; do + cp "$source_dir/web/$file" "$stage_dir/web/" +done diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index ffaeaf6..98da518 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -299,3 +299,11 @@ sometimes carries the real thing. Check `SHIP_TESTS=1`, and that the repo has a command: detection needs one of `package.json` `scripts.test`, a Makefile `test:` target, `go.mod`, `Cargo.toml` or pytest config at the root, otherwise set one on the Projects page. + +The installer builds from `teploy.example.yml` in a private temporary context, +then applies its generated `teploy.install.yml`. It does not inherit the +maintainer's production mounts, preview destinations, or integration settings. +The temporary context is removed when the installer exits. Re-run the installer +to update that installation; do not run `teploy deploy -d install` against the +repository's maintainer configuration. The manual path uses the example as your +own `teploy.yml`. diff --git a/install.sh b/install.sh index c81ed72..5f53300 100755 --- a/install.sh +++ b/install.sh @@ -100,7 +100,14 @@ done # teploy's global --host/--user override servers.yml, so every call below names # the target without this script ever editing a tracked config file. -tp() { teploy --host "${host}" --user "${ssh_user}" "$@"; } +install_stage="" +tp() { + if [ -n "${install_stage}" ]; then + teploy --project-dir "${install_stage}" --host "${host}" --user "${ssh_user}" "$@" + else + teploy --host "${host}" --user "${ssh_user}" "$@" + fi +} remote() { ssh -o BatchMode=yes "${ssh_user}@${host}" "$@"; } # -------------------------------------------------------------------------- @@ -312,27 +319,23 @@ fi # Build and deploy. # -------------------------------------------------------------------------- say "building ship" -for tool in pnpm node; do +for tool in pnpm node git; do command -v "${tool}" >/dev/null 2>&1 || die "${tool} is not on PATH (needed to build the deploy artefacts)" done pnpm install --silent pnpm run build (cd web && pnpm install --silent && pnpm run build) -# A generated destination overlay, so the tracked teploy.yml is never edited -# for one operator's box. `teploy deploy -d install` merges it. +# The destination overlay is applied to the minimal example in a private +# staging directory, never to the maintainer's production teploy.yml. say "writing teploy.install.yml" { echo "# Generated by install.sh. Machine-specific, gitignored, safe to delete." echo "server: ${server}" echo "user: ${ssh_user}" echo "env:" - # teploy.yml's env block points at deploy-test's own gateway and Observe - # instance. Blanking them is most of what makes this repo installable by - # someone who is not Tyler: AI_GATEWAY_URL="" means "call the provider - # directly with ANTHROPIC_API_KEY" (src/cli.ts:314-318), and an empty - # OBSERVE_URL turns the telemetry leg off rather than putting a stranger's - # pull requests next to Tyler's metrics. + # A blank gateway selects the direct provider. Optional telemetry and + # delivery stay disabled until this installation configures them. if [ -n "${model_url}" ]; then echo " AI_GATEWAY_URL: \"${model_url}\"" # The endpoint receives the id verbatim; this prefix makes Ship speak @@ -341,6 +344,7 @@ say "writing teploy.install.yml" else echo " AI_GATEWAY_URL: \"\"" fi + echo " SHIP_REPO_ALLOWLIST: \"${allowlist}\"" echo " SHIP_EMBED_MODEL: \"\"" echo " OBSERVE_URL: \"\"" echo " OBSERVE_SERVICE: \"\"" @@ -350,7 +354,7 @@ say "writing teploy.install.yml" # worker-wide command that is wrong for every repo but one is no longer the # default. An explicit per-repo entry still wins over both. echo " SHIP_TEST_COMMAND: \"\"" - # The maintainer's trusted-delivery clone (teploy.yml): off on a new box. + # Trusted delivery is opt-in for a new installation. echo " SHIP_DELIVERY_DIR: \"\"" echo " SHIP_TESTS: \"1\"" echo " SHIP_MODEL: \"${model}\"" @@ -363,6 +367,13 @@ say "writing teploy.install.yml" fi } > "${repo}/teploy.install.yml" +# Keep the caller's checkout/config untouched. Only image inputs enter this +# temporary context; no secret bundles, node_modules, or production mounts. +install_stage=$(mktemp -d "${TMPDIR:-/tmp}/ship-install.XXXXXXXX") +trap 'rm -rf -- "${install_stage}"' EXIT +bash "${repo}/deploy/stage-install.sh" "${repo}" "${install_stage}" +install_version=$(git -C "${repo}" rev-parse --short HEAD) + say "setting secrets on ${server}" args=() for entry in ${secrets[@]+"${secrets[@]}"}; do args+=("${entry}"); done @@ -380,7 +391,7 @@ remote 'd=/deployments/ship/volumes/ship-data; if [ "$(id -u)" = 0 ]; then mkdir || note "WARNING: could not chown /deployments/ship/volumes/ship-data to 1000:1000 — do it yourself before the first run" say "deploying" -tp deploy -d install +tp deploy -d install --version "${install_version}" say "Ship is up" note "dashboard http://${host}:7460" diff --git a/scripts/install-stage.test.mjs b/scripts/install-stage.test.mjs new file mode 100644 index 0000000..ee6eedf --- /dev/null +++ b/scripts/install-stage.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +test('fresh install stages the minimal config and all Docker inputs without operator files', () => { + const temp = mkdtempSync(join(tmpdir(), 'ship-install-test-')); + try { + const source = join(temp, 'source'), stage = join(temp, 'stage'); + mkdirSync(source); mkdirSync(stage); + const files = ['Dockerfile', '.teployignore', 'teploy.example.yml']; + for (const file of files) writeFileSync(join(source, file), readFileSync(join(root, file))); + const artifacts = ['dist/cli.js', 'deploy/package.ship.json', 'deploy/package-lock.ship.json', + 'deploy/package.web.json', 'deploy/package-lock.web.json', 'web/dist/index.html', + 'web/src/routes/index.tsx', 'web/index.html', 'web/tsconfig.json', 'web/vite.config.ts', 'web/neutron.config.ts']; + for (const file of artifacts) { + mkdirSync(join(source, file, '..'), { recursive: true }); + writeFileSync(join(source, file), 'fixture'); + } + writeFileSync(join(source, 'teploy.install.yml'), 'server: example.internal\n'); + for (const file of ['teploy.yml', 'ship-secrets.env', 'private-notes.txt']) writeFileSync(join(source, file), 'DO NOT COPY'); + const result = spawnSync('bash', [join(root, 'deploy/stage-install.sh'), source, stage], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(stage, 'teploy.yml'), 'utf8'), readFileSync(join(root, 'teploy.example.yml'), 'utf8')); + assert.equal(existsSync(join(stage, 'ship-secrets.env')), false); + assert.equal(existsSync(join(stage, 'private-notes.txt')), false); + const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8'); + for (const line of dockerfile.split('\n').filter(line => line.startsWith('COPY ') && !line.includes('--from='))) { + const sources = line.split(/\s+/).slice(1, -1); + for (const path of sources) assert.ok(existsSync(join(stage, path)), `missing Docker input ${path}`); + } + const retry = spawnSync('bash', [join(root, 'deploy/stage-install.sh'), source, stage], { encoding: 'utf8' }); + assert.notEqual(retry.status, 0, 'must refuse a nonempty destination'); + assert.match(retry.stderr, /must be empty/); + } finally { rmSync(temp, { recursive: true, force: true }); } +});