#!/usr/bin/env bash # Build, sign and publish a Pharos release. Maintainer side only: needs the Ed25519 # signing secret (PHAROS_LICENSE_SECRET_FILE) and SSH access to the release server. # # scripts/build-release.sh 0.5.0 [--notes "One line for the operator"] [--no-upload] [--no-tag] # # Produces in dist/: # pharos-.zip the app with vendor/, no dev packages, no .env, no data # pharos-.zip.sha256 checksum # latest.json signed manifest (payload.signature), what the app's Updates tab reads # and uploads them to https://pharos.solutionmax.net/releases/ (Caddy handle_path → /var/www/pharos-releases). set -euo pipefail VERSION="${1:?usage: build-release.sh [--notes ...] [--no-upload] [--no-tag] [--no-gates]}"; shift NOTES="Pharos ${VERSION}"; UPLOAD=1; TAG=1; GATES=1 while [ $# -gt 0 ]; do case "$1" in --notes) NOTES="$2"; shift 2 ;; --no-upload) UPLOAD=0; shift ;; --no-tag) TAG=0; shift ;; --no-gates) GATES=0; shift ;; # rebuilding an old tag whose tree predates a gate *) echo "unknown option $1" >&2; exit 2 ;; esac done REPO="$(cd "$(dirname "$0")/.." && pwd)" SECRET="${PHAROS_LICENSE_SECRET_FILE:-/root/secrets/pharos-license-secret.hex}" REMOTE="contabo:/var/www/pharos-releases" BASE_URL="https://pharos.solutionmax.net/releases" INSTALLER="${PHAROS_INSTALLER:-$REPO/../pharos-site/pharos-install.php}" # the web installer lives in the site repo STAGE="$(mktemp -d)/pharos-${VERSION}" DIST="${REPO}/dist" [ -r "$SECRET" ] || { echo "no readable secret key at $SECRET" >&2; exit 1; } [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$ ]] || { echo "version must be semver, got $VERSION" >&2; exit 1; } cd "$REPO" [ -z "$(git status --porcelain)" ] || { echo "working tree not clean — commit first" >&2; exit 1; } if [ "$GATES" = 1 ]; then npm ci --ignore-scripts --no-audit --no-fund npm run build:editor npm run build:not-found echo "== 1/6 gates" vendor/bin/pint --test -q vendor/bin/phpstan analyse --memory-limit=1G --no-progress -q php artisan test --compact 2>&1 | tail -1 | grep -q '"result":"passed"' || { echo "tests failed" >&2; exit 1; } else echo "== 1/6 gates skipped (--no-gates)" fi echo "== 2/6 stage ${STAGE}" mkdir -p "$STAGE" # Only what git tracks goes in. The working tree also holds folders git merely ignores # (a local build, notes, a video render); copying the tree itself once shipped one of those. # The excludes are git pathspecs, not rsync filters: with a file list rsync does not prune folders. git ls-files -z -- . \ ':(exclude).github' ':(exclude)tests' ':(exclude)phpstan.neon' ':(exclude)phpunit.xml' \ ':(exclude)storage/app' ':(exclude)storage/logs' ':(exclude)storage/framework/cache' \ ':(exclude)storage/framework/sessions' ':(exclude)storage/framework/views' \ ':(exclude)scripts/build-release.sh' ':(exclude)scripts/release-page.py' \ | rsync -a --from0 --files-from=- ./ "$STAGE/" # the version travels with the code, never with .env sed -i "s/'version' => env('PHAROS_VERSION', '[^']*')/'version' => env('PHAROS_VERSION', '${VERSION}')/" "$STAGE/config/pharos.php" grep -q "'${VERSION}'" "$STAGE/config/pharos.php" || { echo "version not stamped" >&2; exit 1; } # a release must be able to migrate on first boot: keep the empty writable dirs for d in storage/app/public storage/app/backups storage/logs storage/framework/cache/data storage/framework/sessions storage/framework/views bootstrap/cache; do mkdir -p "$STAGE/$d"; touch "$STAGE/$d/.gitkeep" done echo "== 3/6 vendor (production only)" ( cd "$STAGE" && COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader --no-interaction --no-progress -q ) [ -d "$STAGE/vendor/laravel/framework" ] || { echo "composer install failed" >&2; exit 1; } rm -rf "$STAGE/vendor/*/*/tests" 2>/dev/null || true # Nothing may be in the archive that git does not know, apart from what this script makes itself. STRAY=$(cd "$STAGE" && find . -type f ! -path './vendor/*' ! -path './bootstrap/cache/*' ! -name .gitkeep -printf '%P\n' | sort | comm -23 - <(git -C "$REPO" ls-files | sort)) [ -z "$STRAY" ] || { echo "not in git but in the archive:" >&2; echo "$STRAY" >&2; exit 1; } echo "== 4/6 archive" mkdir -p "$DIST"; rm -f "$DIST/pharos-${VERSION}.zip" ( cd "$(dirname "$STAGE")" && zip -qr "$DIST/pharos-${VERSION}.zip" "pharos-${VERSION}" -x '*.DS_Store' ) ( cd "$DIST" && sha256sum "pharos-${VERSION}.zip" > "pharos-${VERSION}.zip.sha256" ) SIZE=$(du -h "$DIST/pharos-${VERSION}.zip" | cut -f1) echo "== 5/6 sign" php artisan pharos:release:sign "$VERSION" "${BASE_URL}/pharos-${VERSION}.zip" "$DIST/pharos-${VERSION}.zip" \ --notes="$NOTES" --key="$SECRET" | tail -1 > "$DIST/latest.json" php artisan tinker --execute="\$m = app(\App\Services\Updater::class)->verify(file_get_contents('${DIST}/latest.json')); if (! \$m || \$m['version'] !== '${VERSION}') { fwrite(STDERR, \"manifest does not verify\n\"); exit(1); } echo 'manifest verifies: '.\$m['version'].' '.\$m['sha256'].PHP_EOL;" -q if [ "$UPLOAD" = 1 ]; then echo "== 6/6 upload → ${BASE_URL}/" # the same signed manifest under the version's own name, so an installer can be pinned to it cp "$DIST/latest.json" "$DIST/pharos-${VERSION}.json" rsync -a "$DIST/pharos-${VERSION}.zip" "$DIST/pharos-${VERSION}.zip.sha256" "$DIST/pharos-${VERSION}.json" "$DIST/latest.json" "$REMOTE/" curl -fsS "${BASE_URL}/latest.json" | cmp -s - "$DIST/latest.json" && echo "latest.json live" # the human-readable side: /releases/ rendered from CHANGELOG.md + releases.json (sizes, checksums) curl -fsS "${BASE_URL}/releases.json" -o "$DIST/releases.json" 2>/dev/null || echo '[]' > "$DIST/releases.json" python3 - "$VERSION" "$DIST" <<'PY' import json,os,sys,datetime v,d=sys.argv[1],sys.argv[2]; rows=[r for r in json.load(open(f"{d}/releases.json")) if r["version"]!=v] rows.insert(0,{"version":v,"date":datetime.date.today().isoformat(),"size":os.path.getsize(f"{d}/pharos-{v}.zip"),"sha256":open(f"{d}/pharos-{v}.zip.sha256").read().split()[0]}) json.dump(rows,open(f"{d}/releases.json","w"),indent=1) PY python3 scripts/release-page.py CHANGELOG.md "$DIST/latest.json" "$DIST/releases.json" > "$DIST/index.html" rsync -a "$DIST/index.html" "$DIST/releases.json" "$REMOTE/" && echo "release page live: ${BASE_URL}/" # the web installer, pinned to this release, next to its zip (the site's own copy stays unpinned) if [ -r "$INSTALLER" ]; then sed "s/^const PHAROS_PIN_VERSION = '';/const PHAROS_PIN_VERSION = '${VERSION}';/" "$INSTALLER" > "$DIST/pharos-install-${VERSION}.php" grep -q "PHAROS_PIN_VERSION = '${VERSION}'" "$DIST/pharos-install-${VERSION}.php" || { echo "could not pin the installer" >&2; exit 1; } rsync -a "$DIST/pharos-install-${VERSION}.php" "$REMOTE/" && echo "pinned installer live: ${BASE_URL}/pharos-install-${VERSION}.php" else echo "no installer at $INSTALLER — pinned copy skipped" >&2 fi else echo "== 6/6 upload skipped" fi if [ "$TAG" = 1 ]; then git tag -a "v${VERSION}" -m "Pharos ${VERSION}" 2>/dev/null && echo "tagged v${VERSION}" || echo "tag v${VERSION} already exists" git push -q origin "v${VERSION}" 2>/dev/null || true # GitHub Release carries the same artefacts as a mirror (assets are token-only while the repo is private) if command -v gh >/dev/null 2>&1; then awk -v v="$VERSION" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > "$DIST/notes-${VERSION}.md" ASSETS="$DIST/pharos-${VERSION}.zip $DIST/pharos-${VERSION}.zip.sha256" [ -r "$DIST/pharos-install-${VERSION}.php" ] && ASSETS="$ASSETS $DIST/pharos-install-${VERSION}.php $INSTALLER" # shellcheck disable=SC2086 gh release view "v${VERSION}" >/dev/null 2>&1 || gh release create "v${VERSION}" --title "Pharos ${VERSION}" --notes-file "$DIST/notes-${VERSION}.md" $ASSETS >/dev/null && echo "GitHub release v${VERSION} created" fi fi rm -rf "$(dirname "$STAGE")" echo "done: pharos-${VERSION}.zip (${SIZE}) · sha256 · latest.json"