diff --git a/.github/workflows/publish-smoke.yml b/.github/workflows/publish-smoke.yml new file mode 100644 index 0000000000..2b2465f50e --- /dev/null +++ b/.github/workflows/publish-smoke.yml @@ -0,0 +1,210 @@ +# Publish Smoke — the dynamic half of the #3091 prevention. +# +# 15.1.0 shipped with every fresh project's auth endpoints returning 500: +# the workspace's pnpm overrides (better-auth pinned to 1.7.0-rc.1) made all +# in-repo CI green, but overrides do NOT ship with published packages, so +# downstream installs resolved a dependency mix that was never tested here. +# The static gate is scripts/check-override-consistency.mjs (#3085 — override +# targets must be reflected in published manifests). This workflow is the +# dynamic gate: install the exact bits a user would get and drive the +# first-run flow (auth sign-up/sign-in/get-session + REST CRUD) for real. +# +# Two jobs, one driver script (scripts/publish-smoke.sh): +# +# pack-smoke SMOKE_MODE=pack — `pnpm pack` every publishable package +# (pack applies the same manifest rewrites as publish), +# scaffold a fresh project OUTSIDE the workspace, pin +# @objectstack/* to the tarballs via the project's own pnpm +# overrides, and smoke it. This is "what 15.1.0 would have +# failed": the release-candidate combination, no workspace +# overrides in sight. +# +# registry-canary SMOKE_MODE=registry — weekly `npx create-objectstack@latest` +# against the real npm registry. Catches ^-range drift in the +# ecosystem (a transitive release) breaking ALREADY-published +# versions after the fact. Opens/refreshes an issue on failure. +# +# Trigger notes: the changesets release PR (changeset-release/main) is pushed +# with GITHUB_TOKEN, and GITHUB_TOKEN events never trigger other workflows — +# a plain `on: pull_request` / `on: push` would silently never run (the +# release PR has NO Actions checks today). So pack-smoke runs on +# `workflow_run` after each Release run completes (that's the moment +# changesets creates/updates the release branch), checks out the release +# branch if an open release PR exists, and reports the verdict back as a +# commit status on the branch head so it IS visible on the release PR. +# Not wired into normal PR CI on purpose: full build + pack + clean install +# is far too slow for the inner loop. + +name: Publish Smoke + +on: + workflow_run: + workflows: [Release] + types: [completed] + schedule: + - cron: '47 4 * * 1' # weekly registry canary (Mon 04:47 UTC) + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish-smoke-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + resolve: + name: Resolve target + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.target.outputs.run }} + ref: ${{ steps.target.outputs.ref }} + report-sha: ${{ steps.target.outputs.report-sha }} + steps: + - name: Pick the ref to smoke + id: target + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # Manual run: smoke whatever ref the run was dispatched on. + echo "run=true" >> "$GITHUB_OUTPUT" + echo "ref=${{ github.ref }}" >> "$GITHUB_OUTPUT" + echo "report-sha=" >> "$GITHUB_OUTPUT" + exit 0 + fi + # workflow_run (a Release run finished): smoke the release branch + # iff an open changesets release PR exists; otherwise skip. + pr=$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head changeset-release/main --state open \ + --json headRefOid -q '.[0].headRefOid // empty') + if [ -n "$pr" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + # Checkout the exact SHA the status is reported against, so a + # concurrent force-push of the release branch can't skew the two. + echo "ref=$pr" >> "$GITHUB_OUTPUT" + echo "report-sha=$pr" >> "$GITHUB_OUTPUT" + echo "Release PR open at $pr — smoking changeset-release/main" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "No open release PR — nothing to smoke" + fi + + pack-smoke: + name: Packed-tarball smoke (release candidate) + needs: resolve + if: needs.resolve.outputs.run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + statuses: write + steps: + - name: Mark pending on the release PR head + if: needs.resolve.outputs.report-sha != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/${{ needs.resolve.outputs.report-sha }}" \ + -f state=pending -f context='publish-smoke / packed-tarballs' \ + -f description='Installing the release candidate into a fresh project…' \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ needs.resolve.outputs.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Enable Corepack + run: corepack enable + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Setup turbo cache + uses: actions/cache@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.job }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Publish smoke (packed tarballs) + run: bash scripts/publish-smoke.sh + + - name: Report verdict to the release PR head + if: always() && needs.resolve.outputs.report-sha != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "${{ job.status }}" = "success" ]; then + state=success; desc='Fresh install of the release candidate: auth + CRUD green' + else + state=failure; desc='Release candidate fails a fresh install — see the run log' + fi + gh api "repos/$GITHUB_REPOSITORY/statuses/${{ needs.resolve.outputs.report-sha }}" \ + -f state="$state" -f context='publish-smoke / packed-tarballs' \ + -f description="$desc" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + + registry-canary: + name: Registry canary (published latest) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Publish smoke (npm registry) + run: SMOKE_MODE=registry bash scripts/publish-smoke.sh + + - name: Open or refresh the canary issue + # Scheduled runs have no human watching — surface the breakage as an + # issue (deduped: one open issue, refreshed with a comment per failure). + if: failure() && github.event_name == 'schedule' + env: + GH_TOKEN: ${{ github.token }} + run: | + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + title="Registry canary failed: fresh npx create-objectstack install is broken" + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "\"$title\" in:title" --json number -q '.[0].number // empty') + body=$(printf 'The weekly publish-smoke registry canary failed: a fresh `npx create-objectstack@latest` project no longer passes first-run auth + CRUD against the npm registry.\n\nThis is the #3091 failure class hitting ALREADY-published versions (e.g. a transitive dependency released into a ^ range). See the run log for the failing probe: %s\n' "$run_url") + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$GITHUB_REPOSITORY" \ + --body "Still failing as of $run_url" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body" + fi diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs new file mode 100644 index 0000000000..3bf3f8d61a --- /dev/null +++ b/scripts/publish-smoke-pack.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Pack every publishable workspace package into and write + * /overrides.json mapping package name → `file:` tarball spec. + * + * Usage: node scripts/publish-smoke-pack.mjs + * + * Part of the publish-artifact smoke (scripts/publish-smoke.sh): `pnpm pack` + * applies the SAME manifest rewrites as `pnpm publish` (workspace:* → + * concrete versions, publishConfig overlay), so the tarballs are what a + * downstream `npm install` would actually receive — including whatever the + * published manifests declare WITHOUT this workspace's pnpm overrides. + * + * The whole public surface is packed (not a hand-curated closure): the CLI + * alone depends on ~45 workspace packages, so any curated list would rot. + * Packing everything keeps the overrides map total — a package missing from + * it would make the smoke project resolve that name from the npm registry, + * silently testing a published version instead of the candidate one. + */ + +import { execFile } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileP = promisify(execFile); + +// Not consumed as npm dependencies by a scaffolded project: +// create-objectstack — the scaffolder itself; the smoke runs it straight +// from the repo's built bin, and no @objectstack/* manifest depends on it. +// objectstack-vscode — a VS Code extension (vsce-packaged), not an npm lib. +const EXCLUDE = new Set(['create-objectstack', 'objectstack-vscode']); + +const CONCURRENCY = 8; + +async function listPublicPackages(repoRoot) { + const { stdout } = await execFileP('pnpm', ['-r', 'list', '--depth', '-1', '--json'], { + cwd: repoRoot, + maxBuffer: 64 * 1024 * 1024, + }); + const all = JSON.parse(stdout); + return all.filter((p) => p.name && p.private !== true && !EXCLUDE.has(p.name)); +} + +async function packOne(pkg, destDir) { + const { stdout } = await execFileP( + 'pnpm', + ['pack', '--json', '--pack-destination', destDir], + { cwd: pkg.path, maxBuffer: 64 * 1024 * 1024 }, + ); + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`pnpm pack --json returned non-JSON for ${pkg.name}:\n${stdout}`); + } + if (!parsed.filename) { + throw new Error(`pnpm pack reported no tarball filename for ${pkg.name}`); + } + return { name: pkg.name, filename: parsed.filename }; +} + +async function main() { + const destArg = process.argv[2]; + if (!destArg) { + console.error('Usage: node scripts/publish-smoke-pack.mjs '); + process.exit(1); + } + const repoRoot = resolve(import.meta.dirname, '..'); + const destDir = resolve(destArg); + mkdirSync(destDir, { recursive: true }); + + const packages = await listPublicPackages(repoRoot); + console.log(`Packing ${packages.length} publishable package(s) → ${destDir}`); + + const overrides = {}; + const queue = [...packages]; + let done = 0; + const workers = Array.from({ length: CONCURRENCY }, async () => { + for (;;) { + const pkg = queue.shift(); + if (!pkg) return; + const { name, filename } = await packOne(pkg, destDir); + overrides[name] = `file:${filename}`; + done += 1; + if (done % 10 === 0 || done === packages.length) { + console.log(` packed ${done}/${packages.length}`); + } + } + }); + await Promise.all(workers); + + const sorted = Object.fromEntries( + Object.entries(overrides).sort(([a], [b]) => a.localeCompare(b)), + ); + const outPath = resolve(destDir, 'overrides.json'); + writeFileSync(outPath, JSON.stringify(sorted, null, 2) + '\n'); + console.log(`Wrote ${Object.keys(sorted).length} override(s) → ${outPath}`); +} + +main().catch((err) => { + console.error(err.stack ?? String(err)); + process.exit(1); +}); diff --git a/scripts/publish-smoke.sh b/scripts/publish-smoke.sh new file mode 100644 index 0000000000..68192703be --- /dev/null +++ b/scripts/publish-smoke.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Publish-artifact smoke — prove the first-run experience works on the exact +# package combination a user would install, BEFORE it is published. +# +# Why (issue #3091): pnpm overrides in pnpm-workspace.yaml do NOT ship with +# published packages. 15.1.0 was fully green in-repo (every job ran the +# overridden better-auth 1.7.0-rc.1) while every fresh `npx create-objectstack` +# project resolved plugin-auth's own declared ranges to an untested mix that +# 500'd every auth endpoint. The static half of the fix is +# scripts/check-override-consistency.mjs (#3085); this is the dynamic half: +# actually install what a user gets and drive auth + CRUD end-to-end. +# +# Modes (SMOKE_MODE): +# pack (default) `pnpm pack` every publishable package, scaffold a +# fresh project with the repo-built create-objectstack, and pin +# every @objectstack/* to the local tarballs via the project's OWN +# pnpm overrides. The project lives outside this workspace and +# deliberately inherits none of its pnpm-workspace.yaml overrides +# — exactly like a downstream install of the release candidate. +# Prereq: `pnpm install` + `pnpm build` (dist/ everywhere). +# registry scaffold with the PUBLISHED create-objectstack@latest and +# npm-install straight from the npm registry — the new-user canary +# that catches ^-range drift breaking already-published versions. +# Needs no repo build; only this script. +# +# Both modes then boot `objectstack dev --fresh` and assert: +# - GET /api/v1/auth/get-session → 200 (anonymous) +# - POST /api/v1/auth/sign-up/email → 200 +# - POST /api/v1/auth/sign-in/email → 200, session established +# - REST CRUD on the scaffolded object (POST/GET/PATCH/DELETE /api/v1/data/…) +# - zero error/fatal log lines (specifically the #3091 signature: +# "Failed to register OIDC discovery routes") +# +# better-sqlite3 is an optionalDependency of @objectstack/driver-sql: if the +# runner cannot build the native addon the install still succeeds and the +# runtime falls back to the WASM sqlite driver (#2229) — the smoke must never +# be blocked on node-gyp. +# +# Usage: +# bash scripts/publish-smoke.sh +# Env: +# SMOKE_MODE pack | registry (default: pack) +# SMOKE_ROOT work dir (default: mktemp -d) +# SMOKE_KEEP 1 = keep work dir + logs (default: 0, auto-clean) +# SMOKE_PORT dev-server port (default: 3210) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SMOKE_MODE="${SMOKE_MODE:-pack}" +SMOKE_PORT="${SMOKE_PORT:-3210}" +SMOKE_KEEP="${SMOKE_KEEP:-0}" +SMOKE_ROOT="${SMOKE_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/objectstack-publish-smoke.XXXXXX")}" +APP_NAME="smoke-app" +APP_DIR="$SMOKE_ROOT/$APP_NAME" +# localhost, not 127.0.0.1: the auth plugin's default trustedOrigins is a +# localhost wildcard, so a 127.0.0.1 origin draws a 403 INVALID_ORIGIN. +BASE_URL="http://localhost:$SMOKE_PORT" +SERVER_LOG="$SMOKE_ROOT/server.log" +SERVER_PID="" + +log() { printf '\n== %s\n' "$*"; } +fail() { printf '::error::%s\n' "$*" >&2; exit 1; } + +command -v jq >/dev/null || fail "jq is required" +command -v curl >/dev/null || fail "curl is required" + +# ── cleanup ───────────────────────────────────────────────────────────────── +# `objectstack dev` spawns a `serve` child that outlives its parent when the +# parent is killed, so tear down the whole process tree, leaves first. +kill_tree() { + local pid=$1 child + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + kill_tree "$child" + done + kill "$pid" 2>/dev/null || true +} + +cleanup() { + local code=$? + if [ -n "$SERVER_PID" ]; then + kill_tree "$SERVER_PID" + fi + if [ "$code" -ne 0 ] && [ -f "$SERVER_LOG" ]; then + printf '\n── server.log (tail) ─────────────────────────────\n' + tail -n 200 "$SERVER_LOG" || true + fi + if [ "$SMOKE_KEEP" = "1" ]; then + printf '\nSMOKE_KEEP=1 — work dir preserved: %s\n' "$SMOKE_ROOT" + else + rm -rf "$SMOKE_ROOT" + fi + exit "$code" +} +trap cleanup EXIT + +# ── 1. obtain the project ─────────────────────────────────────────────────── +mkdir -p "$SMOKE_ROOT" + +if [ "$SMOKE_MODE" = "pack" ]; then + [ -d "$REPO_ROOT/packages/cli/dist" ] || fail "packages/cli/dist missing — run 'pnpm build' first" + [ -f "$REPO_ROOT/packages/create-objectstack/bin/create-objectstack.js" ] \ + || fail "create-objectstack bin missing — run 'pnpm build' first" + + log "Packing publishable packages (pnpm pack == publish-time manifests)" + node "$REPO_ROOT/scripts/publish-smoke-pack.mjs" "$SMOKE_ROOT/tarballs" + + log "Scaffolding $APP_NAME with the repo-built create-objectstack" + (cd "$SMOKE_ROOT" && node "$REPO_ROOT/packages/create-objectstack/bin/create-objectstack.js" \ + "$APP_NAME" --skip-install --skip-skills) + + # The project gets its OWN pnpm-workspace.yaml: it is its own workspace + # root (never resolves settings from this repo), and — because pnpm v10 + # reads overrides only from this file — it pins every @objectstack/* to + # the packed tarballs. Anything NOT in the map (transitive deps, + # better-auth, hono, …) resolves from the registry exactly as it would + # for a real user; that unpinned resolution is the thing under test. + log "Pinning @objectstack/* to local tarballs via project-local overrides" + node - "$SMOKE_ROOT/tarballs/overrides.json" "$APP_DIR/pnpm-workspace.yaml" <<'EOF' +const { readFileSync, writeFileSync } = require('node:fs'); +const [overridesPath, outPath] = process.argv.slice(2); +const overrides = JSON.parse(readFileSync(overridesPath, 'utf8')); +const lines = [ + '# Generated by scripts/publish-smoke.sh — standalone workspace root, no', + '# settings inherited from the framework repo. @objectstack/* pinned to the', + '# about-to-publish tarballs; everything else resolves from the registry.', + 'packages:', + " - '.'", + '', + 'onlyBuiltDependencies:', + ' - better-sqlite3', + ' - esbuild', + '', + 'overrides:', + ...Object.entries(overrides).map(([name, spec]) => ` '${name}': '${spec}'`), +]; +writeFileSync(outPath, lines.join('\n') + '\n'); +console.log(` wrote ${outPath} (${Object.keys(overrides).length} overrides)`); +EOF + + log "Installing (pnpm, tarball-pinned)" + (cd "$APP_DIR" && pnpm install --no-frozen-lockfile) + + # Belt-and-braces: if any @objectstack/* resolved from the REGISTRY the + # override map has a hole and the smoke would silently test published code. + # Registry-resolved lockfile keys read '@objectstack/@'; + # tarball-pinned ones read '@objectstack/@file:…' (with possible + # peer suffixes containing their own @, hence the [^'@] name part). + log "Asserting no @objectstack/* leaked to the registry" + if grep -En "'@objectstack/[^'@]+@[0-9]" "$APP_DIR/pnpm-lock.yaml"; then + fail "some @objectstack/* packages resolved from the registry (see above) — publish-smoke-pack.mjs override map is incomplete" + fi + TARBALL_COUNT=$(grep -cE "'@objectstack/[^'@]+@file:" "$APP_DIR/pnpm-lock.yaml" || true) + echo " ok — $TARBALL_COUNT tarball-resolved @objectstack/* lockfile entries" +else + log "Scaffolding $APP_NAME with published create-objectstack@latest" + (cd "$SMOKE_ROOT" && npx -y create-objectstack@latest "$APP_NAME" --skip-install --skip-skills) + + log "Installing from the npm registry (npm — the default new-user path)" + (cd "$APP_DIR" && npm install --no-fund --no-audit) +fi + +# Diagnostic breadcrumb for the #3091 failure class: show which better-auth +# family versions the DOWNSTREAM resolution actually picked. +log "Resolved better-auth family:" +if [ -d "$APP_DIR/node_modules/.pnpm" ]; then + ls "$APP_DIR/node_modules/.pnpm" | grep -E '^(better-auth|@better-auth\+)' | sed 's/^/ /' || echo " (none found)" +else + (cd "$APP_DIR" && npm ls better-auth "@better-auth/core" --all 2>/dev/null | sed 's/^/ /') || true +fi + +# ── 2. boot the dev server ────────────────────────────────────────────────── +# --fresh: ephemeral OS_HOME + sqlite DB + seeded admin +# (admin@objectos.ai / admin123) — no first-run wizard to block on. +log "Starting objectstack dev (port $SMOKE_PORT)" +# NO_COLOR: some loggers colorize even without a TTY; ANSI codes around +# "ERROR" would slip through the log scan below (they did — see the escaped +# `\x1b[31m…ERROR…` line the negative test produced). +(cd "$APP_DIR" && exec env NO_COLOR=1 ./node_modules/.bin/objectstack dev --port "$SMOKE_PORT" --fresh) \ + > "$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +for i in $(seq 1 60); do + if curl -fsS "$BASE_URL/api/v1/health" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + fail "dev server exited before becoming healthy" + fi + [ "$i" = 60 ] && fail "dev server not healthy after 120s" + sleep 2 +done +echo " healthy after probe #$i" + +# ── 3. probes ─────────────────────────────────────────────────────────────── +COOKIES_USER="$SMOKE_ROOT/cookies-user.txt" +COOKIES_ADMIN="$SMOKE_ROOT/cookies-admin.txt" +BODY="$SMOKE_ROOT/body.json" + +# probe