Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions deploy/stage-install.sh
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions docs/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
35 changes: 23 additions & 12 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}" "$@"; }

# --------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -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: \"\""
Expand All @@ -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}\""
Expand All @@ -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
Expand All @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions scripts/install-stage.test.mjs
Original file line number Diff line number Diff line change
@@ -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 }); }
});
Loading