Skip to content
Merged
200 changes: 194 additions & 6 deletions .github/workflows/rc-docs-sync.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# .github/workflows/rc-docs-sync.yml
#
# Lives in: Yoast/developer
# Purpose: once a day, check each opted-in product repo for RC tags we haven't
# Purpose: every weekday, check each opted-in product repo for RC tags we haven't
# processed yet. For each new RC, ask a Claude agent whether the
# developer-portal docs need updates; if so, open one PR per affected
# feature area (per AGENT_MAP.md).
Expand All @@ -25,7 +25,7 @@ name: RC docs sync

on:
schedule:
- cron: '0 6 * * *' # daily at 06:00 UTC
- cron: '0 6 * * 1-5' # 06:00 UTC, Monday through Friday only
workflow_dispatch:
inputs:
product:
Expand Down Expand Up @@ -275,11 +275,27 @@ jobs:
rb="${bundle_dir}/${name}"
mkdir -p "$rb"
git -C "sources/${name}" diff "${{ matrix.item.prev_release }}..${{ matrix.item.rc_tag }}" > "${rb}/rc.diff.full"
# `-I<regex>` drops hunks whose *every* changed line matches at least one
# of the regexes. Used here to strip the per-RC version-string bumps
# (PHP file header, WPSEO_VERSION define, package.json's "version" and
# "pluginVersion" fields, CURRENT_RELEASE / MINIMUM_SUPPORTED constants)
# so the agent doesn't have to read them and dismiss them as noise each
# run. `rc.diff.full` keeps them as a cross-check.
git -C "sources/${name}" diff "${{ matrix.item.prev_release }}..${{ matrix.item.rc_tag }}" \
-I' \* Version: ' \
-I'WPSEO_VERSION' \
-I'"version":' \
-I'"pluginVersion":' \
-I'CURRENT_RELEASE' \
-I'MINIMUM_SUPPORTED' \
-- \
':(exclude)tests' \
':(exclude)**/__tests__' \
':(exclude)**/__snapshots__' \
':(exclude)**/__tests__/**' \
':(exclude)**/__snapshots__/**' \
':(exclude)**/spec/**' \
':(exclude)**/*.test.*' \
':(exclude)**/*.spec.*' \
':(exclude)**/*.stories.*' \
':(exclude)**/*.lock' \
':(exclude)languages' \
':(exclude).github' \
Expand Down Expand Up @@ -317,16 +333,183 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
issue='${{ matrix.item.tracking_issue }}'
product='${{ matrix.item.product }}'
rc_tag='${{ matrix.item.rc_tag }}'
base_version="${rc_tag%-RC*}"
gh issue comment '${{ matrix.item.tracking_issue }}' --body "<!-- rc-docs-sync:v1 product=${{ matrix.item.product }} rc_tag=${rc_tag} -->
# Idempotency: skip if a marker for this (product, rc_tag) already exists.
if gh issue view "$issue" --json comments --jq '.comments[].body' \
| grep -Eq "<!--[[:space:]]*rc-docs-sync:v1[[:space:]]+product=${product}[[:space:]]+rc_tag=${rc_tag}[[:space:]]*-->"; then
echo "Marker for ${product} ${rc_tag} already on issue #${issue}; skipping no-op summary."
exit 0
fi
gh issue comment "$issue" --body "<!-- rc-docs-sync:v1 product=${product} rc_tag=${rc_tag} -->

**${{ matrix.item.display_name }} ${base_version}** (RC \`${rc_tag}\`) — no doc changes needed.
Filtered diff is empty (only tests/translations/lockfiles changed).
Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

- name: Invoke Claude agent
# ---------------------------------------------------------------------
# Pre-agent fast-path: when the filtered diff is non-empty but contains
# no new public surface (no new register_rest_route / WP_CLI::add_command
# / apply_filters / do_action calls referencing undocumented symbols),
# post a "no doc changes" marker and skip the Claude agent invocation
# entirely. Catches the common case where a small RC contains only
# internal refactors / JS-only changes / version bumps.
#
# Risk: misses behavior-only changes that don't introduce new symbols.
# The agent prompt flags these as uncertain anyway; if this becomes a
# missed-doc source, tighten the heuristic or invoke the agent on a
# cheaper model as a spot-check.
# ---------------------------------------------------------------------
- name: Detect new public surface in filtered diff
id: detect
if: steps.bundle.outputs.any_content == 'true'
env:
PRODUCT: ${{ matrix.item.product }}
RC_TAG: ${{ matrix.item.rc_tag }}
DISPLAY_NAME: ${{ matrix.item.display_name }}
BUNDLE_DIR: ${{ github.workspace }}/${{ steps.bundle.outputs.bundle_dir }}
PREV_RELEASE: ${{ matrix.item.prev_release }}
PREV_KIND: ${{ matrix.item.prev_kind }}
WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
python3 - <<'PY'
import glob, os, re, sys

bundle_dir = os.environ['BUNDLE_DIR']
rc_tag = os.environ['RC_TAG']
display_name = os.environ['DISPLAY_NAME']
prev_release = os.environ['PREV_RELEASE']
prev_kind = os.environ['PREV_KIND']
product = os.environ['PRODUCT']
run_url = os.environ['WORKFLOW_RUN_URL']

# symbol-index.txt entries are quoted (e.g. 'wpseo_foo'); strip quotes.
symbol_index = set()
si_path = os.path.join(bundle_dir, "symbol-index.txt")
if os.path.exists(si_path):
with open(si_path) as f:
for line in f:
sym = line.strip().strip("'\"")
if sym:
symbol_index.add(sym)

HOOK_RE = re.compile(r"(?:apply_filters|do_action)\s*\(\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]")
ROUTE_RE = re.compile(r"register_rest_route\s*\(")
CLI_RE = re.compile(r"WP_CLI::add_command\s*\(")

new_routes, new_cli, new_hook_symbols = [], [], set()
diff_files = sorted(glob.glob(os.path.join(bundle_dir, "*", "rc.diff.filtered")))
for path in diff_files:
with open(path) as f:
for line in f:
if not line.startswith('+') or line.startswith('+++'):
continue
body = line[1:]
if ROUTE_RE.search(body):
new_routes.append(body.strip())
if CLI_RE.search(body):
new_cli.append(body.strip())
for m in HOOK_RE.finditer(body):
sym = m.group(1)
if sym not in symbol_index:
new_hook_symbols.add(sym)

has_public = bool(new_routes or new_cli or new_hook_symbols)
with open(os.environ['GITHUB_OUTPUT'], 'a') as gho:
gho.write(f"has_public_surface={'true' if has_public else 'false'}\n")

if has_public:
print("Public surface detected — agent will be invoked.")
if new_routes:
print(f" new register_rest_route lines: {len(new_routes)}")
for l in new_routes[:5]: print(f" {l}")
if new_cli:
print(f" new WP_CLI::add_command lines: {len(new_cli)}")
for l in new_cli[:5]: print(f" {l}")
if new_hook_symbols:
print(f" new (undocumented) hook symbols: {sorted(new_hook_symbols)}")
sys.exit(0)

# No new public surface — assemble the fast-path comment body.
filtered_total = 0
stat_entries = []
STAT_RE = re.compile(r"^\s*(\S.*?)\s*\|\s*(\d+)")
for path in diff_files:
with open(path) as f:
filtered_total += sum(1 for _ in f)
repo_name = os.path.basename(os.path.dirname(path))
stat_path = os.path.join(bundle_dir, repo_name, "rc.diff.stat")
if os.path.exists(stat_path):
with open(stat_path) as f:
for line in f:
m = STAT_RE.match(line)
if m:
stat_entries.append((m.group(1), int(m.group(2))))
top = sorted(stat_entries, key=lambda p: -p[1])[:8]

base_version = re.sub(r"-RC\d+$", "", rc_tag)
prev_desc = "incremental RC delta" if prev_kind == "rc" else "full release cycle vs. stable"

body = [
f"<!-- rc-docs-sync:v1 product={product} rc_tag={rc_tag} -->",
f"## {display_name} {base_version}",
"",
f"- **RC tag**: `{rc_tag}`",
f"- **Previous release**: `{prev_release}` ({prev_desc})",
f"- **Filtered diff size**: {filtered_total} lines",
f"- **Symbol index**: {len(symbol_index)} symbols currently documented; **0 new public symbols** in this diff",
"",
"### Outcome: 0 PRs opened (fast-path)",
"",
"The filtered diff contains no new `apply_filters` / `do_action` calls referencing undocumented symbols, no `register_rest_route(...)` registrations, and no `WP_CLI::add_command(...)` calls. Per the deterministic pre-agent fast-path, this RC introduces no public API surface and the Claude agent was not invoked.",
"",
]
if top:
body.append("<details><summary>Top changed files</summary>")
body.append("")
body.append("```")
for path, count in top:
body.append(f" {path} | {count}")
body.append("```")
body.append("")
body.append("</details>")
body.append("")
body.append(f"_Fast-path decision — Claude agent skipped. Workflow run: {run_url}_")

out_path = os.path.join(bundle_dir, "fast-path-comment.md")
with open(out_path, "w") as f:
f.write("\n".join(body) + "\n")
print(f"No public surface detected — fast-path comment written to {out_path}")
print(f" filtered_total={filtered_total}, symbols_known={len(symbol_index)}")
PY

- name: Post fast-path marker (no new public surface)
if: steps.bundle.outputs.any_content == 'true' && steps.detect.outputs.has_public_surface == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
BUNDLE_DIR: ${{ github.workspace }}/${{ steps.bundle.outputs.bundle_dir }}
run: |
set -euo pipefail
issue='${{ matrix.item.tracking_issue }}'
product='${{ matrix.item.product }}'
rc_tag='${{ matrix.item.rc_tag }}'
# Idempotency: if a marker for this (product, rc_tag) already exists on the
# tracking issue (e.g. from a prior scheduled run, or a workflow_dispatch
# backfill), don't post a duplicate. Same dedup pattern as the safety-net
# step below.
if gh issue view "$issue" --json comments --jq '.comments[].body' \
| grep -Eq "<!--[[:space:]]*rc-docs-sync:v1[[:space:]]+product=${product}[[:space:]]+rc_tag=${rc_tag}[[:space:]]*-->"; then
echo "Marker for ${product} ${rc_tag} already on issue #${issue}; skipping fast-path comment."
exit 0
fi
gh issue comment "$issue" --body-file "${BUNDLE_DIR}/fast-path-comment.md"

- name: Invoke Claude agent
if: steps.bundle.outputs.any_content == 'true' && steps.detect.outputs.has_public_surface == 'true'
uses: anthropics/claude-code-action@v1
env:
PRODUCT: ${{ matrix.item.product }}
Expand Down Expand Up @@ -385,6 +568,11 @@ jobs:
--max-turns 100
--model claude-sonnet-4-6

# Safety net runs whenever the bundle was non-empty (any_content == 'true').
# The step body deduplicates: if any earlier step already posted the marker
# (the agent itself, or the fast-path marker step), it exits early. This
# also catches the edge case where the detect step crashes — the step body
# then sees no marker exists and posts the fallback.
- name: Ensure marker comment exists (safety net)
if: always() && steps.bundle.outputs.any_content == 'true'
env:
Expand Down
4 changes: 2 additions & 2 deletions AGENT_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,8 @@ No currently-listed product has more than one source repo. If one is ever added
### `ai`
- **Products**: wordpress-seo, wordpress-seo-premium
- **Docs paths**: `docs/features/ai/**`
- **Source paths** (wordpress-seo): `src/ai/**` (current convention — all new AI feature code lives here), `src/ai-*/**` (legacy, being migrated under `src/ai/`; safe to drop once the migration completes), `src/generators/ai*`, `src/integrations/ai*`
- **Source paths** (wordpress-seo-premium): `src/ai/**`
- **Source paths** (wordpress-seo): `src/ai/**` (current convention — all new AI feature code lives here), `src/ai-*/**` (legacy, being migrated under `src/ai/`; safe to drop once the migration completes), `src/generators/ai*`, `src/integrations/ai*`, `packages/js/src/ai-*/**` (frontend code for AI features lives in per-feature `ai-<slug>/` directories under the JS package; e.g. `ai-content-planner/`)
- **Source paths** (wordpress-seo-premium): `src/ai/**`, `packages/js/src/ai-*/**`
- **Symbol namespaces**: `wpseo_ai_*`
- **Typical triggers**: new AI error code; new AI feature exposing a filter; change to request/retry behavior documented in `ai-errors.md`.

Expand Down
Loading