@silverassist/agents-toolkit 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,9 +25,9 @@ This skill provides guidelines for organizing code following Domain-Driven Desig
25
25
 
26
26
  ### ❌ DON'T
27
27
 
28
- - Create generic folders like "helpers", "services", "utils" at root level
28
+ - Create generic folders like `src/helpers/`, `src/services/`, or `src/utils/` directly under `src/` (a scoped `src/lib/utils/` for minimal cross-domain primitives is allowed)
29
29
  - Mix different domain concerns in the same folder
30
- - Create deeply nested folder structures (max 3 levels)
30
+ - Create folder hierarchies deeper than 3 levels below `src/` (e.g., `src/components/auth/login-form/` is allowed; `src/components/auth/login-form/subform/` is not)
31
31
  - Use abbreviations in folder names
32
32
 
33
33
  ## Project Structure
@@ -228,7 +228,7 @@ Ask these questions to identify domains:
228
228
  ### ❌ Generic Folder Anti-Patterns
229
229
 
230
230
  ```
231
- # ❌ BAD: Generic folders
231
+ # ❌ BAD: Generic folders at src/ root
232
232
  src/
233
233
  ├── components/
234
234
  ├── helpers/ # What kind of helpers?
@@ -237,6 +237,11 @@ src/
237
237
  └── types/ # Types should live with their domain
238
238
  ```
239
239
 
240
+ ### Shared TypeScript Types
241
+
242
+ - If a type is used by **3 or more domains**, place it in `src/lib/types/` — the shared-types exception lives under the allowed `src/lib/` scope, **not** a generic `src/types/` root (which the anti-pattern above forbids). Export it from that folder's own barrel (`src/lib/types/index.ts`) and import it as `@/lib/types`. A centrally shared primitive has **no owning domain**, so do not route it through a domain barrel — that would invert the intended dependency direction (domains → shared, never shared → a domain).
243
+ - Otherwise, **colocate the type with its primary domain** (e.g., `src/lib/payment/types.ts`, `src/components/auth/types.ts`) and re-export it through that domain's barrel when other domains need it.
244
+
240
245
  ### ✅ Domain-Oriented Structure
241
246
 
242
247
  ```
@@ -0,0 +1,215 @@
1
+ ---
2
+ name: github-review-management
3
+ description: Fetch, reply to, resolve, and close GitHub pull-request review threads with the gh CLI and GraphQL API. Use when handling Copilot or human PR reviews end-to-end.
4
+ ---
5
+
6
+ # Silver Assist — GitHub Review Management
7
+
8
+ Reference knowledge for clearing GitHub pull-request reviews programmatically. This skill
9
+ backs the `/resolve-github-reviews` prompt: it explains the data model, which API does what,
10
+ the exact commands, and the Copilot-specific edge cases. Use it when a PR has review threads
11
+ (Copilot or human) that need to be addressed, replied to, resolved, and verified to `0`.
12
+
13
+ ## When to Use
14
+
15
+ - A PR has open Copilot or human review comments to clear before merge
16
+ - You need to **resolve** review threads (not just reply) — which REST cannot do
17
+ - Debugging why a reply or resolve call fails (404 on replies, permissions on the mutation)
18
+ - Handling Copilot suppressed / low-confidence notes or `isOutdated` threads
19
+ - Automating the "reply → resolve → assert 0 unresolved" loop across per-commit review rounds
20
+
21
+ ## The data model (get this right first)
22
+
23
+ GitHub exposes three distinct things — conflating them is the usual source of bugs:
24
+
25
+ | Concept | What it is | API surface |
26
+ |---------|-----------|-------------|
27
+ | **Review (submission)** | The top-level verdict on a PR: `APPROVED` / `CHANGES_REQUESTED` / `COMMENTED` | `gh pr review`, REST `/pulls/{n}/reviews` |
28
+ | **Review comment** | A single inline comment anchored to a file/line (has a `databaseId`) | REST `/pulls/{n}/comments` |
29
+ | **Review thread** | An inline comment **plus its replies**, carrying the `isResolved` flag | **GraphQL `reviewThreads` only** |
30
+
31
+ Only **threads** have `isResolved`, and **only GraphQL can flip it**. The REST comments API
32
+ lists and replies to comments but has no concept of resolving.
33
+
34
+ ## Which API does what
35
+
36
+ | Operation | API | Command |
37
+ |-----------|-----|---------|
38
+ | List unresolved threads | GraphQL | `reviewThreads(first:100){ nodes{ id isResolved path line comments(first:1){ nodes{ databaseId } } } }` |
39
+ | Reply to a thread | REST | `POST /pulls/{n}/comments/{comment_id}/replies` |
40
+ | Reply fallback (404) | REST | `POST /pulls/{n}/comments` with `in_reply_to={comment_id}` |
41
+ | Acknowledge suppressed notes | REST | `gh pr comment {n} --body "…"` (PR-level) |
42
+ | **Resolve a thread** | **GraphQL** | `resolveReviewThread(input:{threadId:$id})` |
43
+ | Verify 0 unresolved | GraphQL | re-query `reviewThreads`, count `isResolved == false` |
44
+
45
+ ## Resolve locations
46
+
47
+ ```bash
48
+ REPO_SLUG=$(gh repo view --json nameWithOwner -q .nameWithOwner) # owner/repo
49
+ OWNER=${REPO_SLUG%/*}; REPO=${REPO_SLUG#*/}
50
+ PR=$(gh pr view --json number -q .number) # current branch's PR
51
+ ```
52
+
53
+ ## 1. List unresolved threads (paginated)
54
+
55
+ A PR can have more than 100 threads, so a single `first:100` page is not enough. Walk the
56
+ connection with `pageInfo { endCursor hasNextPage }` and collect every node before filtering.
57
+
58
+ ```bash
59
+ > /tmp/review-threads.jsonl
60
+ CURSOR=null
61
+ while : ; do
62
+ # First page: there is no cursor yet ($CURSOR is the string "null"). Passing it as
63
+ # `-f after="null"` would send the literal string "null" — an invalid cursor, not a
64
+ # "start from the beginning" signal. Omit `after` on this pass so the query starts at the
65
+ # beginning; pass the real endCursor afterward. (gh's typed `-F after=null` instead sends
66
+ # a real GraphQL null.)
67
+ if [ "$CURSOR" = "null" ]; then
68
+ AFTER_ARGS=()
69
+ else
70
+ AFTER_ARGS=(-f after="$CURSOR")
71
+ fi
72
+ PAGE=$(gh api graphql -F owner="$OWNER" -F repo="$REPO" -F pr="$PR" "${AFTER_ARGS[@]}" -f query='
73
+ query($owner:String!, $repo:String!, $pr:Int!, $after:String) {
74
+ repository(owner:$owner, name:$repo) {
75
+ pullRequest(number:$pr) {
76
+ reviewThreads(first:100, after:$after) {
77
+ pageInfo { endCursor hasNextPage }
78
+ nodes {
79
+ id
80
+ isResolved
81
+ isOutdated
82
+ path
83
+ line
84
+ comments(first:1) { nodes { databaseId body author { login } } }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }')
90
+ # Fail fast: a GraphQL/auth/network error must not be mistaken for "no threads".
91
+ if ! echo "$PAGE" | jq -e '.data.repository.pullRequest.reviewThreads' >/dev/null 2>&1; then
92
+ echo "ERROR: GraphQL request failed or returned an unexpected shape:" >&2
93
+ echo "$PAGE" >&2
94
+ exit 1
95
+ fi
96
+ echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> /tmp/review-threads.jsonl
97
+ HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
98
+ CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
99
+ [ "$HAS_NEXT" = "true" ] || break
100
+ done
101
+
102
+ jq -r '
103
+ select(.isResolved == false)
104
+ | "\(.id)\t\(.comments.nodes[0].databaseId)\t\(.path):\(.line)"
105
+ ' /tmp/review-threads.jsonl
106
+ ```
107
+
108
+ Each row gives `threadId` (to resolve) and `commentId` = `databaseId` (to reply). A `null`
109
+ `databaseId` marks a suppressed note — see below.
110
+
111
+ ## 2. Reply to a thread
112
+
113
+ ```bash
114
+ # Primary: reply directly on the thread.
115
+ gh api "repos/$OWNER/$REPO/pulls/$PR/comments/$COMMENT_ID/replies" \
116
+ -f body="Fixed in <sha>: <what changed>."
117
+
118
+ # Fallback when the replies endpoint 404s: link via in_reply_to.
119
+ gh api "repos/$OWNER/$REPO/pulls/$PR/comments" \
120
+ -f body="Fixed in <sha>: <what changed>." \
121
+ -F in_reply_to="$COMMENT_ID"
122
+ ```
123
+
124
+ ## 3. Resolve the thread (GraphQL — the only way)
125
+
126
+ ```bash
127
+ gh api graphql -f id="$THREAD_ID" -f query='
128
+ mutation($id:ID!) {
129
+ resolveReviewThread(input:{threadId:$id}) {
130
+ thread { isResolved }
131
+ }
132
+ }'
133
+ ```
134
+
135
+ Batch all addressed threads:
136
+
137
+ ```bash
138
+ for THREAD_ID in $THREAD_IDS; do
139
+ gh api graphql -f id="$THREAD_ID" -f query='mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}){ thread { isResolved } } }'
140
+ done
141
+ ```
142
+
143
+ > To reopen a thread (rarely needed), the counterpart mutation is `unresolveReviewThread`.
144
+
145
+ ## 4. Verify 0 unresolved (the close-the-loop check)
146
+
147
+ ```bash
148
+ > /tmp/review-threads-remaining.jsonl
149
+ CURSOR=null
150
+ while : ; do
151
+ if [ "$CURSOR" = "null" ]; then
152
+ AFTER_ARGS=()
153
+ else
154
+ AFTER_ARGS=(-f after="$CURSOR")
155
+ fi
156
+ PAGE=$(gh api graphql -F owner="$OWNER" -F repo="$REPO" -F pr="$PR" "${AFTER_ARGS[@]}" -f query='
157
+ query($owner:String!, $repo:String!, $pr:Int!, $after:String) {
158
+ repository(owner:$owner, name:$repo) {
159
+ pullRequest(number:$pr) {
160
+ reviewThreads(first:100, after:$after) {
161
+ pageInfo { endCursor hasNextPage }
162
+ nodes { isResolved }
163
+ }
164
+ }
165
+ }
166
+ }')
167
+ # Fail fast: a GraphQL/auth/network error must not be mistaken for "0 remaining".
168
+ if ! echo "$PAGE" | jq -e '.data.repository.pullRequest.reviewThreads' >/dev/null 2>&1; then
169
+ echo "ERROR: GraphQL request failed or returned an unexpected shape:" >&2
170
+ echo "$PAGE" >&2
171
+ exit 1
172
+ fi
173
+ echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> /tmp/review-threads-remaining.jsonl
174
+ HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
175
+ CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
176
+ [ "$HAS_NEXT" = "true" ] || break
177
+ done
178
+
179
+ REMAINING=$(jq -s '[.[] | select(.isResolved == false)] | length' /tmp/review-threads-remaining.jsonl)
180
+ if [ "$REMAINING" -eq 0 ]; then
181
+ echo "✅ all resolved"
182
+ else
183
+ echo "❌ $REMAINING remaining"
184
+ exit 1
185
+ fi
186
+ ```
187
+
188
+ > If `isResolved` remains `false` immediately after a successful `resolveReviewThread`
189
+ > mutation, wait 2–3 seconds and re-query — GitHub's GraphQL API can exhibit brief
190
+ > consistency lag after bulk resolves, so a thread already resolved server-side may
191
+ > still report as open on the next read.
192
+
193
+ ## Copilot-specific handling
194
+
195
+ - **Suppressed / low-confidence notes** — Copilot posts some findings without an inline comment
196
+ (`databaseId` is `null`), so there is **no inline comment to reply to**. The thread itself still
197
+ has an `id` and is resolvable — acknowledge the note with a single PR-level comment
198
+ (`gh pr comment`), then resolve the thread by its `id` like any other.
199
+ - **`isOutdated` threads** — when the underlying code moves, the thread is flagged `isOutdated`
200
+ but remains **unresolved**. It never clears itself; resolve it explicitly once handled.
201
+ - **Per-commit review rounds** — Copilot re-reviews after each push, potentially opening new
202
+ threads. Re-run list → reply → resolve → verify until the count is `0`. Trigger a fresh pass
203
+ with `gh pr comment $PR --body "@copilot review"` (unquoted `$PR` — it is the integer PR
204
+ number set earlier via `gh pr view --json number -q .number`, and `gh pr comment` takes
205
+ it as a positional argument, not a string flag value).
206
+
207
+ ## Common failures
208
+
209
+ | Symptom | Cause | Fix |
210
+ |---------|-------|-----|
211
+ | Resolve mutation returns a permissions error | Token lacks `repo` scope | Re-auth: `gh auth refresh -s repo` |
212
+ | `POST …/replies` → 404 | Thread doesn't accept direct replies | Use `in_reply_to` fallback |
213
+ | Reply "works" but thread stays open | Replied via REST, never resolved | Run the GraphQL `resolveReviewThread` |
214
+ | `pr` variable rejected by GraphQL | Passed as a string | Use `-F pr="$PR"` (typed) not `-f pr=...` |
215
+ | Count never reaches 0 | New round opened threads | Re-run the loop after each push |
@@ -5,7 +5,7 @@ description: Caching strategy for Next.js (App Router) frontends consuming the C
5
5
 
6
6
  # Next.js Caching Skill
7
7
 
8
- Canonical caching strategy for Silver Side Next.js (App Router) frontends. Use it when touching data
8
+ Canonical caching strategy for Silver Assist Side Next.js (App Router) frontends. Use it when touching data
9
9
  fetching, route segment configs, the asset/page proxy, image config, or the on-demand revalidate
10
10
  route — and when diagnosing "why isn't this page cached / why is it serving stale".
11
11
 
@@ -18,8 +18,14 @@ route — and when diagnosing "why isn't this page cached / why is it serving st
18
18
  ## Core principle
19
19
 
20
20
  **Cache by intent (read vs. mutation), never by the HTTP method.** Some reads must use `POST` because
21
- they take a body (e.g. CCDS `geo-search`). They still have to cache like a `GET`. Mutations must never
22
- cache. Getting this wrong silently turns a static page into a per-request dynamic render.
21
+ they take a body (e.g. CCDS `geo-search`). Their *data* still has to cache like a `GET` send
22
+ `next: { revalidate, tags }`, never `no-store`. Mutations must never cache.
23
+
24
+ **But caching the data is not the same as making the page static.** A POST read caches its response
25
+ cross-request, yet Next.js still renders the *route* dynamically (`private, no-store`). The data-fetch
26
+ cache (layer 2) and the route's static/dynamic classification (layers 1/5) are **independent** — see
27
+ the two sections below. This is the WEB-1069 gotcha and the reason city/community pages needed an edge
28
+ override even though their fetches were already cached.
23
29
 
24
30
  ---
25
31
 
@@ -41,20 +47,54 @@ whole route into **dynamic rendering**, which emits `cache-control: private, no-
41
47
 
42
48
  ---
43
49
 
44
- ## How Next.js 16 actually decides (the rule behind the rule)
45
-
46
- From `next/dist/server/lib/patch-fetch.js`:
47
-
48
- - `fetch` is **not cached by default**. A non-`GET` method is "uncacheable" **only when no explicit
49
- cache config is provided**. Concretely, a POST is auto-no-cached only when it has no `next`/`cache`
50
- options *and* the segment `revalidate` is `0`.
51
- - Therefore **a `POST` IS cached cross-request when you pass an explicit `next: { revalidate }`** (and
52
- the segment isn't `revalidate: 0`). This is why the WordPress GraphQL POST caches fine.
53
- - The **request body is part of the cache key** (`incremental-cache` → `generateCacheKey` hashes
54
- `init.body`), so different filter bodies cache independently and never collide.
55
-
56
- **Implication:** you do **not** need `unstable_cache` or a proxy header override to cache POST reads.
57
- Just send `next: { revalidate, tags }`.
50
+ ## POST reads: the data caches, but the route stays dynamic (the key gotcha)
51
+
52
+ Two independent facts, both true:
53
+
54
+ 1. **The data fetch caches.** From `next/dist/server/lib/patch-fetch.js`: a `fetch` is uncacheable
55
+ only when it has no `next`/`cache` options *and* the segment `revalidate` is `0`. So a POST **with**
56
+ `next: { revalidate }` IS cached cross-request — the **request body is part of the cache key**
57
+ (`generateCacheKey` hashes `init.body`), so different filter bodies cache independently. This is
58
+ real and bounds origin/CCDS load; it is why the WordPress GraphQL POST caches fine.
59
+
60
+ 2. **The route still renders dynamically.** Empirically (WEB-1069, Next 16), a page whose data comes
61
+ from a POST read prerenders as `ƒ` (Dynamic) and serves `cache-control: private, no-store` — **even
62
+ with** `next: { revalidate, tags }` on the fetch. Next treats the POST as request-time data for
63
+ prerendering, so ISR (layer 1) never engages. GET reads do not have this problem — they ISR
64
+ natively (`s-maxage`, `x-nextjs-cache: HIT`).
65
+
66
+ **Implication:** `next: { revalidate, tags }` on a POST read is **necessary** (data cache) but **not
67
+ sufficient** to make the page CDN-cacheable. You must additionally pick a rendering/edge strategy
68
+ (next section). `revalidate` + `tags` alone will NOT flip a POST-read page from `private, no-store` to
69
+ `public`.
70
+
71
+ ## Making a POST-read page cacheable (rendering / edge layer)
72
+
73
+ Pick one (ordered by risk, lowest first):
74
+
75
+ 1. **CDN edge override (current, lowest risk).** `src/proxy.ts` matches the city/community paths and
76
+ sets `Cache-Control: public, s-maxage=2592000, stale-while-revalidate=2592000` — the **same**
77
+ header the ISR pages emit (see `expireTime` under ISR tiers), so the CDN policy is uniform across
78
+ static and dynamic routes. The origin stays dynamic; the CDN caches the deterministic-per-URL
79
+ response. Per-repo regex, no build-time risk — this is what WEB-1069 shipped. **Requires:** a
80
+ per-repo path regex in `src/proxy.ts` that matches exactly the routes you want cached **and**
81
+ those routes must be **public and deterministic per URL** — no auth-, cookie-, or session-
82
+ dependent output. A `public` edge cache serves one stored response to *every* visitor, so a
83
+ personalized route matched here would leak one user's content to others. Never match
84
+ authenticated or personalized paths.
85
+ 2. **`export const dynamic = "force-static"` (interim).** Forces the POST read to `force-cache` and
86
+ prerenders the route as real ISR (confirmed via `prerender-manifest.json`). Removed in WEB-1058
87
+ because a CCDS failure at build time cached a **blank page**; safer now that reads throw on 5xx at
88
+ runtime (a failed revalidation keeps the last good cache), but full prerender is sensitive to
89
+ null/bad records. **Requires:** the page is fully null-safe (every field access guarded); the read
90
+ throws on failure **at build/prerender time too** (not only during runtime revalidation) so a failed
91
+ read aborts generation instead of prerendering a cacheable blank/error page — the exact WEB-1058
92
+ regression; AND the client throws on 5xx at runtime (so ISR keeps the previous version instead of
93
+ caching an error). Re-validate this per repository before adopting the strategy.
94
+ 3. **`cacheComponents: true` + `use cache` (strategic).** Wrap the POST read in `use cache` so its data
95
+ lands in the static shell (PPR). Next-recommended long term; larger migration. **Requires:** a
96
+ deliberate PPR migration for the whole route segment; do **not** mix ad hoc with route-segment
97
+ `export const revalidate` (the two models conflict).
58
98
 
59
99
  ---
60
100
 
@@ -76,7 +116,7 @@ export async function fetchData<T>({
76
116
  method = "GET",
77
117
  body = null,
78
118
  revalidateTag,
79
- revalidate = 86400, // 24h time-based fallback; pair with tags for surgical invalidation
119
+ revalidate = 2592000, // 30d time-based fallback; pair with tags for surgical invalidation
80
120
  mutation = false,
81
121
  }: FetchOptions): Promise<T | null> {
82
122
  const isMutation = mutation || method === "PUT" || method === "DELETE";
@@ -116,18 +156,33 @@ await fetch(WP_API_URL, {
116
156
 
117
157
  ## ISR revalidate tiers
118
158
 
119
- | Route type | `export const revalidate` |
120
- |------------|---------------------------|
121
- | Listing / index | `2592000` (30d) — webhook handles freshness |
122
- | Detail / city / community (`[slug]`) | `86400` (24h) |
123
- | State / advisor landing | `604800` (7d) |
124
- | WP catch-all (`[[...uri]]`) | `604800` (7d) |
159
+ CCDS/WP data changes rarely and is refreshed on-demand via webhooks + tags, so the time-based default
160
+ is intentionally **long** (WEB-1069):
161
+
162
+ | Route / config | Value |
163
+ |----------------|-------|
164
+ | CCDS reads (client default) | `2592000` (30d) |
165
+ | WordPress GraphQL reads (`WP_CACHE_DURATIONS`) | `2592000` (30d) |
166
+ | Listing / detail / city / community / state / landing / WP catch-all | `2592000` (30d) |
167
+ | `next.config` `expireTime` (CDN stale window) | `5184000` (60d) → emits `s-maxage=2592000, stale-while-revalidate=2592000` |
168
+ | `next.config` image `minimumCacheTTL` | `31536000` (1y) — optimized images are content-hashed, never change |
125
169
 
126
170
  - Every cacheable route exports `revalidate`. Do **not** add it to form/personalized routes.
127
- - Prefer `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then
171
+ - **`expireTime` sets the CDN stale window.** Next emits `s-maxage=<revalidate>,
172
+ stale-while-revalidate=<expireTime − revalidate>` for ISR pages, so `expireTime` **must be ≥ the
173
+ largest `revalidate`** or the stale window is invalid (the WEB-1069 bug: `expireTime: 86400` under a
174
+ 30d revalidate). Set it to `5184000` (60d) so a 30d page yields the same header the proxy sets on
175
+ dynamic pages: `s-maxage=2592000, stale-while-revalidate=2592000`.
176
+ - The long default is safe because a missed webhook still self-heals within 30d; use `tags` for
177
+ immediate surgical invalidation. Shorter tiers (24h/7d) are appropriate per-route when the data
178
+ source updates more frequently than the 30d default — for example, sources that update daily or
179
+ weekly. Sources that update less frequently than 30d should keep the default.
180
+ - Default to `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then
128
181
  preserves the last good cache instead of overwriting it with a broken page. Pair with a client that
129
182
  **throws on 5xx at runtime** (so ISR keeps the previous version) but returns an error during the
130
- build phase (so `generateStaticParams` can skip a bad entry without failing the build).
183
+ build phase (so `generateStaticParams` can skip a bad entry without failing the build). `force-static`
184
+ is still a valid POST-read option (see "Making a POST-read page cacheable") **once** the page is
185
+ fully null-safe and the throw-on-5xx guard is in place.
131
186
 
132
187
  ---
133
188
 
@@ -142,17 +197,24 @@ revalidateTag(tag); // granular per-state / per-city tags
142
197
  if (invalidateCDN) await invalidateCloudFrontPaths([path]);
143
198
  ```
144
199
 
200
+ If `invalidateCloudFrontPaths` throws, log the error and surface it to the caller — do **not**
201
+ silently swallow it. The Next.js cache will be fresh but the CDN will serve stale until its TTL
202
+ expires; an operator must retry the CloudFront invalidation manually.
203
+
145
204
  ---
146
205
 
147
206
  ## Anti-patterns
148
207
 
149
- - ❌ `next: method === "GET" ? {...} : { revalidate: 0 }` — leaves POST reads uncached the route
150
- renders dynamically (`private, no-store`). This is the exact regression that broke city pages.
208
+ - ❌ `next: method === "GET" ? {...} : { revalidate: 0 }` — leaves POST reads uncached (origin hit
209
+ every render). Note: even a *correctly* cached POST read still renders the route dynamically — that
210
+ needs an edge override, not just `next` options (see "Making a POST-read page cacheable").
151
211
  - ❌ Bare `fetch(url, { method: "POST", body })` for a read — refetched from origin every render.
152
212
  - ❌ `next: { tags }` with no `revalidate` — holds stale data or doesn't cache at runtime.
153
213
  - ❌ Caching a mutation (`submit`, lead, `PUT`/`DELETE`/`PATCH`).
154
214
  - ❌ Treating `React.cache()` as cross-request caching (it's request-scoped dedup only).
155
- - ❌ `dynamic = "force-static"` on a page whose revalidation can fail (caches a broken page).
215
+ - ❌ `dynamic = "force-static"` on a page that is **not** fully null-safe or lacks throw-on-5xx — a bad
216
+ CCDS record/response at build time caches a broken/blank page (the WEB-1058 regression). It is a
217
+ valid option only once those guards exist.
156
218
  - ❌ Mixing `cacheComponents: true` (`"use cache"`) with route-segment `export const revalidate` —
157
219
  that is a separate, deliberate migration; do not introduce it ad hoc.
158
220
 
@@ -180,5 +242,12 @@ curl -sI https://<host>/<care-type>/<state>/<city> | grep -i cache-control
180
242
  ```
181
243
 
182
244
  In the build output, `●` (or "Static"/"ISR") means prerendered; `ƒ` ("Dynamic") means it renders per
183
- request a POST-read page should be the former. `next.config` `logging.fetches.fullUrl: true` shows
184
- per-fetch cache decisions in dev.
245
+ request. Which one to expect depends on the strategy chosen in "Making a POST-read page cacheable":
246
+ strategy **2** (`force-static`) prerenders fully, so it shows `●`; strategy **3** (`use cache` / PPR)
247
+ shows `●` only when the whole route is static — if dynamic holes remain it renders as a **partial
248
+ prerender** and Next marks it `◐` (partial), which is still correct output, so do not reject it.
249
+ Strategy **1** (CDN edge override) intentionally leaves the route as `ƒ` — the page is CDN-cached at
250
+ the edge even though Next classifies it as dynamic. Because the symbol alone is ambiguous across
251
+ strategies, verify with the `curl -sI … | grep -i cache-control` check above and defer to the
252
+ installed Next.js version's build-output legend rather than the symbol. `next.config`
253
+ `logging.fetches.fullUrl: true` shows per-fetch cache decisions in dev.
@@ -41,7 +41,7 @@ plugin-slug/
41
41
 
42
42
  ### Key Principles
43
43
 
44
- 1. **Unified scripts** — `build-release.sh` and `release.yml` are identical across all 6 plugins.
44
+ 1. **Unified scripts** — `build-release.sh` and `release.yml` are structurally identical across all 6 plugins. The only permitted per-plugin variation is the Silver Assist package validation block at the end of `build-release.sh` (see [Validation Checks](#validation-checks)), which may omit checks for packages the plugin does not depend on.
45
45
  2. **Selective copy** — Only runtime files go into the ZIP (no tests, docs, dev configs).
46
46
  3. **Auto-detection** — The build script finds the main plugin file, slug, and version automatically.
47
47
  4. **Checksums** — Every ZIP gets MD5 and SHA-256 checksums.
@@ -80,13 +80,21 @@ git push origin v1.3.6
80
80
 
81
81
  ### Step 1: Update All Versions
82
82
 
83
- Use whichever version script the plugin has:
83
+ **Selection is driven by which script the plugin ships — each plugin ships exactly one variant**,
84
+ and `release.yml` auto-detects the same way (it runs `update-version.sh` if present, else
85
+ `update-version-simple.sh`). Manual runs and CI therefore stay consistent *because only one script
86
+ exists per plugin*; never ship both, or CI's preference for `update-version.sh` would silently
87
+ diverge from a manual `update-version-simple.sh` run.
88
+
89
+ - Full variant (`update-version.sh`): `acf-clone-fields`, `silver-assist-post-revalidate`, `silver-assist-security`.
90
+ - Simple variant (`update-version-simple.sh`): `contact-form-to-api`, `leadgen-app-form`, `nextjs-graphql-hooks`.
91
+ - New plugin: ship **only** `update-version-simple.sh` unless it genuinely needs the full variant.
84
92
 
85
93
  ```bash
86
94
  # Full variant (acf-clone-fields, silver-assist-post-revalidate, silver-assist-security)
87
95
  ./scripts/update-version.sh 1.2.0 --no-confirm --force
88
96
 
89
- # Simple variant (contact-form-to-api, leadgen-app-form, nextjs-graphql-hooks)
97
+ # Simple variant (contact-form-to-api, leadgen-app-form, nextjs-graphql-hooks, new plugins by default)
90
98
  ./scripts/update-version-simple.sh 1.2.0 --no-confirm --force
91
99
  ```
92
100
 
@@ -165,7 +173,7 @@ gh run watch <run-id> --exit-status
165
173
 
166
174
  ## Unified Build Script (`scripts/build-release.sh`)
167
175
 
168
- This script is **identical** across all Silver Assist plugins. When setting up a new plugin, copy it verbatim.
176
+ This script is **structurally identical** across all Silver Assist plugins. When setting up a new plugin, copy it and change only the dependency-specific asset-validation checks a plugin does not need — the single allowed per-plugin variation, documented below (see the Silver Assist package-asset table and [Key Principles](#key-principles)). Everything else must remain identical; copying it verbatim for a plugin that lacks one of those dependencies would leave an invalid validation check that fails its release build.
169
177
 
170
178
  ### What It Does
171
179
 
@@ -406,7 +414,7 @@ The build script validates these Silver Assist package assets exist in the final
406
414
  | Settings Hub CSS | `vendor/silverassist/wp-settings-hub/assets/css/settings-hub.css` |
407
415
  | GitHub Updater JS | `vendor/silverassist/wp-github-updater/assets/js/check-updates.js` |
408
416
 
409
- If the plugin does NOT use these packages, remove the corresponding validation checks.
417
+ If the plugin does NOT depend on one of these packages, omit only that package's validation check when creating the plugin's `build-release.sh`. This is the single allowed per-plugin variation of the script (see [Key Principles](#key-principles)); everything else must remain identical to the template above.
410
418
 
411
419
  ---
412
420
 
@@ -429,6 +437,14 @@ on:
429
437
  description: 'Version to release (e.g. 1.2.0). Leave empty to use plugin file version.'
430
438
  required: false
431
439
 
440
+ # Note: `workflow_dispatch` still performs a REAL release — the "Create GitHub Release"
441
+ # step below publishes the release and creates the `v<version>` tag if it does not
442
+ # already exist. It is NOT a dry run. Use it only to release a version whose source is
443
+ # already committed on the checked-out ref: the "Update version" step edits files inside
444
+ # the CI checkout without committing them back, so an uncommitted bump would ship a ZIP
445
+ # that differs from the repository. For a true dry run, run the build steps in a separate
446
+ # non-publishing workflow that omits the "Create GitHub Release" step.
447
+
432
448
  permissions:
433
449
  contents: write
434
450
 
@@ -457,6 +473,10 @@ jobs:
457
473
  else
458
474
  VERSION=$(grep -o 'Version: [0-9]\+\.[0-9]\+\.[0-9]\+' *.php 2>/dev/null | head -1 | cut -d' ' -f2)
459
475
  fi
476
+ if [ -z "$VERSION" ]; then
477
+ echo "::error::Could not detect version: no tag, no manual input, and no Version: header found in root PHP files."
478
+ exit 1
479
+ fi
460
480
  echo "version=$VERSION" >> $GITHUB_OUTPUT
461
481
  echo "🏷️ Version: $VERSION"
462
482
 
@@ -75,6 +75,24 @@ jest.mock('stripe', () => ({
75
75
 
76
76
  ## Server Action Testing Pattern
77
77
 
78
+ ### Mocking `next/navigation`
79
+
80
+ Server Actions that call `redirect()` or use routing helpers require `next/navigation` to be mocked, since it is not available in the Jest test environment.
81
+
82
+ ```typescript
83
+ // Mock next/navigation BEFORE imports.
84
+ // redirect() must THROW like the real implementation — Next signals a redirect by throwing
85
+ // NEXT_REDIRECT to terminate the action's control flow. A plain `jest.fn()` no-op would let
86
+ // code after redirect() keep running, producing outcomes impossible in production. Throw a
87
+ // sentinel and assert on it (e.g. `await expect(action()).rejects.toThrow(/NEXT_REDIRECT/)`).
88
+ jest.mock('next/navigation', () => ({
89
+ redirect: jest.fn((url) => {
90
+ throw new Error(`NEXT_REDIRECT: ${url}`);
91
+ }),
92
+ useRouter: jest.fn(() => ({ push: jest.fn() })),
93
+ }));
94
+ ```
95
+
78
96
  ### Basic Server Action Test
79
97
 
80
98
  ```typescript
@@ -113,6 +131,26 @@ describe('submitWizardToSalesforce', () => {
113
131
  expect(result.success).toBe(true);
114
132
  expect(result.leadId).toBeDefined();
115
133
  });
134
+
135
+ it('should return failure state when the API rejects', async () => {
136
+ // Simulate an error thrown by the mocked dependency
137
+ mockCreateLead.mockRejectedValue(new Error('API error'));
138
+
139
+ const formData = new FormData();
140
+ formData.append('email', 'test@example.com');
141
+ formData.append('firstName', 'John');
142
+
143
+ const result = await submitWizardToSalesforce(
144
+ { success: false, message: '', timestamp: 0 },
145
+ formData
146
+ );
147
+
148
+ // Assert the error path for THIS action: it catches the API failure and returns a
149
+ // failure state. Match the action's own contract — actions that instead throw (e.g.
150
+ // via redirect(), see the mock above) must be asserted with `rejects.toThrow(...)`.
151
+ expect(result.success).toBe(false);
152
+ expect(result.message).toContain('error');
153
+ });
116
154
  });
117
155
  ```
118
156