Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
eaf52ed
single-box: serve the five zynd.ai domains through Caddy over HTTPS
AtmegaBuzz Sep 29, 2026
52adc1f
cards-web: make the dashboard's landing page the site root
AtmegaBuzz Sep 29, 2026
5cd27a1
single-box: add server-side deploy script for CI/CD and manual rollbacks
AtmegaBuzz Sep 29, 2026
bec559a
ci: add pytest and eslint baseline scripts that fail only on new fail…
AtmegaBuzz Sep 29, 2026
9492561
Add CI and single-box CD: checks on PRs, auto-deploy of main
AtmegaBuzz Sep 29, 2026
545740b
ci: fall back to webpack when Turbopack's Google-font handler fails o…
AtmegaBuzz Sep 29, 2026
538cee0
single-box: run a dev instance (branch dev) next to prod (branch main)
AtmegaBuzz Sep 29, 2026
8e3221d
deploy workflow: make the SSH host-key pin robust and self-diagnosing
AtmegaBuzz Sep 29, 2026
ecc11f7
added suggestion people and suggested posts
saraffa13 Sep 30, 2026
bcfaab5
Merge pull request #5 from zyndai/feat/cards-suggested-people
saraffa13 Sep 30, 2026
c4585df
cards: "Claim this card" header button for creators; Google Calendar …
Sep 30, 2026
779193c
cards: make links in the downloaded resume PDF clickable
Sep 30, 2026
d0f47bd
cards-web /create: fix LinkedIn label overlapping posts; make the pag…
Sep 30, 2026
039ac25
cards-web: send OAuth callback redirects to the public host, not the …
Sep 30, 2026
e819af5
feat: integrate QuickEnrich API for suggested people functionality
saraffa13 Sep 30, 2026
cdc40fb
cards-api: keep the pasted profile URLs as the card's links, not the …
Sep 30, 2026
717ef4b
Merge pull request #6 from zyndai/feat/cards-suggested-people
saraffa13 Sep 30, 2026
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
34 changes: 34 additions & 0 deletions .github/scripts/eslint-baseline.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Fail only if ESLint reports MORE errors than today's baseline (a ratchet).
#
# eslint-baseline.sh <allowed_errors> # run inside an app directory
#
# `npm run lint` already exits 1 on main (pre-existing errors, listed nowhere in
# AGENTS.md's baseline table), so a plain lint gate would be red from day one.
# This keeps that debt from growing: lower the number when you fix errors,
# never raise it to make a red build green. Warnings are not counted.
set -uo pipefail

max="${1:?usage: eslint-baseline.sh <allowed_errors>}"

json="$(npx eslint . --format json 2>/dev/null)"
errors="$(printf '%s' "$json" | node -e '
let s = "";
process.stdin.on("data", d => (s += d)).on("end", () => {
try { console.log(JSON.parse(s).reduce((n, f) => n + f.errorCount, 0)); }
catch { console.log("crashed"); }
});')"

if [[ "$errors" == "crashed" ]]; then
echo "eslint-baseline: ESLint did not produce a report (config/parse crash):" >&2
npx eslint . 2>&1 | tail -n 20 >&2
exit 1
fi

echo "eslint-baseline: $errors error(s) (baseline allows $max)"
if (( errors > max )); then
echo "eslint-baseline: FAIL: $((errors - max)) new lint error(s) over the baseline of $max" >&2
npx eslint . --quiet 2>&1 | tail -n 60 >&2
exit 1
fi
echo "eslint-baseline: OK"
39 changes: 39 additions & 0 deletions .github/scripts/pytest-baseline.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Run a pytest command and fail only on NEW failures.
#
# pytest-baseline.sh <allowed_failures> -- <pytest command...>
#
# AGENTS.md §5 lists known, pre-existing failures per service (they need env
# vars or a live Postgres/Redis that CI doesn't have). This passes while the
# number of failed + errored tests is <= that baseline, and fails the moment
# it goes above it. Lower the number when you fix one; never raise it to make a
# red build green.
set -uo pipefail

max="${1:?usage: pytest-baseline.sh <allowed_failures> -- <command...>}"
shift
[[ "${1:-}" == "--" ]] && shift

out="$("$@" 2>&1)"
rc=$?
printf '%s\n' "$out" | tail -n 60

if [[ $rc -eq 0 ]]; then
echo "pytest-baseline: all tests passed"
exit 0
fi
if [[ $rc -ne 1 ]]; then
echo "pytest-baseline: pytest exited $rc (usage error / interrupted / no tests), not a plain test failure" >&2
exit "$rc"
fi

summary="$(printf '%s\n' "$out" | grep -E '[0-9]+ (passed|failed|error)' | tail -n 1)"
bad="$(printf '%s' "$summary" | grep -oE '[0-9]+ (failed|errors?)' | awk '{s += $1} END {print s + 0}')"
echo "pytest-baseline: $bad failed/errored (baseline allows $max) — $summary"

if (( bad > max )); then
echo "pytest-baseline: FAIL: $((bad - max)) new failure(s) over the baseline of $max" >&2
printf '%s\n' "$out" | grep -E '^(FAILED|ERROR)' >&2
exit 1
fi
echo "pytest-baseline: OK (only the known baseline failures)"
97 changes: 97 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# CI for the monorepo: web apps (lint ratchet, typecheck, build) and the three
# Python services (tests against AGENTS.md §5's known baselines). Never
# deploys. deploy-single-box.yml calls this before deploying `main`.
#
# The two gates are ratchets, not zero-tolerance: main has pre-existing failures
# (AGENTS.md §5 baselines; ESLint errors that AGENTS.md doesn't list). They fail
# only on NEW ones. Lower a number when you fix something; never raise it to turn
# a red build green. packages/db has its own workflow (db.yml).
name: ci

# Runs on pull requests, and is called by deploy-single-box.yml for pushes to
# main and dev (so a push isn't checked twice).
on:
pull_request:
branches: [main, dev]
workflow_call:

permissions:
contents: read

jobs:
web:
name: web (${{ matrix.app }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { app: persona-web, lint_baseline: 24 }
- { app: cards-web, lint_baseline: 3 }
defaults:
run:
working-directory: apps/${{ matrix.app }}
env:
# Placeholders so `next build` can prerender. None of these are secrets.
NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder-anon-key
NEXT_PUBLIC_API_URL: https://api.example.test
NEXT_PUBLIC_SITE_URL: https://example.test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: apps/${{ matrix.app }}/package-lock.json
- run: npm ci --no-audit --no-fund
- name: Lint (ratchet)
run: bash "$GITHUB_WORKSPACE/.github/scripts/eslint-baseline.sh" ${{ matrix.lint_baseline }}
- name: Typecheck
run: npx tsc --noEmit
- name: Build
# Turbopack first, like production. persona-web is pinned to next@16.2.1,
# whose Turbopack font handler fails on some Google Fonts responses
# ("next/font/google queries have exactly one entry": seen on a GitHub
# runner for Geist; the same build passes elsewhere, and cards-web on
# next@16.3.6 is unaffected). If that happens, retry with webpack: a real
# code error fails both builds and stays red; only the bundler bug is
# let through, with a warning. Fix at the source by bumping persona-web's
# Next to the version cards-web uses.
run: |
npm run build && exit 0
echo "::warning title=Turbopack build failed::retrying with webpack to tell a code error from the next/font/google Turbopack bug on old Next"
npm run build -- --webpack

python:
name: python (${{ matrix.service }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# baseline = the known failures in AGENTS.md §5 (measured on a clean checkout with no .env)
- service: persona-api
baseline: 5
install: pip install -r requirements.txt pytest pytest-asyncio
test: python -m pytest -q
- service: cards-api
baseline: 2
install: pip install -r requirements.txt pytest pytest-asyncio
test: python -m pytest -q
- service: memory
baseline: 9
install: pip install uv && uv sync
test: uv run pytest -q -m "not integration"
defaults:
run:
working-directory: services/${{ matrix.service }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install
run: ${{ matrix.install }}
- name: Tests (no new failures over the baseline)
run: bash "$GITHUB_WORKSPACE/.github/scripts/pytest-baseline.sh" ${{ matrix.baseline }} -- ${{ matrix.test }}
139 changes: 139 additions & 0 deletions .github/workflows/deploy-single-box.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# CD for the single box (infra/single-box/README.md). The box runs two
# environments off the same code: `main` -> prod, `dev` -> dev. When either
# branch changes, run the CI checks, then SSH to the box and run
# infra/single-box/deploy.sh for that environment, which rebuilds and restarts
# only the services whose files changed and health-checks them. Never applies
# DB migrations (packages/db is applied by a person).
#
# Manual runs (Actions tab -> Run workflow) skip the checks so a rollback isn't
# blocked by them: pick the environment, and give a commit sha on that branch to
# roll to, `force_all` to rebuild everything, or `plan_only` to see what a
# deploy would do.
#
# Needs three repository secrets (see README, "CI/CD"): DEPLOY_HOST,
# DEPLOY_SSH_KEY, DEPLOY_HOST_KEY. Only `push` to main/dev and manual dispatch
# trigger this, never pull requests, so forks can't reach the secrets.
name: deploy-single-box

on:
push:
branches: [main, dev]
workflow_dispatch:
inputs:
environment:
description: 'Which instance to deploy: prod (branch main) or dev (branch dev)'
type: choice
options: [prod, dev]
default: prod
ref:
description: 'Commit sha on that environment''s branch (blank = latest; an older sha rolls back)'
required: false
default: ''
force_all:
description: 'Rebuild and restart every service'
type: boolean
default: false
plan_only:
description: 'Show what would change; deploy nothing'
type: boolean
default: false

permissions:
contents: read

concurrency:
group: deploy-single-box
cancel-in-progress: false

jobs:
checks:
if: github.event_name == 'push'
uses: ./.github/workflows/ci.yml

deploy:
needs: checks
# `checks` is skipped on manual runs; a skipped need would otherwise skip us too.
if: ${{ !cancelled() && (needs.checks.result == 'success' || needs.checks.result == 'skipped') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Build the deploy arguments
id: args
env:
# push to dev -> dev instance, push to main -> prod; a manual run picks.
ENVIRONMENT: ${{ github.event_name == 'workflow_dispatch' && inputs.environment || (github.ref_name == 'dev' && 'dev' || 'prod') }}
REF: ${{ inputs.ref }}
FORCE_ALL: ${{ inputs.force_all }}
PLAN_ONLY: ${{ inputs.plan_only }}
run: |
[[ "$ENVIRONMENT" == "prod" || "$ENVIRONMENT" == "dev" ]] || { echo "::error::unknown environment '$ENVIRONMENT'"; exit 1; }
args="latest"
if [[ -n "$REF" ]]; then
[[ "$REF" =~ ^[0-9a-f]{7,40}$ ]] || { echo "::error::ref must be a commit sha (7-40 hex characters)"; exit 1; }
args="$REF"
fi
args="$args --env=$ENVIRONMENT"
[[ "$FORCE_ALL" == "true" ]] && args="$args --force-all"
[[ "$PLAN_ONLY" == "true" ]] && args="$args --plan"
echo "Deploying the $ENVIRONMENT instance: $args"
echo "value=$args" >> "$GITHUB_OUTPUT"

- name: Set up SSH
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }}
run: |
missing=()
for v in DEPLOY_HOST DEPLOY_SSH_KEY DEPLOY_HOST_KEY; do [[ -n "${!v}" ]] || missing+=("$v"); done
if ((${#missing[@]})); then
echo "::error::Missing repository secret(s): ${missing[*]}. See infra/single-box/README.md, CI/CD."
exit 1
fi
umask 077
install -d ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key

# Sanity-check the private key without printing any of it.
head -n 1 ~/.ssh/deploy_key | grep -q -- '-----BEGIN OPENSSH PRIVATE KEY-----' \
|| { echo "::error::DEPLOY_SSH_KEY must be the whole private key file, starting with the -----BEGIN OPENSSH PRIVATE KEY----- line"; exit 1; }
ssh-keygen -y -f ~/.ssh/deploy_key >/dev/null 2>&1 \
|| { echo "::error::DEPLOY_SSH_KEY is not a valid private key (missing END line, or line breaks lost when pasting?)"; exit 1; }

# Pinned host key, not trust-on-first-use. Take "<type> <base64>" from whatever
# was pasted, so a leading host name, a trailing comment or quotes don't break it.
hostkey="$(printf '%s' "$DEPLOY_HOST_KEY" | tr -d '\r"'"'" | awk '
{ for (i = 1; i < NF; i++) if ($i ~ /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521))$/) { print $i, $(i+1); exit } }')"
if [[ -z "$hostkey" ]]; then
# Only the base64 part was pasted: every ed25519 host key starts with this fixed prefix.
b64="$(printf '%s' "$DEPLOY_HOST_KEY" | tr -d '\r"'"'" | grep -oE 'AAAAC3NzaC1lZDI1NTE5[A-Za-z0-9+/=]+' | head -n 1 || true)"
[[ -n "$b64" ]] && hostkey="ssh-ed25519 $b64"
fi
if [[ -z "$hostkey" ]]; then
echo "::error::DEPLOY_HOST_KEY has no host key in it. It must be one line, 'ssh-ed25519 AAAA...', i.e. the output of: cut -d' ' -f1-2 /etc/ssh/ssh_host_ed25519_key.pub"
exit 1
fi
printf '%s %s\n' "$DEPLOY_HOST" "$hostkey" > ~/.ssh/known_hosts

# Compare with what the server actually presents, and say so if they differ
# (fingerprints of public host keys only).
if [[ "$hostkey" == ssh-ed25519* ]]; then
scanned="$(ssh-keyscan -T 10 -t ed25519 "$DEPLOY_HOST" 2>/dev/null | awk '{print $2, $3}' | head -n 1)"
if [[ -z "$scanned" ]]; then
echo "::warning::could not reach the server on port 22 to double-check the pinned host key"
elif [[ "$scanned" != "$hostkey" ]]; then
echo "::error::DEPLOY_HOST_KEY does not match the key the server presents. Pinned: $(echo "$hostkey" | ssh-keygen -lf - | cut -d' ' -f2) Server: $(echo "$scanned" | ssh-keygen -lf - | cut -d' ' -f2)"
exit 1
fi
fi

- name: Deploy
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
ARGS: ${{ steps.args.outputs.value }}
run: |
# The key on the server is restricted to a forced command (deploy.sh), so
# these arguments are all it will ever accept.
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=yes -o ConnectTimeout=20 -o ServerAliveInterval=30 \
"ubuntu@${DEPLOY_HOST}" "$ARGS"
4 changes: 2 additions & 2 deletions apps/cards-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The standalone frontend for **cards.zynd.ai** — ported out of the `dashboard`
repo's `app/(site)/{p,create,profile,directory,find,search,tag,for-ai}` and
`app/agent-card`, per `ZYND_CARDS_MOVE_PLAN.md` phase P2. It talks to the
`app/agent-card` (now the `/` landing page, `app/(landing)`), per `ZYND_CARDS_MOVE_PLAN.md` phase P2. It talks to the
existing `services/cards-api` (still `api.zynd.ai`, unchanged) for card data,
and to a Supabase project for login.

Expand Down Expand Up @@ -43,7 +43,7 @@ production auth yet:
`www.zynd.ai` — the dashboard's versions mixed in registry/blog content
that doesn't belong here. `api/indexnow` uses its own IndexNow key (Bing
requires the key file to be hosted on the exact host it's submitted for).
- Everything else — `p/[handle]/**`, `agent-card/**`, `profile/[id]`,
- Everything else — `p/[handle]/**`, the `(landing)` page, `profile/[id]`,
`directory`, `find`, `search`, `tag/[skill]`, `for-ai`, `lib/cards.ts`,
`lib/memory*.ts`, `lib/claim-tokens.ts`, `lib/supabase/*`,
`components/memory/*`, `ProfileChatWidget`, `useMyCard` — ported with no
Expand Down
5 changes: 5 additions & 0 deletions apps/cards-web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
// The landing page used to live at /agent-card (as it still does on the
// dashboard); here it is the site root.
async redirects() {
return [{ source: "/agent-card", destination: "/", permanent: true }];
},
async headers() {
return [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function AgentCardAuthBar() {
const { ready, authenticated, handle } = useMyCard();

function signIn() {
setAuthNext("/agent-card", "card");
setAuthNext("/", "card");
createClient().auth.signInWithOAuth({
provider: "linkedin_oidc",
options: { redirectTo: CALLBACK() },
Expand All @@ -21,7 +21,7 @@ export function AgentCardAuthBar() {
async function signOut() {
const { error } = await createClient().auth.signOut();
if (error) console.error("Sign out failed:", error);
window.location.href = "/agent-card";
window.location.href = "/";
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ export const metadata: Metadata = {
title: "Zynd — The Living Professional Identity for Technical Builders and Agents",
description:
"Zynd synthesizes GitHub, LinkedIn, X and your website into one living professional profile — browsable by people, searchable by AI agents.",
alternates: { canonical: "/agent-card" },
alternates: { canonical: "/" },
};

/**
* Standalone root layout. `/agent-card` deliberately sits OUTSIDE the `(site)`
* route group so it does not inherit globals.css / zynd-ui.css — the page ships
* its own compiled Tailwind v3 stylesheet and would otherwise fight the app's
* Tailwind 4 preflight.
* Standalone root layout for `/`. The landing page deliberately sits OUTSIDE
* the `(site)` route group so it does not inherit globals.css / zynd-ui.css —
* the page ships its own compiled Tailwind v3 stylesheet and would otherwise
* fight the app's Tailwind 4 preflight.
*/
export default function AgentCardLayout({
children,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Typewriter } from "./typewriter";
import { AgentCardAuthBar } from "./auth-bar";

/**
* `/agent-card` — the standalone Zynd landing page.
* `/` — the standalone Zynd landing page.
*
* Static marketing markup, with two live hooks into the create flow:
* - every "Create your Living Profile" / "Claim Handle" CTA links to /create
Expand Down
Loading
Loading