Skip to content

RC docs sync

RC docs sync #51

Workflow file for this run

# .github/workflows/rc-docs-sync.yml
#
# Lives in: Yoast/developer
# 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).
#
# Why no GitHub App or PAT: product repos are public → anonymous cloning works;
# writes to Yoast/developer (branches, PRs, issue comments) use GITHUB_TOKEN.
# The only secret required is ANTHROPIC_API_KEY.
#
# State management: because `main` is protected, this workflow never writes to
# `main`. Instead, the per-product tracking issue's comments serve as state.
# Every run-summary comment starts with a machine-readable marker:
# <!-- rc-docs-sync:v1 product=<slug> rc_tag=<tag> -->
# The workflow scans the tracking issue's comments to find the latest processed
# RC per product, then processes any newer RC tags.
#
# Validation: Cloudflare Pages auto-deploys a preview on every PR push, acting
# as the per-PR check (broken Docusaurus links fail the deploy). The agent
# doesn't re-run `yarn build` locally; it trusts CF Pages for the final word.
name: RC docs sync
on:
schedule:
- cron: '0 6 * * 1-5' # 06:00 UTC, Monday through Friday only
workflow_dispatch:
inputs:
product:
description: 'Product slug (must match AGENT_MAP.md; e.g. wordpress-seo). Leave blank to sweep all opted-in products.'
required: false
type: string
rc_tag:
description: 'Specific RC tag to process (e.g. 27.5-RC1). Bypasses state gating for that one product+tag. Useful for backfill.'
required: false
type: string
concurrency:
group: rc-docs-sync
cancel-in-progress: false
permissions:
contents: write # push per-RC doc branches (NOT main — main is protected)
pull-requests: write # open and label PRs
issues: write # comment on tracking issue(s)
id-token: write # required by anthropics/claude-code-action for OIDC auth
jobs:
# =====================================================================
# Job 1: resolve which (product, rc_tag) pairs need processing.
# Outputs a JSON queue that job 2 fans out over with a matrix.
# =====================================================================
resolve:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
queue: ${{ steps.queue.outputs.queue_json }}
count: ${{ steps.queue.outputs.count }}
env:
TRACKING_ISSUE_WORDPRESS_SEO: ${{ vars.TRACKING_ISSUE_WORDPRESS_SEO }}
steps:
- name: Check out Yoast/developer
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Resolve work queue
id: queue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_PRODUCT: ${{ github.event.inputs.product }}
INPUT_RC_TAG: ${{ github.event.inputs.rc_tag }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
python3 - <<'PY' > queue.json
import json, os, re, subprocess, sys, urllib.request
# --- opted-in products for this phase of rollout ---
PRODUCTS = {
"wordpress-seo": {
"display_name": "Yoast SEO",
"repos": ["Yoast/wordpress-seo"],
"tracking_issue_var": "TRACKING_ISSUE_WORDPRESS_SEO",
},
}
MARKER_RE = re.compile(
r"<!--\s*rc-docs-sync:v1\s+product=(?P<product>\S+)\s+rc_tag=(?P<rc_tag>\S+)\s*-->"
)
RC_TAG_RE = re.compile(r"^\d+\.\d+(?:\.\d+)?-RC\d+$")
STABLE_RE = re.compile(r"^\d+\.\d+(?:\.\d+)?$")
def sort_key(tag):
m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?(?:-RC(\d+))?$", tag)
if not m:
return (0, 0, 0, 0)
major, minor, patch, rc = m.groups()
return (int(major), int(minor), int(patch or 0), int(rc) if rc else 99999)
def gh_json(args):
cp = subprocess.run(["gh"] + args, check=True, capture_output=True, text=True)
return json.loads(cp.stdout)
def fetch_processed_markers(issue_number, product_slug):
"""Return list of RC tags processed for this product, in the document order
they appear in the tracking issue's comments (oldest first)."""
data = gh_json(["issue", "view", str(issue_number), "--json", "comments"])
out = []
for c in data.get("comments", []):
for m in MARKER_RE.finditer(c.get("body", "")):
if m.group("product") == product_slug:
out.append(m.group("rc_tag"))
return out
def base_version(tag):
"""Strip the -RC<n> suffix from a tag. 27.6-RC2 -> 27.6; 27.1.1-RC3 -> 27.1.1."""
return re.sub(r"-RC\d+$", "", tag)
def fetch_tags(repo):
url = f"https://api.github.com/repos/{repo}/tags?per_page=100"
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req) as r:
return [t["name"] for t in json.load(r)]
input_product = os.environ.get("INPUT_PRODUCT") or ""
input_rc_tag = os.environ.get("INPUT_RC_TAG") or ""
queue, seed_actions = [], []
products_to_sweep = [input_product] if input_product else list(PRODUCTS.keys())
for slug in products_to_sweep:
if slug not in PRODUCTS:
print(f"skipping unknown product {slug}", file=sys.stderr); continue
product = PRODUCTS[slug]
tracking_issue = os.environ.get(product["tracking_issue_var"])
if not tracking_issue:
print(f"missing repo variable {product['tracking_issue_var']}; cannot process {slug}", file=sys.stderr)
continue
main_repo = product["repos"][0]
all_tags = fetch_tags(main_repo)
rc_tags = [t for t in all_tags if RC_TAG_RE.match(t)]
stable_tags = [t for t in all_tags if STABLE_RE.match(t)]
processed_markers = fetch_processed_markers(tracking_issue, slug)
# Per-product state — must be initialized for every iteration of the
# outer loop, and used by both the dispatch and the scheduled paths.
superseded_by_latest = {}
if input_rc_tag and input_product == slug:
if input_rc_tag not in rc_tags:
print(f"{input_rc_tag} not found as RC in {main_repo}", file=sys.stderr); sys.exit(2)
rcs_to_process = [input_rc_tag]
else:
if not processed_markers:
rc_tags_sorted = sorted(rc_tags, key=sort_key)
seed_rc = rc_tags_sorted[-1] if rc_tags_sorted else None
if seed_rc:
seed_actions.append({
"issue": tracking_issue, "product": slug,
"rc_tag": seed_rc, "display_name": product["display_name"],
})
continue
# Use the max processed marker by sort_key, not the last in
# document order. Superseded markers are posted *after* their
# superseding RC's marker, so document order would mis-identify
# an older (superseded) RC as the high-water mark and re-queue
# an already-processed RC. Manual backfills via workflow_dispatch
# (which can post markers for arbitrarily-old RCs) have the same
# property.
last_key = sort_key(max(processed_markers, key=sort_key))
rcs_to_process = sorted([t for t in rc_tags if sort_key(t) > last_key], key=sort_key)
# Within each base version, collapse to just the latest unprocessed
# RC and attach the skipped ones as `superseded_rcs` on that queue
# entry. The matrix step that processes the latest RC will post
# the superseded markers itself, AFTER its own marker is in place
# — that way a clone/bundle failure doesn't leave the tracking
# issue with superseded markers that incorrectly advance state
# past unprocessed RCs.
#
# Collapsing is *per base version*: if two cycles' RCs are
# simultaneously unprocessed (e.g. workflow missed 27.8-RC1 and
# 27.9-RC1), each base still gets its own queue entry diffed
# against its own previous-stable. Explicit workflow_dispatch
# with input_rc_tag bypasses this collapse.
if len(rcs_to_process) > 1:
by_base = {}
for rc in rcs_to_process:
by_base.setdefault(base_version(rc), []).append(rc)
collapsed = []
for base, group in by_base.items():
group_sorted = sorted(group, key=sort_key)
latest_in_group = group_sorted[-1]
superseded_by_latest[latest_in_group] = group_sorted[:-1]
collapsed.append(latest_in_group)
rcs_to_process = sorted(collapsed, key=sort_key)
for rc_tag in rcs_to_process:
# Prefer the most recent already-processed RC of the same base version as
# the diff base — that way iterative RCs (RC2, RC3, ...) only see the
# incremental delta from the last RC, not the whole release cycle.
# Falls back to the latest stable release before this RC when no prior
# same-base RC has been processed (first RC of a new base, or backfill
# against an RC older than anything previously processed).
base = base_version(rc_tag)
same_base_processed = [
t for t in processed_markers
if base_version(t) == base
and t != rc_tag
and sort_key(t) < sort_key(rc_tag)
]
if same_base_processed:
prev = sorted(same_base_processed, key=sort_key)[-1]
prev_kind = "rc"
else:
prev_candidates = [t for t in stable_tags if sort_key(t) <= sort_key(rc_tag)]
if not prev_candidates:
print(f"no previous stable for {rc_tag}; skipping", file=sys.stderr); continue
prev = sorted(prev_candidates, key=sort_key)[-1]
prev_kind = "stable"
queue.append({
"product": slug,
"display_name": product["display_name"],
"repos": product["repos"],
"rc_tag": rc_tag,
"prev_release": prev,
"prev_kind": prev_kind,
"tracking_issue": tracking_issue,
"superseded_rcs": superseded_by_latest.get(rc_tag, []),
})
print(json.dumps({"queue": queue, "seeds": seed_actions}))
PY
cat queue.json
# Emit queue as compact JSON for matrix consumption.
echo "queue_json=$(jq -c '.queue' queue.json)" >> "$GITHUB_OUTPUT"
echo "count=$(jq '.queue | length' queue.json)" >> "$GITHUB_OUTPUT"
echo "seed_count=$(jq '.seeds | length' queue.json)" >> "$GITHUB_OUTPUT"
- name: Seed first-run tracking issues
if: steps.queue.outputs.seed_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
jq -c '.seeds[]' queue.json | while read -r seed; do
issue=$(echo "$seed" | jq -r .issue)
product=$(echo "$seed" | jq -r .product)
rc_tag=$(echo "$seed" | jq -r .rc_tag)
display=$(echo "$seed" | jq -r .display_name)
gh issue comment "$issue" --body "<!-- rc-docs-sync:v1 product=${product} rc_tag=${rc_tag} -->
**First-run seed for ${display}** — RC tag \`${rc_tag}\` recorded as the baseline. No historical RCs will be processed automatically. To backfill a specific RC, use \`workflow_dispatch\` with \`product=${product}\` and the desired \`rc_tag\`."
done
- name: Note when queue is empty
if: steps.queue.outputs.count == '0'
run: echo "No new RC tags to process this run."
# =====================================================================
# Job 2: per-RC processing. Fans out as a matrix over the resolved queue.
# Each matrix entry: clone source repo(s), build bundle, invoke Claude agent.
# max-parallel: 1 to avoid PR-creation races on the same area branches.
# =====================================================================
process:
needs: resolve
if: needs.resolve.outputs.count != '0'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 1
matrix:
item: ${{ fromJSON(needs.resolve.outputs.queue) }}
steps:
- name: Check out Yoast/developer
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Clone source repo(s) at RC and previous release
run: |
set -euo pipefail
mkdir -p sources
for repo in $(echo '${{ toJSON(matrix.item.repos) }}' | jq -r '.[]'); do
name="${repo##*/}"
git clone --depth 50 --no-single-branch "https://github.com/${repo}.git" "sources/${name}"
git -C "sources/${name}" fetch --depth 200 origin \
"refs/tags/${{ matrix.item.rc_tag }}:refs/tags/${{ matrix.item.rc_tag }}" \
"refs/tags/${{ matrix.item.prev_release }}:refs/tags/${{ matrix.item.prev_release }}" || true
done
- name: Build diff bundle, symbol index, and changelog
id: bundle
run: |
set -euo pipefail
bundle_dir="bundle/${{ matrix.item.product }}/${{ matrix.item.rc_tag }}"
mkdir -p "$bundle_dir"
for repo in $(echo '${{ toJSON(matrix.item.repos) }}' | jq -r '.[]'); do
name="${repo##*/}"
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)**/spec/**' \
':(exclude)**/*.test.*' \
':(exclude)**/*.spec.*' \
':(exclude)**/*.stories.*' \
':(exclude)**/*.lock' \
':(exclude)languages' \
':(exclude).github' \
':(exclude)composer.lock' \
':(exclude)yarn.lock' \
':(exclude)package-lock.json' \
> "${rb}/rc.diff.filtered"
git -C "sources/${name}" diff --stat "${{ matrix.item.prev_release }}..${{ matrix.item.rc_tag }}" > "${rb}/rc.diff.stat"
for f in readme.txt CHANGELOG.md changelog.md changelog.txt; do
if git -C "sources/${name}" show "${{ matrix.item.rc_tag }}:${f}" > "${rb}/changelog.source" 2>/dev/null; then
break
fi
done
done
# Symbol index from the current docs/ tree.
(
grep -rohE "'wpseo_[a-zA-Z0-9_]+'" docs/ || true
grep -rohE "'Yoast\\\\WP\\\\SEO\\\\[a-zA-Z0-9_\\\\]+'" docs/ || true
grep -rohE "'duplicate_post_[a-zA-Z0-9_]+'" docs/ || true
) | sort -u > "${bundle_dir}/symbol-index.txt"
# Decide whether to invoke the agent at all.
any_content=false
for f in ${bundle_dir}/*/rc.diff.filtered; do
[ -s "$f" ] && any_content=true
done
echo "bundle_dir=${bundle_dir}" >> "$GITHUB_OUTPUT"
echo "any_content=${any_content}" >> "$GITHUB_OUTPUT"
- name: Post no-op summary if filtered diff is empty
if: steps.bundle.outputs.any_content == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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*}"
# 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 }}"
# ---------------------------------------------------------------------
# Pre-fetch open documentation PRs in this repo so the agent can defer
# to them instead of opening duplicates. A pre-doc PR is a human-authored
# PR that documents an upcoming feature before its RC ships (e.g. #391
# documented the wp yoast auth CLI namespace before 27.7-RC1). When the
# RC lands and the agent sees the same source files, it should comment
# on the existing PR rather than open a competing one.
#
# Filter: open, not labeled rc-doc-sync (the agent's own past PRs),
# updated within the last 30 days, touching docs/ or sidebars.js.
# ---------------------------------------------------------------------
- name: Fetch recent open documentation PRs in this repo
id: open_prs
if: steps.bundle.outputs.any_content == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUNDLE_DIR: ${{ github.workspace }}/${{ steps.bundle.outputs.bundle_dir }}
run: |
set -euo pipefail
cutoff=$(date -u -d '30 days ago' +%Y-%m-%d)
gh pr list -R "${{ github.repository }}" \
--search "is:open is:pr -label:rc-doc-sync updated:>=${cutoff}" \
--limit 50 \
--json number,title,headRefName,updatedAt,body,files,labels \
| jq '[ .[]
| select((.files // []) | any((.path | startswith("docs/")) or .path == "sidebars.js"))
| {number, title, headRefName, updatedAt,
files: [.files[].path],
body: .body[0:1500] }
]' \
> "${BUNDLE_DIR}/open-doc-prs.json"
echo "Fetched $(jq length "${BUNDLE_DIR}/open-doc-prs.json") open doc PR(s) updated since ${cutoff}"
# ---------------------------------------------------------------------
# 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 }}
RC_TAG: ${{ matrix.item.rc_tag }}
DISPLAY_NAME: ${{ matrix.item.display_name }}
BUNDLE_DIR: ${{ github.workspace }}/${{ steps.bundle.outputs.bundle_dir }}
TRACKING_ISSUE: ${{ matrix.item.tracking_issue }}
PREV_RELEASE: ${{ matrix.item.prev_release }}
PREV_KIND: ${{ matrix.item.prev_kind }} # 'stable' or 'rc' — see Resolve work queue
GH_REPO: ${{ github.repository }}
WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# The agent reads its full instructions from the prompt file in the
# repo. Keeping the prompt in-tree (not inlined here) means the
# workflow stays small and the prompt is reviewable as a separate
# artifact.
prompt: |
Read `.github/claude-agent/run.md` in this repository and execute the orchestration described there for the current RC.
Environment variables already set for you:
- `PRODUCT` (e.g. `wordpress-seo`)
- `RC_TAG` (e.g. `27.5-RC1`)
- `DISPLAY_NAME` (e.g. `Yoast SEO`)
- `BUNDLE_DIR` — absolute path to this run's bundle directory; contains `rc.diff.filtered`, `rc.diff.full`, `rc.diff.stat`, `changelog.source`, `symbol-index.txt`, organized as `$BUNDLE_DIR/<source-repo>/...`.
- `TRACKING_ISSUE` — numeric issue id where the run-summary comment must be posted.
- `PREV_RELEASE` — the source-repo tag the diff was computed against. May be a stable release (e.g. `27.5`) or a prior RC of the same base version (e.g. `27.6-RC1`); `PREV_KIND` is `stable` or `rc` accordingly. When it's `rc`, expect the diff to be small (incremental delta vs. the previous RC of this cycle); when it's `stable`, the diff is the full release cycle.
- `WORKFLOW_RUN_URL` — link to this workflow run; include in the PR body for reviewer context.
When the prompt instructs you to post comments or create PRs, use `gh` (already authenticated). When it instructs you to read the source diff, look in `$BUNDLE_DIR/<repo-name>/`.
Begin by reading `.github/claude-agent/run.md` end-to-end, then execute.
settings: |
{
"permissions": {
"defaultMode": "auto",
"allow": [
"Read(//**)",
"Grep(//**)",
"Glob(//**)",
"Edit(//**)",
"Write(//**)",
"Bash(git *)",
"Bash(gh *)",
"Bash(jq *)",
"Bash(cat *)",
"Bash(ls *)",
"Bash(diff *)",
"Bash(grep *)",
"Bash(echo *)",
"Bash(date *)"
]
}
}
claude_args: |
--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:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
issue='${{ matrix.item.tracking_issue }}'
product='${{ matrix.item.product }}'
rc_tag='${{ matrix.item.rc_tag }}'
display='${{ matrix.item.display_name }}'
# Skip if the agent already posted its own marker for this (product, rc_tag).
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}; nothing to do."
exit 0
fi
base_version="${rc_tag%-RC*}"
gh issue comment "$issue" --body "<!-- rc-docs-sync:v1 product=${product} rc_tag=${rc_tag} -->
**${display} ${base_version}** (RC \`${rc_tag}\`) — agent step did not post its own summary; this is a safety-net marker so the next scheduled run does not re-process this RC.
Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
Inspect the run logs and any PRs labeled \`rc/${rc_tag}\` to see what the agent produced before failing."
# Posts "superseded by X" markers for the RCs this matrix entry collapsed
# past. Gated on the latest RC's own marker actually being present — if
# clone or bundle failed before any marker for this RC was posted, the
# skipped RCs stay unmarked so the next run re-picks them up correctly
# (rather than being incorrectly used as `same_base_processed` diff bases).
- name: Mark RCs superseded by this entry
if: always() && steps.bundle.outputs.any_content != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
LATEST_RC_TAG: ${{ matrix.item.rc_tag }}
DISPLAY_NAME: ${{ matrix.item.display_name }}
PRODUCT: ${{ matrix.item.product }}
TRACKING_ISSUE: ${{ matrix.item.tracking_issue }}
SUPERSEDED_RCS: ${{ toJSON(matrix.item.superseded_rcs) }}
run: |
set -euo pipefail
# No-op when this entry didn't collapse anything.
if [ "$(echo "$SUPERSEDED_RCS" | jq 'length')" = "0" ]; then
exit 0
fi
# Refuse to post superseded markers unless the marker for this entry's
# latest RC is already on the tracking issue (placed by the no-op step,
# fast-path step, agent itself, or the safety-net step above).
if ! gh issue view "$TRACKING_ISSUE" --json comments --jq '.comments[].body' \
| grep -Eq "<!--[[:space:]]*rc-docs-sync:v1[[:space:]]+product=${PRODUCT}[[:space:]]+rc_tag=${LATEST_RC_TAG}[[:space:]]*-->"; then
echo "Latest RC ${LATEST_RC_TAG} has no marker yet; not posting superseded markers."
exit 0
fi
base_version="${LATEST_RC_TAG%-RC*}"
echo "$SUPERSEDED_RCS" | jq -r '.[]' | while read -r rc_tag; do
[ -z "$rc_tag" ] && continue
if gh issue view "$TRACKING_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 ${rc_tag} already exists; skipping."
continue
fi
gh issue comment "$TRACKING_ISSUE" --body "<!-- rc-docs-sync:v1 product=${PRODUCT} rc_tag=${rc_tag} -->
**${DISPLAY_NAME} ${base_version}** (RC \`${rc_tag}\`) — superseded by \`${LATEST_RC_TAG}\` in the same sync run. The later RC was processed against the same diff base, so any net public-surface changes are covered there."
done