Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions .github/compose/d1-read.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Read-only query against a production D1 database. Prints tab-separated rows.
# Needs `wrangler login` (or CLOUDFLARE_API_TOKEN) with D1 read access, and jq.
#
# .github/compose/d1-read.sh core "SELECT name FROM d1_migrations ORDER BY id"
# .github/compose/d1-read.sh feed "SELECT COUNT(*) FROM feed_items"
#
# core = ghfind (shared by dev and production), feed = ghfind-feed. Schema changes go
# through migrations and ops/feed-production-schema-release.json in ghfind-server,
# never through this script, so it accepts one SELECT / WITH / EXPLAIN / read-only
# PRAGMA statement and rejects anything that writes.
set -euo pipefail
case "${1:-}" in core) db=ghfind ;; feed) db=ghfind-feed ;; *) echo "usage: $0 core|feed \"SELECT …\"" >&2; exit 64 ;; esac
sql=${2:-}
[ -n "$sql" ] && [ $# = 2 ] || { echo "usage: $0 core|feed \"SELECT …\"" >&2; exit 64; }

upper=$(printf '%s' "$sql" | tr '[:lower:]' '[:upper:]')
body=${upper%;} # one trailing semicolon is fine; any other means a second statement
if [[ "$body" == *";"* ]] \
|| ! [[ "$upper" =~ ^[[:space:]]*(SELECT|WITH|EXPLAIN|PRAGMA)[[:space:]] ]] \
|| [[ "$upper" =~ (^|[^A-Z_])(INSERT|UPDATE|DELETE|REPLACE|UPSERT|DROP|ALTER|CREATE|ATTACH|DETACH|VACUUM|REINDEX)([^A-Z_]|$) ]] \
|| { [[ "$upper" =~ ^[[:space:]]*PRAGMA ]] && [[ "$upper" == *"="* ]]; }; then
echo "d1-read: only one read-only SELECT / WITH / EXPLAIN / PRAGMA statement is allowed" >&2
exit 64
fi

out=$(CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-8f19bebe359e4ec1a24c68c5f49c1584} \
npx -y wrangler@4 d1 execute "$db" --remote --json --command "$sql" 2>/dev/null) || {
echo "d1-read: wrangler failed; run it without 2>/dev/null to see why (often: wrangler login)" >&2; exit 1; }
if [ "$(jq -r '.[0].meta.changed_db' <<<"$out")" = true ]; then
echo "d1-read: WARNING: the database reports a change; tell the owners" >&2; exit 1
fi
jq -r '.[0].results | if length == 0 then "(no rows)" else (.[0] | keys_unsorted | @tsv), (.[] | [.[] | tostring] | @tsv) end' <<<"$out"
25 changes: 25 additions & 0 deletions .github/compose/probe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Read-only probe of production after a deploy: status code, latency, and raw i18n keys
# (a page showing "tiers.x.name" instead of text means a missing translation).
#
# .github/compose/probe.sh # https://ghfind.com
# .github/compose/probe.sh https://host # another origin
#
# Exit status: 0 every path returned 200 with no raw keys, 1 otherwise.
set -uo pipefail
origin=${1:-https://ghfind.com}
paths=(/ /en /zh /en/leaderboard /zh/leaderboard /en/u/hikariming /zh/u/hikariming
/en/vs/torvalds/yyx990803 /en/developers/language/TypeScript /en/projects /en/feed /en/docs
/api/score/hikariming /api/stats "/api/leaderboard?view=trending&window=all"
/api/badge/hikariming "/api/card/hikariming?theme=light" /openapi.json /llms.txt /sitemap.xml)
body=$(mktemp); trap 'rm -f "$body"' EXIT
bad=0
printf '%-44s %5s %7s %s\n' PATH CODE MS RAWKEYS
for p in "${paths[@]}"; do
out=$(curl -s -o "$body" -w '%{http_code} %{time_total}' -H 'user-agent: ghfind-deploy-probe' --max-time 30 "$origin$p")
code=${out% *}; ms=$(awk -v t="${out#* }" 'BEGIN{printf "%d", t*1000}')
raw=$(grep -o -E '(realms|tiers)\.[a-zA-Z]+\.(name|blurb)' "$body" 2>/dev/null | sort -u | head -3 | tr '\n' ' ')
{ [ "$code" = 200 ] && [ -z "$raw" ]; } || bad=1
printf '%-44s %5s %7s %s\n' "$p" "$code" "$ms" "$raw"
done
exit $bad
105 changes: 105 additions & 0 deletions .github/compose/release-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Where a release is in the chain: Release → rel/ marker → deploy commit → deploy CI →
# production deploys. Read-only; needs gh (authenticated) and jq.
#
# .github/compose/release-status.sh ghfind-server v1.0.6 # one report
# .github/compose/release-status.sh --wait ghfind v1.0.3 # follow until it ends
#
# Exit status: 0 deployed (Cloudflare and apps deploys succeeded), 1 a step failed,
# 2 still in progress (only without --wait).
#
# The deploy workflows start by workflow_run, so their runs report main's SHA, not the
# deploy commit's. They are matched as the first runs created after the deploy CI run.
set -euo pipefail

wait=0
[ "${1:-}" = --wait ] && { wait=1; shift; }
repo=${1:-}; tag=${2:-}
case "$repo" in ghfind|ghfind-server|ghfind-frontend) ;; *) echo "usage: $0 [--wait] ghfind|ghfind-server|ghfind-frontend vX.Y.Z" >&2; exit 64 ;; esac
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "release-status: tag must look like v1.2.3" >&2; exit 64; }
hub=FindAny/ghfind

# gh with retries: transient GitHub/TLS errors should not end a long --wait.
ghr() { local _; for _ in 1 2 3 4; do "$@" 2>/dev/null && return 0; sleep 5; done; return 1; }
line() { printf '%-11s %-10s %s\n' "$1" "$2" "${3:-}"; }

# Prints the report; returns 0 deployed, 1 failed, 2 pending.
report() {
local run state url
run=$(ghr gh run list --repo "FindAny/$repo" --workflow release.yml --limit 30 \
--json databaseId,status,conclusion,headBranch,url \
--jq "[.[]|select(.headBranch==\"$tag\")][0]|select(.!=null)|\"\(.status) \(.conclusion) \(.url)\"") || run=""
if [ -z "$run" ]; then line release pending "no Release run for $tag in FindAny/$repo yet"; return 2; fi
read -r state conc url <<<"$run"
[ "$state" = completed ] || { line release running "$url"; return 2; }
[ "$conc" = success ] || { line release "$conc" "$url"; return 1; }
line release success "$url"

if ! git ls-remote --exit-code --tags "https://github.com/FindAny/$repo.git" "refs/tags/rel/$tag" >/dev/null 2>&1; then
line verified missing "rel/$tag not pushed (see the Release run)"; return 1
fi
line verified yes "rel/$tag"

# Deploy commits that pin this version, newest first, e.g.
# "deploy: ghfind v1.0.3 + ghfind-server v1.0.5 + ghfind-frontend v1.0.2".
local commits head pinned=() s m
commits=$(ghr gh api "repos/$hub/commits?sha=deploy&per_page=60" --jq '.[]|"\(.sha) \(.commit.message|split("\n")[0])"') || commits=""
while read -r s m; do
[[ " ${m#deploy: } " =~ (^|[[:space:]+])$repo\ $tag([[:space:]]|$) ]] && pinned+=("$s $m")
done <<<"$commits"
if [ ${#pinned[@]} = 0 ]; then line compose pending "no deploy commit pins $repo $tag yet (Compose release in $hub)"; return 2; fi
head=$(ghr gh api "repos/$hub/commits/deploy" --jq .sha) || head=""
# Report the newest commit, unless a newer composition superseded it before it
# finished (CI cancelled); then report the newest older commit that deployed.
local entry out rc first="" first_rc=0
for entry in "${pinned[@]}"; do
set +e; out=$(deploy_chain "${entry%% *}" "${entry#* }" "$head"); rc=$?; set -e
[ -n "$first" ] || { first=$out; first_rc=$rc; }
if [ $rc = 0 ] || [ "${entry%% *}" = "$head" ]; then printf '%s\n' "$out"; return $rc; fi
done
printf '%s\n' "$first"; return $first_rc
}

# One deploy commit through CI and both production deploys.
deploy_chain() {
local sha=$1 msg=$2 head=$3 run state conc url
line compose "done" "${sha:0:7} $msg$([ "$sha" = "$head" ] && echo ' (deploy head)' || echo ' (superseded by a newer deploy commit)')"

local ci created
ci=$(ghr gh run list --repo "$hub" --workflow ci.yml --commit "$sha" --limit 1 \
--json status,conclusion,createdAt,url --jq '.[0]|select(.!=null)|"\(.status) \(.conclusion) \(.createdAt) \(.url)"') || ci=""
if [ -z "$ci" ]; then line ci pending "CI on deploy not started"; return 2; fi
read -r state conc created url <<<"$ci"
[ "$state" = completed ] || { line ci running "$url"; return 2; }
[ "$conc" = success ] || { line ci "$conc" "$url (production deploys are skipped)"; return 1; }
line ci success "$url"

local wf name result=0 id plan
for wf in deploy-apps-production.yml deploy-cf-production.yml; do
name=$([ "$wf" = deploy-cf-production.yml ] && echo cloudflare || echo apps)
run=$(ghr gh run list --repo "$hub" --workflow "$wf" --limit 40 \
--json databaseId,status,conclusion,createdAt,url \
--jq "[.[]|select(.createdAt>=\"$created\")]|sort_by(.createdAt)[0]|select(.!=null)|\"\(.databaseId) \(.status) \(.conclusion) \(.url)\"") || run=""
if [ -z "$run" ]; then line "$name" pending "not started"; result=2; continue; fi
read -r id state conc url <<<"$run"
if [ "$state" != completed ]; then line "$name" running "$url"; result=2; continue; fi
if [ "$conc" != success ]; then line "$name" "$conc" "$url"; [ $result = 2 ] || result=1; continue; fi
if [ "$name" = cloudflare ]; then
# The Feed plan: site-only when Feed inputs are unchanged, else the Feed cutover.
plan=$(ghr gh run view "$id" --repo "$hub" --log | grep -oE '\{"feedRelease":[^}]*\}' | head -1) || plan=""
line "$name" success "$url ${plan:+feed plan $plan}"
else
line "$name" success "$url"
fi
done
return $result
}

if [ $wait = 0 ]; then report; exit $?; fi
prev=""
while true; do
set +e; out=$(report); rc=$?; set -e
if [ "$out" != "$prev" ]; then printf '\n[%s]\n%s\n' "$(date +%H:%M:%S)" "$out"; prev=$out; fi
[ $rc = 2 ] || exit $rc
sleep 30
done
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| 了解三仓库、组装、发布 → 组合 → 部署、凭据与安全模型 | [`docs/architecture.md`](docs/architecture.md) |
| 发版、组合 / 固定版本、回滚、Feed 切换、故障恢复、App 密钥轮换、本地组装 | [`docs/runbook.md`](docs/runbook.md)(man page 格式,操作以此为准) |
| 给客户 / 合作方介绍部署方式(中文) | [`docs/deployment.zh.md`](docs/deployment.zh.md) |
| 跟踪发布到上线、检查线上、只读查询生产 D1 | `.github/compose/release-status.sh`、`probe.sh`、`d1-read.sh`(用法见 runbook SYNOPSIS;等待发布用 `release-status.sh --wait`) |
| 文件归属(哪个仓库拥有哪个路径) | [`.github/compose/components.json`](.github/compose/components.json)(唯一清单) |
| 修改 CI/CD | `.github/workflows/`;私有仓库共用 [`component-ci.yml`](.github/workflows/component-ci.yml) / [`component-release.yml`](.github/workflows/component-release.yml),它们自己的 `ci.yml`/`release.yml` 只是调用方 |
| 评分模块 | [`scoring/README.md`](scoring/README.md)、[`docs/rfc/2026-10-15-account-scoring.md`](docs/rfc/2026-10-15-account-scoring.md) |
Expand Down
4 changes: 4 additions & 0 deletions docs/deployment.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ gh workflow run compose-release.yml -R FindAny/ghfind \

# 查看当前线上版本清单
git fetch origin deploy && git show origin/deploy:components.manifest.json

# 跟踪某个版本从发布到上线的全过程,并检查线上页面(只读)
.github/compose/release-status.sh --wait ghfind-server vX.Y.Z
.github/compose/probe.sh
```

完整操作(包括恢复流程、密钥轮换和本地开发)见 [runbook.md](./runbook.md)。
Expand Down
15 changes: 13 additions & 2 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ gh variable set FEED_RELEASE_MODE -R FindAny/ghfind --body always # then delet

# Assemble a workspace locally (in a FindAny/ghfind checkout)
.github/compose/assemble.sh [--channel main] [--ref NAME=REF] [--dir NAME=PATH] [--clean]

# Follow a release to production, then check the live site (read-only)
.github/compose/release-status.sh [--wait] ghfind|ghfind-server|ghfind-frontend vX.Y.Z
.github/compose/probe.sh [https://origin]

# Query a production D1 database (read-only; needs wrangler login)
.github/compose/d1-read.sh core|feed "SELECT …"
```

## DESCRIPTION
Expand Down Expand Up @@ -54,14 +61,16 @@ so all CI/CD changes happen in one place.
1. Merge to `main` in the component's repository and wait for its `CI` to pass.
2. Tag the version on that exact commit: `git tag vX.Y.Z && git push origin vX.Y.Z`.
Use the next semver. Tags are permanent, so never move or reuse one.
3. Watch, in order:
3. Follow it with `.github/compose/release-status.sh --wait <repo> vX.Y.Z`, then run
`.github/compose/probe.sh`. The chain, in order:
- `Release` in the component's repository. It refuses a tag that is not on
`main` or whose commit has no green `CI`, then pushes `rel/vX.Y.Z` and
dispatches composition.
- `Compose release` in `FindAny/ghfind`.
- `CI` on the `deploy` branch in `FindAny/ghfind`.
- `Deploy production (Cloudflare)` and `Deploy apps (production)` in
`FindAny/ghfind`.
`FindAny/ghfind`. They start by `workflow_run`, so their runs report main's
SHA, not the deploy commit's; match them by start time, as the script does.

A release is *verified* once `rel/vX.Y.Z` exists. It is *deployed* once the deploy
workflows succeed on the `deploy` commit that pins it.
Expand Down Expand Up @@ -207,6 +216,8 @@ deployment. It must never appear in this repository, the SDKs or a workflow log.
| `.github/compose/components.json` | Who owns which path |
| `.github/compose/assemble.sh` | Workspace assembly |
| `.github/compose/compose-release.sh` | Builds the `deploy` commit. It refuses to stage anything except the manifest. |
| `.github/compose/check-hub-contract.mjs` | Component CI check: every script a hub workflow calls exists; runs ci.yml's contract tests |
| `.github/compose/release-status.sh`, `probe.sh`, `d1-read.sh` | Read-only release tracking, production probe and production D1 queries |
| `.github/actions/assemble/` | Assembly action used by every workflow |
| `.github/workflows/component-{ci,release}.yml` | Reusable CI and release for the private repositories |
| `components.manifest.json` (on `deploy` only) | Exact component commits for that deploy |
Expand Down
Loading