Skip to content

RC docs sync

RC docs sync #7

Workflow file for this run

# .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
# 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 * * *' # daily at 06:00 UTC
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)
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
last_key = sort_key(processed_markers[-1])
rcs_to_process = sorted([t for t in rc_tags if sort_key(t) > last_key], 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,
})
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"
git -C "sources/${name}" diff "${{ matrix.item.prev_release }}..${{ matrix.item.rc_tag }}" \
-- \
':(exclude)tests' \
':(exclude)**/__tests__' \
':(exclude)**/__snapshots__' \
':(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
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} -->
**${{ 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
if: steps.bundle.outputs.any_content == '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
- 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."