diff --git a/.github/workflows/comprehensive-tests.yml b/.github/workflows/comprehensive-tests.yml index a88c3420..1f9c633d 100644 --- a/.github/workflows/comprehensive-tests.yml +++ b/.github/workflows/comprehensive-tests.yml @@ -38,12 +38,21 @@ jobs: - name: Run all unit suites (core, web, server, mcp-server) run: npm run test:coverage && npm run test:unified:lib - # Genuinely-real checks: the installer exists, deployment compose parses + # Genuinely-real checks: the installer installs a ref that exists and can + # fetch its code, and the deployment compose files parse installer-and-deploy-config: name: Installer & Deploy Config runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.18.0' + - name: Installer ref and image-tag contract + run: node --test tests/toolchain/installer-contract.mjs + - name: Installer end to end in a container (clone, update, setup-script URLs) + run: sh tests/integration/installer-container.sh - name: Installation script present and executable run: | test -f scripts/install.sh || { echo "::error::scripts/install.sh missing"; exit 1; } @@ -51,9 +60,11 @@ jobs: echo "✅ installer present" - name: Production docker compose config is valid run: | - docker compose -f deployment/docker-compose.yml config > /dev/null \ - && echo "✅ deployment/docker-compose.yml is valid" \ - || { echo "::error::deployment/docker-compose.yml failed to parse"; exit 1; } + for file in deployment/docker-compose.yml deployment/docker-compose.registry.yml; do + docker compose -f "$file" config > /dev/null \ + && echo "✅ $file is valid" \ + || { echo "::error::$file failed to parse"; exit 1; } + done # Honest PR comment derived from real job outcomes (no fabricated numbers) report: diff --git a/docs/deployment.md b/docs/deployment.md index 4f7a5766..730674eb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -269,6 +269,15 @@ GRAPHDONE_HOME=/opt/graphdone curl -fsSL https://graphdone.com/start.sh | sh GRAPHDONE_NO_START=1 curl -fsSL https://graphdone.com/start.sh | sh ``` +`scripts/install.sh` installs `main`, the branch it is served from. To install +a release tag or another branch, set `GRAPHDONE_REF` for the shell that runs it +(the clone, updates of `~/graphdone` and the setup-script downloads all follow +it). `sh install.sh sources` prints the repository, ref and URLs it will use: + +```bash +curl -fsSL https://raw.githubusercontent.com/GraphDone/GraphDone-Core/main/scripts/install.sh | GRAPHDONE_REF=dev sh +``` + ## Updating GraphDone Users can update their installation by running the same command: diff --git a/package.json b/package.json index 28b291f0..75dd863e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dashboard:open": "node tests/lib/dashboard/server.mjs --open", "dashboard:narrate": "node scripts/narrate-report.mjs", "test:installation": "./scripts/test-installation-simple.sh", + "test:installer": "node --test tests/toolchain/installer-contract.mjs && sh tests/integration/installer-container.sh", "test:https": "node tests/integration/ssl-certificate-analysis.js && node tests/integration/mobile-https-compatibility-test.js", "test:report": "open test-artifacts/unified/report.html", "lint": "turbo run lint", diff --git a/scripts/install.sh b/scripts/install.sh index 7d10a313..f5601d14 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -153,7 +153,7 @@ # Component 7: Success UI & Command Handler (Lines 3601-3700) # ├─ Beautiful success message with URLs # ├─ Management commands display -# └─ CLI argument processing (install/stop/remove) +# └─ CLI argument processing (install/stop/remove/sources) # # ⌨️ COMMAND REFERENCE # ============================================================================ @@ -167,6 +167,12 @@ # Complete cleanup (removes containers, volumes, images): # sh install.sh remove # +# Show the repository, ref and setup-script URLs the installer uses: +# sh install.sh sources +# +# Install a branch or release tag other than main: +# GRAPHDONE_REF=dev sh install.sh +# # 📊 EXIT CODES # ============================================================================ # 0 - Success (GraphDone installed and running) @@ -280,9 +286,44 @@ cleanup() { trap 'cleanup; exit 130' INT TERM trap 'cleanup' EXIT -# GitHub repository details +# GitHub repository details. +# GRAPHDONE_REF is the single source of truth for what gets installed: the +# fresh clone, the update of an installer-managed checkout and the fallback +# downloads of scripts/setup_*.sh all use it. It defaults to main because the +# one-line installer is served from main; set GRAPHDONE_REF to another branch +# or release tag to install that instead (tests point it at a branch). GITHUB_REPO="GraphDone/GraphDone-Core" -GITHUB_BRANCH="fix/first-start" +GITHUB_REPO_URL="https://github.com/${GITHUB_REPO}.git" +GRAPHDONE_REF="${GRAPHDONE_REF:-main}" +# Prerequisite installers fetched from GitHub when not run from a checkout. +SETUP_SCRIPTS="setup_git.sh setup_nodejs.sh setup_docker.sh" + +# Raw URL of a setup script at the configured ref +setup_script_url() { + printf 'https://raw.githubusercontent.com/%s/%s/scripts/%s\n' "$GITHUB_REPO" "$GRAPHDONE_REF" "$1" +} + +# Print where this installer gets its code from (the `sources` command) +print_sources() { + printf 'repository %s\n' "$GITHUB_REPO_URL" + printf 'ref %s\n' "$GRAPHDONE_REF" + for script_name in $SETUP_SCRIPTS; do + printf 'setup-script %s\n' "$(setup_script_url "$script_name")" + done +} + +# Bring an installer-managed checkout to the configured ref. Old installs may +# sit on a branch that no longer exists upstream, so fetch the ref explicitly +# instead of pulling the current branch's upstream. +update_checkout_to_ref() { + git fetch --quiet --tags origin "$GRAPHDONE_REF" || return 1 + if git show-ref --verify --quiet "refs/tags/$GRAPHDONE_REF"; then + git checkout --quiet --detach FETCH_HEAD || return 1 + else + git checkout --quiet "$GRAPHDONE_REF" || return 1 + git merge --ff-only --quiet FETCH_HEAD || return 1 + fi +} # Helper function to run setup scripts (local or download from GitHub) run_setup_script() { @@ -296,7 +337,7 @@ run_setup_script() { sh "scripts/$script_name" $script_args else # Download from GitHub and run - local github_url="https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/scripts/${script_name}" + local github_url="$(setup_script_url "$script_name")" local temp_script="/tmp/${script_name}.$$" if command -v curl >/dev/null 2>&1; then @@ -3272,8 +3313,17 @@ install_graphdone() { cd "$INSTALL_DIR" - # Run git pull in background to show progress - git pull --quiet >/dev/null 2>&1 & + # Update in background to show progress. An installer-managed checkout + # follows GRAPHDONE_REF; a developer checkout the installer was run + # from keeps its current branch and pulls that branch's upstream. + update_log="$LOG_DIR/git-update-${INSTALL_TIMESTAMP}.log" + if [ "$FRESH_INSTALL" = true ]; then + update_target="$GRAPHDONE_REF" + update_checkout_to_ref >"$update_log" 2>&1 & + else + update_target="current branch" + git pull --quiet >"$update_log" 2>&1 & + fi pull_pid=$! # Add pink color for the circle @@ -3338,7 +3388,18 @@ install_graphdone() { # Smooth transition: show completion state briefly printf " ${GREEN}●${NC}" sleep 0.3 - wait $pull_pid + pull_result=0 + wait $pull_pid || pull_result=$? + + if [ $pull_result -ne 0 ]; then + printf "\r\033[K" + printf " ${RED}✗${NC} ${BOLD}Failed to update GraphDone to ${update_target}${NC}\n" + if [ -f "$update_log" ]; then + printf "\n${BOLD}Last 20 lines from update log:${NC}\n" + tail -20 "$update_log" + fi + error "Git update failed in $INSTALL_DIR - commit or stash local changes, or remove the directory to reinstall" + fi # Success line with exact 88-character content area success_content="${GREEN}✓${NC} ${BOLD}Updated${NC} ${GREEN}to latest version${NC}" @@ -3368,7 +3429,7 @@ install_graphdone() { # Clone with progress - redirect to log file to capture any errors local clone_log="$LOG_DIR/git-clone-${INSTALL_TIMESTAMP}.log" - git clone --progress --branch fix/first-start https://github.com/GraphDone/GraphDone-Core.git "$INSTALL_DIR" >"$clone_log" 2>&1 & + git clone --progress --branch "$GRAPHDONE_REF" "$GITHUB_REPO_URL" "$INSTALL_DIR" >"$clone_log" 2>&1 & clone_pid=$! # Single loop with timeout (no nested loops to avoid race conditions) @@ -3400,8 +3461,8 @@ install_graphdone() { elapsed=$((elapsed + 1)) done - wait $clone_pid - clone_result=$? + clone_result=0 + wait $clone_pid || clone_result=$? # Clear the line completely to prevent spinner artifacts printf "\r\033[K" @@ -3429,6 +3490,13 @@ install_graphdone() { cd "$INSTALL_DIR" + # Test hook: stop once the code is in place. The installer's container + # test uses it; nothing is installed or started past this point. + if [ "${GRAPHDONE_INSTALL_STOP_AFTER:-}" = "clone" ]; then + printf " ${GREEN}✓${NC} GraphDone ${BOLD}%s${NC} is at %s; stopping (GRAPHDONE_INSTALL_STOP_AFTER=clone)\n" "$GRAPHDONE_REF" "$INSTALL_DIR" + exit 0 + fi + # Project dependencies check and install (after code is downloaded) # First show checking animation PINK='\033[38;5;213m' @@ -3999,7 +4067,17 @@ show_success() { # Handle command line arguments COMMAND="${1:-install}" +# GRAPHDONE_REF is passed to git; accept only plain branch or tag names. +case "$GRAPHDONE_REF" in + ""|-*|*[!A-Za-z0-9._/-]*|*..*) + error "Invalid GRAPHDONE_REF '$GRAPHDONE_REF': use a branch or tag name such as main" + ;; +esac + case "$COMMAND" in + sources) + print_sources + ;; stop) stop_services ;; @@ -4010,6 +4088,6 @@ case "$COMMAND" in install_graphdone ;; *) - error "Unknown command: $COMMAND. Use: install, stop, or remove" + error "Unknown command: $COMMAND. Use: install, stop, remove, or sources" ;; esac diff --git a/tests/README.md b/tests/README.md index 634cf119..b9b960a4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,6 +8,7 @@ evidence format, and their limits. | --- | --- | | `npm test` | Package unit suites through Turbo; does not run browser acceptance. | | `npm run test:unified:lib` | Report adapters, process outcomes, CLI safety, source provenance, and dashboard helpers. No browser or database required. | +| `npm run test:installer` | One-line installer: every ref it uses is `main` or the `GRAPHDONE_REF` override, image tags are `latest` or versions, and in a disposable container it clones, updates and reaches its setup-script URLs. Needs Docker. | | `TEST_URL=http://localhost:3127 npm run test:smoke` | Focused user smoke against an already-running disposable stack. | | `TEST_URL=http://localhost:3127 npm run test:unified:smoke` | Web unit tests, user smoke, and focused authentication acceptance, with one combined report. | | `TEST_URL=http://localhost:3127 npm run test:pr` | Broader graph, mobile, and performance sequences. | diff --git a/tests/integration/installer-container.sh b/tests/integration/installer-container.sh new file mode 100755 index 00000000..2484603f --- /dev/null +++ b/tests/integration/installer-container.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# End-to-end check of the one-line installer's code download (#243). +# +# Runs scripts/install.sh non-interactively in a disposable container and +# stops it right after the code step (GRAPHDONE_INSTALL_STOP_AFTER=clone), so +# nothing is installed or started. It proves that: +# 1. a fresh install clones GRAPHDONE_REF (default main) and lands on the +# commit the remote has for it; +# 2. re-running updates the installer-managed checkout in place; +# 3. an old install stranded on a branch deleted upstream (fix/first-start) +# is moved to GRAPHDONE_REF instead of failing on `git pull`; +# 4. every setup-script fallback URL the installer reports answers HTTP 200. +# +# Docker on the host is only used to run the container. Inside it, `docker` +# is a stub that reports a running daemon so the dependency check passes. +# +# Usage: sh tests/integration/installer-container.sh +# GRAPHDONE_REF= ref to install (default main) +# INSTALLER_UNDER_TEST= installer to run (default scripts/install.sh) +set -eu + +IMAGE="node:22.18.0-trixie-slim" +MOUNT=/opt/graphdone-installer-test + +if [ "${1:-}" != "--inside" ]; then + root="$(cd "$(dirname "$0")/../.." && pwd)" + installer="${INSTALLER_UNDER_TEST:-$root/scripts/install.sh}" + installer="$(cd "$(dirname "$installer")" && pwd)/$(basename "$installer")" + name="graphdone-installer-test-$$" + trap 'docker rm -f "$name" >/dev/null 2>&1 || true' EXIT INT TERM + docker run --rm --name "$name" \ + -e GRAPHDONE_REF="${GRAPHDONE_REF:-main}" \ + -e TERM=xterm \ + -v "$installer:$MOUNT/install.sh:ro" \ + -v "$root/tests/integration/installer-container.sh:$MOUNT/test.sh:ro" \ + "$IMAGE" sh "$MOUNT/test.sh" --inside + exit $? +fi + +# ── Inside the container ──────────────────────────────────────────────────── +REF="$GRAPHDONE_REF" +INSTALL_DIR="$HOME/graphdone" +REPO_URL="https://github.com/GraphDone/GraphDone-Core.git" +failures=0 + +pass() { printf 'PASS %s\n' "$1"; } +fail() { printf 'FAIL %s\n' "$1"; failures=$((failures + 1)); } + +apt-get -qq -o Acquire::Retries=3 update >/dev/null +DEBIAN_FRONTEND=noninteractive apt-get -qq -o Acquire::Retries=3 install -y --no-install-recommends \ + git curl ca-certificates sudo openssl ncurses-bin >/dev/null + +cat > /usr/local/bin/docker <<'EOF' +#!/bin/sh +case "$1" in + --version) echo "Docker version 27.0.0, build test-stub" ;; +esac +exit 0 +EOF +chmod +x /usr/local/bin/docker + +# The commit the remote has for REF: a branch head or a (peeled) tag. +remote_sha=$(git ls-remote --heads --tags "$REPO_URL" | awk -v r="$REF" ' + { sha[$2] = $1 } + END { + if (("refs/heads/" r) in sha) print sha["refs/heads/" r] + else if (("refs/tags/" r "^{}") in sha) print sha["refs/tags/" r "^{}"] + else if (("refs/tags/" r) in sha) print sha["refs/tags/" r] + }') +if [ -n "$remote_sha" ]; then + pass "ref '$REF' exists on $REPO_URL at $remote_sha" +else + fail "ref '$REF' does not exist on $REPO_URL" +fi + +run_installer() { + label="$1" + log="/tmp/installer-$label.log" + if (cd /tmp && GRAPHDONE_INSTALL_STOP_AFTER=clone sh "$MOUNT/install.sh" "$log" 2>&1); then + pass "installer exited 0 ($label)" + else + fail "installer exited non-zero ($label); last lines:" + tail -25 "$log" | sed 's/\x1b\[[0-9;?]*[A-Za-z]//g' + return 1 + fi +} + +check_checkout() { + label="$1" + head_sha=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null || echo none) + if [ "$head_sha" = "$remote_sha" ]; then + pass "$INSTALL_DIR is at $REF $remote_sha ($label)" + else + fail "$INSTALL_DIR HEAD $head_sha is not $REF $remote_sha ($label)" + fi + if git -C "$INSTALL_DIR" show-ref --verify --quiet "refs/heads/$REF"; then + branch=$(git -C "$INSTALL_DIR" rev-parse --abbrev-ref HEAD) + if [ "$branch" = "$REF" ]; then pass "checked out branch $REF ($label)"; else fail "checked out '$branch', expected $REF ($label)"; fi + fi + # The same marker the installer uses to recognise a GraphDone checkout. + if grep -q '"name": "graphdone"' "$INSTALL_DIR/package.json" 2>/dev/null; then + pass "clone is a GraphDone checkout ($label)" + else + fail "clone has no GraphDone package.json ($label)" + fi +} + +if run_installer fresh; then + check_checkout fresh + + if run_installer update; then + if grep -q "Update existing" /tmp/installer-update.log; then pass "second run took the update path"; else fail "second run did not take the update path"; fi + check_checkout update + fi + + # An install made by the old installer: a local fix/first-start branch whose + # upstream was deleted, one commit behind. + git -C "$INSTALL_DIR" checkout -q -b fix/first-start HEAD~1 + git -C "$INSTALL_DIR" config branch.fix/first-start.remote origin + git -C "$INSTALL_DIR" config branch.fix/first-start.merge refs/heads/fix/first-start + if run_installer stranded; then + check_checkout "stranded on fix/first-start" + fi +fi + +# Setup-script fallbacks, exactly as the installer would fetch them. +sources=$(sh "$MOUNT/install.sh" sources) || { fail "installer 'sources' command failed"; sources=""; } +if echo "$sources" | grep -qx "ref $REF"; then pass "installer reports ref $REF"; else fail "installer does not report ref $REF"; fi +urls=$(echo "$sources" | awk '$1 == "setup-script" {print $2}') +[ -n "$urls" ] || fail "installer reported no setup-script URLs" +for url in $urls; do + code=$(curl -sS -o /dev/null -w '%{http_code}' --retry 3 --max-time 30 "$url" || echo 000) + if [ "$code" = 200 ]; then pass "HTTP 200 $url"; else fail "HTTP $code $url"; fi +done + +if [ "$failures" -ne 0 ]; then + printf '\n%s installer check(s) failed\n' "$failures" + exit 1 +fi +printf '\nAll installer checks passed for ref %s\n' "$REF" diff --git a/tests/toolchain/installer-contract.mjs b/tests/toolchain/installer-contract.mjs new file mode 100644 index 00000000..ac9adc54 --- /dev/null +++ b/tests/toolchain/installer-contract.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const INSTALLER = 'scripts/install.sh'; + +// The one-line installer is served from main, so it installs main unless the +// GRAPHDONE_REF override says otherwise. Every ref it hands to git or puts in +// a download URL must be that one variable: a hard-coded branch is how +// fix/first-start broke every fresh install once the branch was deleted (#243). +const REF_VAR = /^"?\$\{?GRAPHDONE_REF\}?"?$/; +const GIT_REF_TOKENS = new Set(['FETCH_HEAD', 'origin']); +// Published images may only be pulled by a moving release channel or a version. +const IMAGE_TAG = /^(latest|v?\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?|\$\{[A-Z0-9_]+(:-[^}]*)?\})$/; + +function code(line) { + return line.replace(/^\s*#.*$/, ''); +} + +export function installerViolations(source) { + const violations = []; + const lines = source.split('\n'); + + const defaults = [...source.matchAll(/^GRAPHDONE_REF="\$\{GRAPHDONE_REF:-([^}]*)\}"$/gm)].map((m) => m[1]); + if (defaults.length !== 1 || defaults[0] !== 'main') { + violations.push(`GRAPHDONE_REF must be defined once with default main (found: ${defaults.join(', ') || 'none'})`); + } + + lines.forEach((raw, index) => { + const line = code(raw); + const at = `${INSTALLER}:${index + 1}`; + + if (/\bgit\s+clone\b/.test(line)) { + const branch = line.match(/(?:--branch|-b)\s+(\S+)/); + if (!branch || !REF_VAR.test(branch[1])) violations.push(`${at}: git clone must use --branch "$GRAPHDONE_REF": ${raw.trim()}`); + if (!/"\$\{?GITHUB_REPO_URL\}?"/.test(line)) violations.push(`${at}: git clone must use "$GITHUB_REPO_URL": ${raw.trim()}`); + } + + const gitRef = line.match(/\bgit\s+(checkout|switch|fetch|pull)\b(.*)/); + if (gitRef) { + const args = gitRef[2].replace(/[|&;>].*$/, '').trim().split(/\s+/).filter(Boolean); + for (const arg of args.filter((a) => !a.startsWith('-'))) { + if (!REF_VAR.test(arg) && !GIT_REF_TOKENS.has(arg)) violations.push(`${at}: git ${gitRef[1]} uses a literal ref '${arg}'; use "$GRAPHDONE_REF"`); + } + } + + for (const m of line.matchAll(/raw\.githubusercontent\.com\/(?:GraphDone\/GraphDone-Core|\$\{?GITHUB_REPO\}?)\/([^/\s"']+)\//g)) { + if (!REF_VAR.test(m[1])) violations.push(`${at}: raw GitHub URL pins '${m[1]}'; build it with setup_script_url`); + } + + for (const m of line.matchAll(/\b(?:fix|feat|feature|chore|hotfix|release|bugfix)\/[A-Za-z0-9._-]+/g)) { + violations.push(`${at}: hard-coded branch name '${m[0]}'`); + } + }); + + const urlHelper = source.match(/^setup_script_url\(\) \{\n([\s\S]*?)\n\}/m); + if (!urlHelper || !/"\$GITHUB_REPO" "\$GRAPHDONE_REF"/.test(urlHelper[1])) { + violations.push('setup_script_url must build its URL from "$GITHUB_REPO" "$GRAPHDONE_REF"'); + } + return violations; +} + +export function imageTagViolations(path, text) { + const hits = []; + text.split('\n').forEach((raw, index) => { + for (const m of code(raw).matchAll(/ghcr\.io\/graphdone\/[a-z0-9-]+:([^\s"'@]+)/g)) { + if (!IMAGE_TAG.test(m[1])) hits.push(`${path}:${index + 1}: image tag '${m[1]}' is not latest or a version`); + } + }); + return hits; +} + +function read(path) { + return readFileSync(resolve(root, path), 'utf8'); +} + +function tracked(...patterns) { + const result = spawnSync('git', ['ls-files', '--', ...patterns], { cwd: root, encoding: 'utf8' }); + assert.equal(result.status, 0, `git ls-files failed: ${result.stderr}`); + return result.stdout.split('\n').filter(Boolean); +} + +test('rules reject the deleted fix/first-start pin and accept the GRAPHDONE_REF form', () => { + const pinned = [ + 'GITHUB_BRANCH="fix/first-start"', + 'local github_url="https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/scripts/${script_name}"', + 'git clone --progress --branch fix/first-start https://github.com/GraphDone/GraphDone-Core.git "$INSTALL_DIR"', + 'git pull origin develop', + ].join('\n'); + const found = installerViolations(pinned).join('\n'); + for (const expected of ['default main', "pins '${GITHUB_BRANCH}'", 'git clone must use --branch', "'fix/first-start'", "literal ref 'develop'", 'setup_script_url']) { + assert.ok(found.includes(expected), `expected a violation mentioning ${expected}:\n${found}`); + } + assert.equal(imageTagViolations('x', 'image: ghcr.io/graphdone/graphdone-web:fix-first-start').length, 1); + + const good = [ + 'GRAPHDONE_REF="${GRAPHDONE_REF:-main}"', + 'setup_script_url() {', + ' printf \'https://raw.githubusercontent.com/%s/%s/scripts/%s\\n\' "$GITHUB_REPO" "$GRAPHDONE_REF" "$1"', + '}', + 'git clone --progress --branch "$GRAPHDONE_REF" "$GITHUB_REPO_URL" "$INSTALL_DIR" >"$log" 2>&1 &', + 'git fetch --quiet --tags origin "$GRAPHDONE_REF" || return 1', + 'git checkout --quiet --detach FETCH_HEAD || return 1', + 'git pull --quiet >"$update_log" 2>&1 &', + ].join('\n'); + assert.deepEqual(installerViolations(good), []); + assert.deepEqual(imageTagViolations('x', 'ghcr.io/graphdone/graphdone-neo4j:latest ghcr.io/graphdone/graphdone-api:v0.4.0-alpha ghcr.io/graphdone/graphdone-web:${TAG:-latest}'), []); +}); + +test('installer installs main by default and every ref it uses is GRAPHDONE_REF', () => { + assert.deepEqual(installerViolations(read(INSTALLER)), []); +}); + +test('installer `sources` reports main and the setup-script URLs it falls back to', () => { + const env = { ...process.env }; + delete env.GRAPHDONE_REF; + const run = spawnSync('sh', [INSTALLER, 'sources'], { cwd: root, encoding: 'utf8', env }); + assert.equal(run.status, 0, run.stderr); + const rows = run.stdout.trim().split('\n').map((row) => row.split(' ')); + assert.deepEqual(rows.slice(0, 2), [['repository', 'https://github.com/GraphDone/GraphDone-Core.git'], ['ref', 'main']]); + const urls = rows.filter(([kind]) => kind === 'setup-script').map(([, url]) => url); + assert.ok(urls.length > 0, 'no setup-script URLs reported'); + + // Every setup script the installer can fetch is listed, exists in this + // checkout (so it exists on main once this lands) and is fetched from main. + const called = new Set([...read(INSTALLER).matchAll(/run_setup_script "([^"]+)"/g)].map((m) => m[1])); + const listed = new Set(urls.map((url) => url.split('/').pop())); + for (const name of called) assert.ok(listed.has(name), `run_setup_script "${name}" is not in SETUP_SCRIPTS`); + for (const url of urls) { + assert.match(url, /^https:\/\/raw\.githubusercontent\.com\/GraphDone\/GraphDone-Core\/main\/scripts\/setup_[a-z]+\.sh$/); + assert.ok(existsSync(resolve(root, 'scripts', url.split('/').pop())), `${url} has no file in scripts/`); + } + + const override = spawnSync('sh', [INSTALLER, 'sources'], { cwd: root, encoding: 'utf8', env: { ...env, GRAPHDONE_REF: 'v0.4.0-alpha' } }); + assert.equal(override.status, 0, override.stderr); + assert.match(override.stdout, /^ref v0\.4\.0-alpha$/m); + assert.match(override.stdout, /\/GraphDone-Core\/v0\.4\.0-alpha\/scripts\/setup_git\.sh$/m); + + const unsafe = spawnSync('sh', [INSTALLER, 'sources'], { cwd: root, encoding: 'utf8', env: { ...env, GRAPHDONE_REF: '--upload-pack=x' } }); + assert.notEqual(unsafe.status, 0, 'an option-like GRAPHDONE_REF must be rejected'); +}); + +test('installer and compose files pull published images only by latest or a version tag', () => { + const files = [INSTALLER, ...tracked('deployment/*.yml', 'deployment/**/*.yml')]; + const hits = files.flatMap((path) => imageTagViolations(path, read(path))); + assert.deepEqual(hits, [], hits.join('\n')); +}); + +test('documented one-line install URLs serve the installer from main', () => { + const listed = spawnSync('git', ['grep', '-nE', 'raw\\.githubusercontent\\.com/GraphDone/GraphDone-Core/[^ ]*install\\.sh', '--', '*.md'], { cwd: root, encoding: 'utf8' }); + assert.ok(listed.status === 0 || listed.status === 1, listed.stderr); + const wrong = listed.stdout.split('\n').filter(Boolean).filter((row) => !row.includes('/GraphDone-Core/main/scripts/install.sh')); + assert.deepEqual(wrong, [], `Install URLs must point at /main/scripts/install.sh:\n${wrong.join('\n')}`); +});