g1t/deploy/self-host/smoke.sh

295 lines16,028 bytesCodeBlame
1#!/usr/bin/env bash
2# End-to-end check of a self-hosted g1t: sign up, confirm the email, make a
3# workspace and a repository, push and clone over HTTP, open an issue, and
4# read the code back through the site. Then the API on its own port (REST,
5# OAuth metadata and MCP, with an access token), an npm package published to
6# the installation's registry and installed back, pull requests from a branch
7# and from a fork merged onto main, the merge queue taking a pull request
8# and giving it back, and every cron handler the scheduler runs.
9#
10# ./smoke.sh # against the compose stack's defaults
11# G1T_URL=http://localhost:8787 MAIL_LOG=wrangler.log ./smoke.sh
12#
13# The confirmation link is read from Mailpit (MAILPIT_URL, the default) or,
14# with MAIL_LOG set, from a log the mail Worker printed it to. API_URL and
15# MCP_URL are where the API is (default: G1T_URL's host on port 8789).
16# SCHEDULER_ONCE is the command that runs every cron once (scheduler.mjs
17# --once, inside the g1t container); unset, that step is skipped:
18#
19# SCHEDULER_ONCE="docker compose -f deploy/self-host/docker-compose.yml exec -T g1t \
20# node deploy/self-host/scheduler.mjs --once /data/generated/schedules.json" ./smoke.sh
21#
22# (In Git Bash on Windows, start the command with `env MSYS_NO_PATHCONV=1`
23# so /data is not rewritten into a Windows path.)
24# PACK_CACHE=off skips the check that a second clone is served from the
25# clone pack cache. PACKAGES=off skips publishing an npm package to the
26# installation's registry and installing it back.
27#
28# Needs curl, git and node (to read JSON), and npm for the package.
29set -euo pipefail
30
31G1T_URL="${G1T_URL:-http://localhost:8787}"
32API_URL="${API_URL:-$(node -e 'const u = new URL(process.argv[1]); u.port = "8789"; console.log(u.origin)' "$G1T_URL")}"
33MCP_URL="${MCP_URL:-$API_URL/mcp}"
34MAILPIT_URL="${MAILPIT_URL:-http://localhost:8025}"
35MAIL_LOG="${MAIL_LOG:-}"
36SCHEDULER_ONCE="${SCHEDULER_ONCE:-}"
37# off: this installation keeps no clone packs (no PACK_STORE), so a second
38# clone is not checked for a kept one.
39PACK_CACHE="${PACK_CACHE:-on}"
40# off: skip publishing and installing an npm package (which needs npm).
41PACKAGES="${PACKAGES:-on}"
42RUN="$(date +%s)"
43USER_NAME="smoke${RUN}"
44EMAIL="${USER_NAME}@example.com"
45PASSWORD="correct-horse-${RUN}"
46WORKSPACE="ws${RUN}"
47REPO="hello"
48WORK="$(mktemp -d)"
49JAR="$WORK/cookies"
50trap 'rm -rf "$WORK"' EXIT
51
52step() { printf '\n== %s\n' "$*"; }
53fail() { printf 'FAILED: %s\n' "$*" >&2; exit 1; }
54
55# A form POST as a browser sends it, with the Origin the site checks.
56post() {
57 local path="$1"; shift
58 curl -sS -o "$WORK/body" -w '%{http_code} %{redirect_url}' -b "$JAR" -c "$JAR" \
59 -H "Origin: $G1T_URL" "$@" "$G1T_URL$path"
60}
61get() {
62 curl -sS -o "$WORK/body" -w '%{http_code}' -b "$JAR" -c "$JAR" "$G1T_URL$1"
63}
64# An API call with the access token: method, path, optional JSON body. The
65# answer is in $WORK/api; the status is printed.
66api() {
67 local method="$1" path="$2" body="${3:-}"
68 local args=(-sS -o "$WORK/api" -w '%{http_code}' -X "$method" -H "Authorization: Bearer $TOKEN")
69 [ -n "$body" ] && args+=(-H "content-type: application/json" --data "$body")
70 curl "${args[@]}" "$API_URL$path"
71}
72# A field of the last API answer (or of FILE), by a JavaScript path: `json pull.number`.
73json() {
74 node -e 'const v = process.argv[2].split(".").reduce((o, k) => o?.[k], JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))); console.log(typeof v === "object" ? JSON.stringify(v) : v ?? "")' "${2:-$WORK/api}" "$1"
75}
76# Waits for pull request $1 to have status $2.
77until_status() {
78 local status=""
79 for _ in $(seq 1 30); do
80 [ "$(api GET "/repos/$WORKSPACE/$REPO/pulls/$1")" = 200 ] && status="$(json pull.status)"
81 [ "$status" = "$2" ] && return 0
82 sleep 1
83 done
84 fail "pull request #$1 is $status, not $2: $(head -c 400 "$WORK/api")"
85}
86# Whether main, cloned fresh, has a file.
87main_has() {
88 rm -rf "$WORK/main"
89 git -c credential.helper= clone -q "$G1T_URL/$WORKSPACE/$REPO.git" "$WORK/main"
90 [ -f "$WORK/main/$1" ]
91}
92# A commit on a new branch of $1 (a clone), pushed to $2.
93commit_file() {
94 local dir="$1" remote="$2" branch="$3" file="$4"
95 (
96 cd "$dir"
97 git config user.name "Smoke Test"
98 git config user.email "$EMAIL"
99 git config commit.gpgsign false
100 git switch -q -c "$branch" 2>/dev/null || git switch -q "$branch"
101 mkdir -p "$(dirname "$file")"
102 printf 'Changed by smoke.sh on %s.\n' "$branch" > "$file"
103 git add . && git commit -qm "Add $file"
104 git -c credential.helper= push -q "$remote" "HEAD:$branch"
105 )
106}
107
108step "site answers at $G1T_URL"
109[ "$(get /)" = 200 ] || fail "GET / did not answer 200"
110
111step "sign up as $USER_NAME"
112out="$(post /register --data-urlencode "username=$USER_NAME" --data-urlencode "email=$EMAIL" --data-urlencode "password=$PASSWORD")"
113echo "$out"
114case "$out" in 30[23]*) ;; *) fail "register: $out $(head -c 300 "$WORK/body")" ;; esac
115# curl keeps Secure cookies only for https or localhost; carry it by hand.
116grep -q g1t_session "$JAR" || fail "no session cookie"
117
118step "confirm the email"
119link=""
120for _ in $(seq 1 20); do
121 if [ -n "$MAIL_LOG" ]; then
122 link="$(grep -ao "[a-z]*://[^ \"<]*/verify?token=[0-9a-zA-Z_-]*" "$MAIL_LOG" | tail -1 || true)"
123 else
124 id="$(curl -sS "$MAILPIT_URL/api/v1/search?query=to:$EMAIL" | sed -n 's/.*"ID":"\([^"]*\)".*/\1/p' | head -1)"
125 [ -n "$id" ] && link="$(curl -sS "$MAILPIT_URL/api/v1/message/$id" | grep -ao '[a-z]*://[^ "<\\]*/verify?token=[0-9a-zA-Z_-]*' | head -1 || true)"
126 fi
127 [ -n "$link" ] && break
128 sleep 1
129done
130[ -n "$link" ] || fail "no confirmation email arrived"
131echo "$link"
132[ "$(get "/verify?${link#*\?}")" = 200 ] || fail "verify"
133grep -q "$USER_NAME" "$WORK/body" || fail "verify page does not name the account"
134
135step "create workspace $WORKSPACE"
136out="$(post /workspaces/new --data-urlencode "slug=$WORKSPACE" --data-urlencode "displayName=Smoke $RUN")"
137echo "$out"
138case "$out" in 30[23]*) ;; *) fail "workspace: $out $(head -c 300 "$WORK/body")" ;; esac
139
140step "create repository $WORKSPACE/$REPO"
141out="$(post /new --data-urlencode "workspace=$WORKSPACE" --data-urlencode "name=$REPO" --data-urlencode "description=Self-host smoke test" --data-urlencode "visibility=public" --data-urlencode "source=empty")"
142echo "$out"
143case "$out" in 30[23]*) ;; *) fail "repo: $out $(head -c 300 "$WORK/body")" ;; esac
144
145step "push over HTTP"
146remote="${G1T_URL/:\/\//://$USER_NAME:$PASSWORD@}/$WORKSPACE/$REPO.git"
147git init -q -b main "$WORK/src"
148(
149 cd "$WORK/src"
150 git config user.name "Smoke Test"
151 git config user.email "$EMAIL"
152 # A throwaway commit: never signed, whatever the global config says.
153 git config commit.gpgsign false
154 printf '# hello\n\nPushed to a self-hosted g1t.\n' > README.md
155 mkdir -p src && printf 'fn main() {\n println!("hello from g1t");\n}\n' > src/main.rs
156 git add . && git commit -qm "First commit"
157 git -c credential.helper= push -q "$remote" main
158)
159echo "pushed $(git -C "$WORK/src" rev-parse --short HEAD)"
160
161step "clone over HTTP"
162git -c credential.helper= clone -q "$G1T_URL/$WORKSPACE/$REPO.git" "$WORK/clone"
163diff -q "$WORK/src/README.md" "$WORK/clone/README.md" || fail "clone differs"
164echo "clone matches"
165
166if [ "$PACK_CACHE" != off ]; then
167 step "clone again: the pack comes from the cache"
168 # The repos service says in Server-Timing whether the pack was kept.
169 GIT_TRACE_CURL=1 GIT_TRACE_CURL_NO_DATA=1 git -c credential.helper= clone -q "$G1T_URL/$WORKSPACE/$REPO.git" "$WORK/again" 2> "$WORK/trace"
170 grep -qi 'server-timing:.*pack;desc=hit' "$WORK/trace" || fail "the second clone's pack was not kept: $(grep -io 'pack;desc=[a-z]*' "$WORK/trace" | tr '\n' ' ')"
171 diff -qr --exclude=.git "$WORK/clone" "$WORK/again" >/dev/null || fail "the kept pack differs"
172 echo "hit"
173fi
174
175step "open an issue"
176out="$(post "/$WORKSPACE/$REPO/issues/new" --data-urlencode "title=It works" --data-urlencode "body=Opened by smoke.sh")"
177echo "$out"
178case "$out" in 30[23]*/issues/1) ;; *) fail "issue: $out $(head -c 300 "$WORK/body")" ;; esac
179[ "$(get "/$WORKSPACE/$REPO/issues/1")" = 200 ] || fail "issue page"
180grep -q "It works" "$WORK/body" || fail "issue page does not show the title"
181
182step "browse code in the site"
183[ "$(get "/$WORKSPACE/$REPO/code")" = 200 ] || fail "code page"
184grep -q "README.md" "$WORK/body" || fail "code page does not list README.md"
185[ "$(get "/$WORKSPACE/$REPO/blob/main/src/main.rs")" = 200 ] || fail "blob page"
186grep -q "hello from g1t" "$WORK/body" || fail "blob page does not show the file"
187[ "$(get "/$WORKSPACE/$REPO/commits")" = 200 ] || fail "commits page"
188grep -q "First commit" "$WORK/body" || fail "commits page does not show the commit"
189
190step "make an access token"
191out="$(post /settings/tokens --data-urlencode "intent=add-token" --data-urlencode "label=smoke" --data-urlencode "preset=full" --data-urlencode "expires=7")"
192echo "$out"
193TOKEN="$(grep -ao 'g1t_[0-9A-Za-z_-]*' "$WORK/body" | head -1 || true)"
194[ -n "$TOKEN" ] || fail "no token in the tokens page: $out"
195echo "token ${TOKEN:0:8}…"
196
197step "the API answers at $API_URL"
198[ "$(api GET /user)" = 200 ] || fail "GET /user: $(head -c 300 "$WORK/api")"
199[ "$(json username)" = "$USER_NAME" ] || fail "GET /user names $(json username), not $USER_NAME"
200[ "$(api GET /)" = 200 ] || fail "GET /"
201[ "$(json mcp_url)" = "$MCP_URL" ] || fail "the API's index names $(json mcp_url) as MCP, not $MCP_URL"
202[ "$(json git_url)" = "$G1T_URL/{owner}/{name}.git" ] || fail "git_url is $(json git_url)"
203curl -sS -o "$WORK/api" "$API_URL/.well-known/oauth-authorization-server"
204[ "$(json issuer)" = "$API_URL" ] || fail "the OAuth issuer is $(json issuer), not $API_URL"
205[ "$(json authorization_endpoint)" = "$G1T_URL/oauth/authorize" ] || fail "people approve at $(json authorization_endpoint)"
206echo "issuer $(json issuer)"
207code="$(curl -sS -o "$WORK/api" -D "$WORK/headers" -w '%{http_code}' -X POST -H "content-type: application/json" \
208 --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' "$MCP_URL")"
209[ "$code" = 401 ] || fail "MCP without a token answered $code"
210grep -qi "resource_metadata=\"$API_URL/.well-known/oauth-protected-resource/mcp\"" "$WORK/headers" || fail "MCP's challenge: $(grep -i www-authenticate "$WORK/headers")"
211code="$(curl -sS -o "$WORK/api" -w '%{http_code}' -X POST -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
212 -H "accept: application/json, text/event-stream" --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' "$MCP_URL")"
213[ "$code" = 200 ] && grep -q '"tools"' "$WORK/api" || fail "MCP tools/list: $code $(head -c 300 "$WORK/api")"
214echo "MCP lists its tools at $MCP_URL"
215
216if [ "$PACKAGES" != off ]; then
217 step "publish an npm package and install it"
218 # The tarball is kept in the packages store (S3), and read back from it.
219 npmrc="@$WORKSPACE:registry=$G1T_URL/-/npm/
220//${G1T_URL#*://}/-/npm/:_authToken=$TOKEN"
221 mkdir -p "$WORK/pkg" "$WORK/app"
222 printf '%s\n' "$npmrc" > "$WORK/pkg/.npmrc"
223 printf '%s\n' "$npmrc" > "$WORK/app/.npmrc"
224 printf '{"name":"@%s/%s","version":"1.0.0","repository":"%s/%s/%s","main":"index.js"}\n' \
225 "$WORKSPACE" "$REPO" "$G1T_URL" "$WORKSPACE" "$REPO" > "$WORK/pkg/package.json"
226 printf 'module.exports = "published by smoke.sh %s";\n' "$RUN" > "$WORK/pkg/index.js"
227 (cd "$WORK/pkg" && npm publish --silent) || fail "npm publish"
228 (cd "$WORK/app" && npm init -y >/dev/null && npm install --silent --no-audit --no-fund "@$WORKSPACE/$REPO@1.0.0") || fail "npm install"
229 got="$(cd "$WORK/app" && node -p "require('@$WORKSPACE/$REPO')")"
230 [ "$got" = "published by smoke.sh $RUN" ] || fail "the installed package says: $got"
231 echo "installed @$WORKSPACE/$REPO@1.0.0"
232fi
233
234step "a pull request from a branch, merged"
235git -C "$WORK/clone" config credential.helper ""
236commit_file "$WORK/clone" "$remote" from-branch docs/branch.md
237[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls" '{"title":"From a branch","branch":"from-branch","body":"Opened by smoke.sh"}')" = 200 ] \
238 || fail "open from a branch: $(head -c 300 "$WORK/api")"
239BRANCH_PULL="$(json pull.number)"
240echo "pull request #$BRANCH_PULL ($(json pull.status))"
241[ "$(get "/$WORKSPACE/$REPO/pull/$BRANCH_PULL")" = 200 ] && grep -q "From a branch" "$WORK/body" || fail "pull request page"
242[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$BRANCH_PULL/merge" '{}')" = 200 ] || fail "merge: $(head -c 300 "$WORK/api")"
243until_status "$BRANCH_PULL" merged
244main_has docs/branch.md || fail "main does not have the branch's change"
245echo "merged onto main"
246
247step "a pull request from a fork, merged"
248[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls" '{"title":"From a fork","agent":"smoke"}')" = 200 ] \
249 || fail "open with a fork: $(head -c 300 "$WORK/api")"
250FORK_PULL="$(json pull.number)"
251fork_remote="$(json git.remote)"
252echo "pull request #$FORK_PULL ($(json pull.status)), fork $fork_remote"
253case "$fork_remote" in "$G1T_URL"/*) ;; *) fail "the fork's remote is not on $G1T_URL: $fork_remote" ;; esac
254[ "$fork_remote" != "$G1T_URL/$WORKSPACE/$REPO.git" ] || fail "no fork was made"
255authed_fork="${fork_remote/:\/\//://$USER_NAME:$TOKEN@}"
256git -c credential.helper= clone -q "$authed_fork" "$WORK/fork"
257commit_file "$WORK/fork" "$authed_fork" "$(git -C "$WORK/fork" branch --show-current)" docs/fork.md
258[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$FORK_PULL/ready" '{"summary":"Adds docs/fork.md, from a fork."}')" = 200 ] \
259 || fail "ready: $(head -c 300 "$WORK/api")"
260[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$FORK_PULL/merge" '{}')" = 200 ] || fail "merge: $(head -c 300 "$WORK/api")"
261until_status "$FORK_PULL" merged
262main_has docs/fork.md || fail "main does not have the fork's change"
263echo "merged onto main"
264
265step "the merge queue takes a pull request, and gives it back"
266[ "$(api PATCH "/repos/$WORKSPACE/$REPO/settings" '{"merge_queue":true}')" = 200 ] || fail "turn the queue on: $(head -c 300 "$WORK/api")"
267git -C "$WORK/clone" fetch -q "$G1T_URL/$WORKSPACE/$REPO.git" main
268git -C "$WORK/clone" switch -q -c queued FETCH_HEAD
269commit_file "$WORK/clone" "$remote" queued docs/queued.md
270[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls" '{"title":"Through the queue","branch":"queued"}')" = 200 ] || fail "open: $(head -c 300 "$WORK/api")"
271QUEUE_PULL="$(json pull.number)"
272[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$QUEUE_PULL/merge" '{}')" = 200 ] || fail "merge into the queue: $(head -c 300 "$WORK/api")"
273[ "$(api GET "/repos/$WORKSPACE/$REPO/queue")" = 200 ] || fail "queue: $(head -c 300 "$WORK/api")"
274[ "$(json enabled)" = true ] || fail "the queue is not on"
275node -e 'const q = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); const e = q.active.find((e) => e.number === Number(process.argv[2])); if (!e) process.exit(1); console.log(`#${e.number} is ${e.state} in the queue`)' "$WORK/api" "$QUEUE_PULL" \
276 || fail "#$QUEUE_PULL is not in the queue: $(head -c 400 "$WORK/api")"
277[ "$(get "/$WORKSPACE/$REPO/queue")" = 200 ] && grep -q "Through the queue" "$WORK/body" || fail "queue page"
278# Testing a queued state needs a sandbox, and agents are off: take it out.
279out="$(post "/$WORKSPACE/$REPO/pull/$QUEUE_PULL" --data-urlencode "action=unqueue")"
280echo "unqueue: $out"
281[ "$(api GET "/repos/$WORKSPACE/$REPO/queue")" = 200 ] || fail "queue"
282node -e 'const q = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.exit(q.active.some((e) => e.number === Number(process.argv[2])) ? 1 : 0)' "$WORK/api" "$QUEUE_PULL" \
283 || fail "#$QUEUE_PULL is still in the queue"
284[ "$(api PATCH "/repos/$WORKSPACE/$REPO/settings" '{"merge_queue":false}')" = 200 ] || fail "turn the queue off"
285[ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$QUEUE_PULL/merge" '{}')" = 200 ] || fail "merge: $(head -c 300 "$WORK/api")"
286until_status "$QUEUE_PULL" merged
287main_has docs/queued.md || fail "main does not have the change"
288echo "out of the queue, then merged onto main"
289
290if [ -n "$SCHEDULER_ONCE" ]; then
291 step "every cron handler runs"
292 $SCHEDULER_ONCE || fail "a cron handler failed"
293fi
294
295printf '\nAll checks passed: %s/%s/%s\n' "$G1T_URL" "$WORKSPACE" "$REPO"