chore: add action to sync skills to documentation changes

This commit is contained in:
Shahed Nasser
2026-07-31 14:15:54 +03:00
parent 34c71b3ecd
commit 7758d90325
5 changed files with 1104 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
<role>
You are a technical maintainer of the `medusajs/medusa-agent-skills` repository,
which publishes Claude Code plugins whose skill files restate Medusa
documentation as instructions for a coding agent. You read documentation diffs
and judge whether they make any published instruction wrong.
</role>
<task>
Correct every skill file in this repository that the supplied Medusa
documentation commit has made inaccurate.
</task>
<context>
All publishable content lives under `plugins/`. There are four plugins, each
with skills at `plugins/<plugin>/skills/<skill>/`, made up of a `SKILL.md` plus
supporting markdown in `reference/`, `references/`, `lessons/`, `checkpoints/`,
`architecture/`, or `troubleshooting/` subdirectories. These files are consumed
by agents, not read by people, so a wrong instruction propagates silently into
generated code.
No mapping exists between documentation pages and skill files. Deciding which
skill files a docs change affects is your judgement, made by searching the
content. Most documentation commits affect nothing in this repository.
Documentation URLs resolve to paths in the `medusajs/medusa` repository as
follows, which is how you tell whether a `https://docs.medusajs.com/...` link in
a skill file points at a changed docs path:
- `/learn/*` is `www/apps/book/app/learn/*`
- `/resources/*` is `www/apps/resources/app/*`
- `/ui/*` is `www/apps/ui/*`
- `/cloud/*` is `www/apps/cloud/*`
A workflow runs `node scripts/validate-skills.mjs` after you finish, opens a
pull request only if files under `plugins/` differ from HEAD, and fails the run
if any file outside `plugins/` was created or modified.
</context>
<input>
Two files have been written to the workspace before you start.
`/tmp/docs-change/commit.json`:
- `sha` (string) - the docs commit SHA.
- `url` (string) - the commit's GitHub URL.
- `message` (string) - the commit subject line.
- `file_list_source` (string) - how the file list was assembled.
`/tmp/docs-change/changed-files.json`:
- `considered` (array) - the authored docs files the commit touched. Each entry
has `status` (`"A"` added, `"M"` modified, `"D"` deleted), `path` (relative to
the root of `medusajs/medusa`), `previous_path` (string or null, set when the
file was renamed), `patch` (unified diff, or null), and optionally
`patch_omitted: true` when the diff was too large to include.
- `ignored` (array) - paths already dropped as build output, generated
reference material, or docs apps this repository does not track. Each entry
has `status`, `path`, and `reason`.
</input>
<steps>
1. Read `/tmp/docs-change/commit.json` and `/tmp/docs-change/changed-files.json`
in full, including every `patch`.
2. For each entry in `considered`, determine what changed in substance, using
the rules for its `status`:
- `"M"`: read the patch. Act only on a renamed export, a changed function or
method signature, a new required option, a reversed default, a removed or
replaced API, or a corrected code example. Take no action on prose
rewording, typo fixes, formatting, or frontmatter-only changes.
- `"A"`: a new page usually documents something new, which the skills are not
obliged to cover. Act only if a skill file makes a claim the new page
contradicts, or if the new page is the new home for content a skill file
points at.
- `"D"`: the path no longer exists. Act on it. Any `docs.medusajs.com` link
in a skill file that resolves to this path is now dead.
3. Run `Glob` on `plugins/**/*.md` to list every file you may edit.
4. For each substantive change found in step 2, run `Grep` across `plugins/` for
the concrete symbols, option names, commands, and URLs the patch touched.
Use the search results to decide which files are affected. Do not infer
affected files from directory names.
5. Edit only the files the change makes wrong. When the change is a `"D"`,
repoint each dead link at its replacement page, or delete the reference when
there is no replacement. Do not refresh the surrounding text and leave the
dead link in place. When an entry with status `"A"` carries a `previous_path`
equal to the deleted path, that entry is the replacement page.
6. Run `node scripts/validate-skills.mjs`. If it reports a problem, fix it. If
you cannot fix it, revert the edit that caused it.
7. Write `/tmp/docs-change/summary.md` in the shape given in `<output_format>`.
</steps>
<constraints>
- Edit only files whose path starts with `plugins/`. Never create or modify any
file under `.github/`, `scripts/`, `.claude-plugin/`, or `README.md`.
- Never write a scratch, log, notes, or output file into the repository working
directory. No `output.txt`, no `notes.md`, no `summary.md` at the repository
root. The single file you write outside `plugins/` is
`/tmp/docs-change/summary.md`, at that exact absolute path.
- Never create a new skill, delete a skill, or rename a directory.
- Never change the `name` field in a `SKILL.md` frontmatter block. Change a
`description` field only when the docs change makes its wording factually
wrong.
- Make the smallest edit that makes the file correct: change only the sentences,
code lines, or links that the docs change falsified. Do not reorder sections,
rewrite unaffected paragraphs, or add content the docs change did not require.
- Match the file you are editing on three points: heading depth, code-fence
language tags, and whether instructions are written as imperatives or as
descriptions.
- Use no emoji in any edited file or in the summary.
- Use only the `Read`, `Write`, `Edit`, `Glob`, `Grep`, and `WebFetch` tools,
the `MedusaDocs` MCP server, and the read-only bash commands you are
permitted. Run no `git` command that stages, commits, pushes, or checks out.
- When you need to confirm how a Medusa feature behaves beyond what the patch
shows, query the `MedusaDocs` MCP server or fetch the page from
https://docs.medusajs.com. Do not infer behavior from the patch alone.
- Making no edit is a correct and common outcome. Never edit a file to
demonstrate activity.
</constraints>
<error_handling>
- If `considered` is empty or absent: make no edits and write a summary whose
`Files updated` section is `- none`.
- If either input file is missing or does not parse: make no edits and write a
summary whose `Files updated` section is `- none` and whose
`Needs human review` section names the file that could not be read.
- If an entry has `patch_omitted: true`: fetch the page from
https://docs.medusajs.com to see the current content. If the fetch fails, list
that path under `Needs human review` and make no edit for it.
- If an entry has `patch: null` and no `patch_omitted` flag: treat the `status`
and `path` as the only evidence. For `"D"`, that is enough to repoint links.
For `"A"` and `"M"`, list the path under `Needs human review` instead of
guessing at content you have not seen.
- If you cannot determine whether a changed docs path affects any skill file:
make no edit and list that path under `Needs human review` with the reason.
- If two skill files state the same fact and the docs change falsifies it: edit
both. Never leave one correct and one wrong.
- If `node scripts/validate-skills.mjs` still reports a problem after you try to
fix it: revert the edits that caused it and list the affected docs path under
`Needs human review`.
</error_handling>
<examples>
<example>
<input>
commit.json message: "docs: split migration guide into three (#16250)"
considered:
- status "D", path "www/apps/book/app/learn/.../migrate-from-platforms/page.mdx"
- status "A", path "www/apps/book/app/learn/.../migrate-from-shopify/page.mdx",
previous_path "www/apps/book/app/learn/.../migrate-from-platforms/page.mdx"
A skill file contains the line:
See the [migration guide](https://docs.medusajs.com/learn/.../migrate-from-platforms).
</input>
<output>
The `D` path is dead, and the `A` entry names it as `previous_path`, so it is
the replacement. Edit the skill file to repoint the link:
See the [migration guide](https://docs.medusajs.com/learn/.../migrate-from-shopify).
Summary:
### Files updated
- `plugins/medusa-dev/skills/new-user/SKILL.md` - repointed a dead link to the split migration guide, driven by the deletion of `www/apps/book/app/learn/.../migrate-from-platforms/page.mdx`
### Needs human review
- none
</output>
</example>
<example>
<input>
commit.json message: "docs: clarify wording on the workflows page (#16301)"
considered:
- status "M", path "www/apps/book/app/learn/fundamentals/workflows/page.mdx",
patch shows only "You can create a workflow" changed to "You may create a workflow"
and a comma added in the following sentence.
</input>
<output>
The patch changes prose wording only. No export, signature, option, default, or
code example changed, so no skill file is now wrong. Make no edits.
Summary:
### Files updated
- none
### Needs human review
- none
</output>
</example>
</examples>
<output_format>
Write exactly one file, `/tmp/docs-change/summary.md`, containing exactly these
two level-three headings in this order and nothing else:
```
### Files updated
- `<repository-relative path>` - what you changed and which docs path drove it
### Needs human review
- `<docs path>` - why you could not determine the impact
```
Rules for the summary:
- Write `- none` as the sole bullet under any heading with no entries. Never
omit a heading.
- Under `Files updated`, one bullet per file you edited. Each bullet names a
path starting with `plugins/` and cites the docs path that drove the edit.
- Under `Needs human review`, one bullet per docs path you could not judge. Each
bullet names a path inside `medusajs/medusa` and gives the reason.
- Add no preamble, no closing remarks, and no headings beyond these two.
</output_format>
<success_criteria>
- Every path listed under `Files updated` starts with `plugins/` and its file
content differs from HEAD.
- No file outside `plugins/` was created or modified, and no scratch file was
left in the repository working directory.
- `node scripts/validate-skills.mjs` exits 0.
- `/tmp/docs-change/summary.md` exists and contains both headings, each with at
least one bullet.
- No `SKILL.md` frontmatter `name` field differs from HEAD.
- No skill file contains a `docs.medusajs.com` link that resolves to a path
listed with status `"D"` in the input.
- No edited file and no line of the summary contains an emoji.
- Every docs path in `considered` appears either as the cited driver of a bullet
under `Files updated`, or as a bullet under `Needs human review`, or in
neither because you judged it to affect nothing.
</success_criteria>
@@ -0,0 +1,360 @@
name: Sync Medusa Docs Changes
# Reacts to a documentation commit merged in medusajs/medusa and, when that
# commit affects content this repository restates, opens a pull request updating
# the affected skill files.
#
# Required repository secrets (Settings -> Secrets and variables -> Actions):
# MEDUSA_APP_ID App ID of the Medusa GitHub App
# MEDUSA_APP_PRIVATE_KEY PEM private key of the Medusa GitHub App
# ANTHROPIC_API_KEY Anthropic API key used by claude-code-base-action
#
# The Medusa GitHub App must be installed on THIS repository with
# `contents: write` and `pull-requests: write`. Every commit and pull request
# this workflow creates is attributed to that app, never to a personal account
# and never to the default GITHUB_TOKEN.
on:
repository_dispatch:
types: [medusa-docs-updated]
workflow_dispatch:
inputs:
commit_sha:
description: "Full 40-character SHA of a medusajs/medusa docs commit to process"
required: true
type: string
# Two docs commits merged close together must not race on the same base branch,
# and neither may be dropped -- so serialize instead of cancelling.
concurrency:
group: sync-medusa-docs-changes
cancel-in-progress: false
permissions:
contents: read # writes go through the app token, not GITHUB_TOKEN
env:
DOCS_REPO: medusajs/medusa
WORK_DIR: /tmp/docs-change
jobs:
sync:
name: Sync docs changes into skills
runs-on: ubuntu-latest
steps:
# Must be first: everything that writes to this repository uses this token.
# If this step fails, the Medusa GitHub App is not installed on this
# repository or MEDUSA_APP_ID / MEDUSA_APP_PRIVATE_KEY are wrong.
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.MEDUSA_APP_ID }}
private-key: ${{ secrets.MEDUSA_APP_PRIVATE_KEY }}
- name: Resolve and validate commit SHA
id: sha
env:
DISPATCH_SHA: ${{ github.event.client_payload.commit_sha }}
INPUT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
SHA="$INPUT_SHA"
else
SHA="$DISPATCH_SHA"
fi
if ! printf '%s' "$SHA" | grep -qE '^[0-9a-fA-F]{40}$'; then
echo "::error::invalid or missing commit_sha in dispatch payload"
exit 1
fi
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHA:0:8}" >> "$GITHUB_OUTPUT"
echo "Processing $DOCS_REPO commit $SHA"
# A repository_dispatch that carries no file list has nothing to act on.
# A workflow_dispatch never carries one, and takes the file list from the
# commit API instead, so it must not exit here.
- name: Check the payload carries docs files
id: payload-files
env:
CHANGED_FILES_JSON: ${{ toJSON(github.event.client_payload.changed_files) }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
COUNT=$(printf '%s' "$CHANGED_FILES_JSON" | jq 'if type == "array" then length else 0 end')
if [ "$COUNT" -eq 0 ]; then
echo "no docs files in payload"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "proceed=true" >> "$GITHUB_OUTPUT"
- name: Checkout repository
if: steps.payload-files.outputs.proceed == 'true'
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
- name: Set up Node.js
if: steps.payload-files.outputs.proceed == 'true'
uses: actions/setup-node@v4
with:
node-version: "22"
# The default GITHUB_TOKEN can read the public medusajs/medusa repository,
# so the app token is deliberately not used here. Note the commits API
# returns at most 300 files; larger commits are reported as such.
- name: Fetch the docs commit
if: steps.payload-files.outputs.proceed == 'true'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ steps.sha.outputs.sha }}
run: |
set -euo pipefail
mkdir -p "$WORK_DIR"
if ! gh api "repos/$DOCS_REPO/commits/$SHA?per_page=300" > "$WORK_DIR/commit-api.json" 2> "$WORK_DIR/commit-api.err"; then
# A well-formed SHA that does not exist answers 422 ("No commit
# found for SHA"), not 404 -- both mean the same thing here.
if grep -qE 'HTTP 404|HTTP 422|Not Found|No commit found' "$WORK_DIR/commit-api.err"; then
echo "::error::commit $SHA was not found in $DOCS_REPO. It may have been force-pushed away or the SHA is wrong. Refusing to proceed on partial data."
else
echo "::error::could not fetch commit $SHA from $DOCS_REPO"
cat "$WORK_DIR/commit-api.err"
fi
exit 1
fi
TOTAL=$(jq '.files | length' "$WORK_DIR/commit-api.json")
echo "commit API returned $TOTAL file entries"
if [ "$TOTAL" -ge 300 ]; then
echo "::warning::the commits API caps its file list at 300 entries; this commit may have more."
fi
# Mechanical filtering only: build output, generated TSDoc references, and
# docs apps no skill restates are dropped here. Whether the remaining
# files actually affect this repository is Claude's call, not a hardcoded
# path mapping's.
- name: Normalize and filter the changed-file list
id: resolve
if: steps.payload-files.outputs.proceed == 'true'
env:
PAYLOAD_JSON: ${{ toJSON(github.event.client_payload) }}
run: |
set -euo pipefail
node scripts/resolve-docs-commit.mjs \
--commit-api "$WORK_DIR/commit-api.json" \
--output-dir "$WORK_DIR"
- name: Report commit with no docs content
if: steps.payload-files.outputs.proceed != 'true' || steps.resolve.outputs.has_docs_files != 'true'
run: |
set -euo pipefail
if [ -f "$WORK_DIR/changed-files.json" ]; then
echo "no authored docs files in this commit after filtering build output and generated references"
jq -r '.ignored[] | "ignored: [\(.status)] \(.path) - \(.reason)"' "$WORK_DIR/changed-files.json"
else
echo "no docs files in payload"
fi
echo "No pull request opened."
- name: Run Claude Code to update affected skills
if: steps.resolve.outputs.has_docs_files == 'true'
uses: anthropics/claude-code-base-action@e8132bc5e637a42c27763fc757faa37e1ee43b34 # beta / v0.0.63
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt_file: .github/prompts/sync-medusa-docs-changes.md
# The prompt tells the agent to confirm Medusa behavior against the
# docs, so give it the same MCP server the plugins ship with.
mcp_config: '{"mcpServers":{"MedusaDocs":{"type":"http","url":"https://docs.medusajs.com/mcp"}}}'
allowed_tools: "Read,Write,Edit,Glob,Grep,WebFetch,mcp__MedusaDocs,Bash(node scripts/validate-skills.mjs),Bash(ls:*),Bash(cat:*),Bash(find:*)"
max_turns: 60
timeout_minutes: 30
# The action and the model both tend to drop scratch files into the
# working tree -- `output.txt` most often. They are not skill content, so
# they would trip the "modified outside plugins/" check below and fail an
# otherwise good run. Delete them before anything looks at the diff.
# Only untracked files are removed, so a real repository file with one of
# these names would be left alone and still fail the check.
- name: Remove Claude scratch artifacts
if: steps.resolve.outputs.has_docs_files == 'true'
run: |
set -euo pipefail
# If the summary landed at the repo root instead of the work dir,
# rescue it rather than throwing away the PR body.
if [ -f summary.md ] && ! git ls-files --error-unmatch summary.md >/dev/null 2>&1; then
[ -f "$WORK_DIR/summary.md" ] || mv summary.md "$WORK_DIR/summary.md"
rm -f summary.md
echo "Moved a stray summary.md into $WORK_DIR"
fi
for artifact in output.txt output.json claude-execution-output.json execution-output.json; do
[ -e "$artifact" ] || continue
if git ls-files --error-unmatch "$artifact" >/dev/null 2>&1; then
echo "::warning::$artifact is tracked in this repository and was left in place"
continue
fi
rm -rf "$artifact"
echo "Removed scratch artifact $artifact"
done
# There is no lint/build/test command in this repository (no package.json),
# so this integrity check stands in for one. It fails the run rather than
# opening a pull request with malformed skill content.
- name: Validate skill content
if: steps.resolve.outputs.has_docs_files == 'true'
run: node scripts/validate-skills.mjs
- name: Detect edits on disk
id: diff
if: steps.resolve.outputs.has_docs_files == 'true'
run: |
set -euo pipefail
# Check for strays first. A run that edited nothing but left a file
# outside plugins/ behind is still a prompt violation and must fail
# loudly rather than pass as "nothing to do".
# cut -c4- keeps paths containing spaces intact.
STRAY=$(git status --porcelain -- . ':(exclude)plugins' | cut -c4-)
if [ -n "$STRAY" ]; then
echo "::error::files outside plugins/ were created or modified, which the prompt forbids:"
echo "$STRAY"
exit 1
fi
CHANGED=$(git status --porcelain -- plugins | cut -c4-)
if [ -z "$CHANGED" ]; then
echo "No skill files changed on disk. No pull request opened."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "$CHANGED" > "$WORK_DIR/changed-plugin-files.txt"
echo "Changed files:"
echo "$CHANGED"
# Runs after the plugins-only check above, so Claude is still forbidden
# from touching .claude-plugin/ while the workflow itself is free to.
# An edited skill that ships under an unchanged version is not picked up
# by installs, so every content change moves a version.
- name: Bump plugin and marketplace versions
id: bump
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
run: |
set -euo pipefail
node scripts/bump-versions.mjs --output "$WORK_DIR/version-bumps.md" \
< "$WORK_DIR/changed-plugin-files.txt"
- name: Build pull request body
id: pr-body
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
run: |
set -euo pipefail
{
echo "## Automated skill update from a Medusa docs change"
echo
echo "Source commit: $(jq -r '.url' "$WORK_DIR/commit.json")"
echo
echo "> $(jq -r '.message' "$WORK_DIR/commit.json")"
echo
echo "File list source: $(jq -r '.file_list_source' "$WORK_DIR/commit.json")"
echo
echo "### Docs files Claude reviewed"
echo
jq -r '.considered[] | "- `\(.path)` (\(.status))" + (if .patch_omitted then " - diff omitted, over the patch budget" else "" end)' "$WORK_DIR/changed-files.json"
echo
# Claude reports what it changed and what it could not judge. The
# fallback keeps the body honest if it never wrote a summary.
if [ -f "$WORK_DIR/summary.md" ]; then
cat "$WORK_DIR/summary.md"
else
echo "### Files updated"
echo
git status --porcelain -- plugins | cut -c4- | sed 's/^/- `/; s/$/`/'
echo
echo "Claude did not write a summary, so this list is derived from the diff."
fi
echo
if [ -s "$WORK_DIR/version-bumps.md" ]; then
cat "$WORK_DIR/version-bumps.md"
fi
echo "---"
echo
echo "Generated by the \`sync-medusa-docs-changes\` workflow. Review before merging: the edits are model-generated and may have missed context."
} > "$WORK_DIR/pr-body.md"
cat "$WORK_DIR/pr-body.md"
- name: Commit and push to a dedicated branch
id: push
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
env:
SHORT_SHA: ${{ steps.sha.outputs.short_sha }}
run: |
set -euo pipefail
BRANCH="docs-sync/medusa-$SHORT_SHA"
git config user.name "medusa-bot[bot]"
git config user.email "medusa-bot[bot]@users.noreply.github.com"
# Deterministic branch name so a re-run of the same docs commit updates
# the existing pull request instead of opening a second one.
git checkout -B "$BRANCH"
# .claude-plugin holds the marketplace manifest, written by the bump
# step above -- never by Claude.
git add -- plugins .claude-plugin
git commit -m "chore(skills): sync with medusa docs commit $SHORT_SHA"
# Never force-push: a plain push to a branch we just branched from the
# default branch either fast-forwards or fails loudly.
git push origin "$BRANCH"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Create or update the pull request
if: steps.resolve.outputs.has_docs_files == 'true' && steps.diff.outputs.has_changes == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCH: ${{ steps.push.outputs.branch }}
SHORT_SHA: ${{ steps.sha.outputs.short_sha }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
TITLE="chore(skills): sync with Medusa docs commit $SHORT_SHA"
EXISTING=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number' || true)
if [ -n "$EXISTING" ]; then
gh pr edit "$EXISTING" --repo "$REPO" \
--title "$TITLE" \
--body-file "$WORK_DIR/pr-body.md"
echo "Updated existing pull request #$EXISTING"
exit 0
fi
if ! gh pr create --repo "$REPO" \
--base "${{ github.event.repository.default_branch }}" \
--head "$BRANCH" \
--title "$TITLE" \
--body-file "$WORK_DIR/pr-body.md" 2> "$WORK_DIR/pr-create.err"; then
if grep -qE 'HTTP 40[34]|Resource not accessible' "$WORK_DIR/pr-create.err"; then
echo "::error::could not open the pull request with the Medusa GitHub App token. The app is either not installed on $REPO or lacks contents:write and pull-requests:write. Not falling back to any other credential."
fi
cat "$WORK_DIR/pr-create.err"
exit 1
fi
# The Claude step and the validation step both run before anything is
# pushed, so a failure there leaves no branch behind. This only cleans up
# if the push succeeded and a later step failed.
- name: Clean up branch on failure
if: failure() && steps.push.outputs.branch != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCH: ${{ steps.push.outputs.branch }}
REPO: ${{ github.repository }}
run: |
OPEN=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number' || true)
if [ -z "$OPEN" ]; then
gh api --method DELETE "repos/$REPO/git/refs/heads/$BRANCH" || true
echo "Deleted partial branch $BRANCH"
fi
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* Bumps the patch version of every plugin whose content changed, plus the
* marketplace version, so a published skill edit is actually picked up by
* installs.
*
* Input (stdin): the changed paths, one per line, as produced by
* `git status --porcelain -- plugins | cut -c4-`.
* Output (--output <file>, optional): a markdown list of the bumps, for the
* pull request body.
*
* Only `plugins/<name>/...` paths count. A run where no plugin content changed
* bumps nothing, including the marketplace.
*/
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
const MARKETPLACE = ".claude-plugin/marketplace.json"
function arg(name) {
const i = process.argv.indexOf(name)
return i === -1 ? null : process.argv[i + 1]
}
/** Bumps the patch component of a strict X.Y.Z version. */
function bumpPatch(version, label) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || "").trim())
if (!match) {
console.error(
`::error::${label} has version "${version}", which is not a plain X.Y.Z. Refusing to guess how to bump it.`
)
process.exit(1)
}
const [, major, minor, patch] = match
return `${major}.${minor}.${Number(patch) + 1}`
}
/**
* Rewrites a single `"version": "..."` value in place, leaving the rest of the
* file byte-for-byte alone. Reserializing with JSON.stringify would reformat
* files that are hand-maintained.
*/
function writeVersion(path, from, to) {
const original = readFileSync(path, "utf8")
const pattern = new RegExp(`("version"\\s*:\\s*)"${from.replace(/\./g, "\\.")}"`)
if (!pattern.test(original)) {
console.error(`::error::could not locate the version string in ${path}`)
process.exit(1)
}
writeFileSync(path, original.replace(pattern, `$1"${to}"`))
}
function main() {
const outputPath = arg("--output")
const changed = readFileSync(0, "utf8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
// plugins/<name>/... -- anything else is not publishable plugin content.
const affected = new Set()
for (const path of changed) {
const match = /^plugins\/([^/]+)\//.exec(path)
if (match) affected.add(match[1])
}
if (affected.size === 0) {
console.error("no plugin content changed, nothing to bump")
if (outputPath) writeFileSync(outputPath, "")
return
}
const bumps = []
for (const plugin of [...affected].sort()) {
const manifest = `plugins/${plugin}/.claude-plugin/plugin.json`
if (!existsSync(manifest)) {
console.error(`::error::${plugin} has changed content but no ${manifest}`)
process.exit(1)
}
const current = JSON.parse(readFileSync(manifest, "utf8")).version
const next = bumpPatch(current, manifest)
writeVersion(manifest, current, next)
bumps.push({ name: plugin, path: manifest, from: current, to: next })
}
// The marketplace version gates the whole catalogue, so it moves whenever any
// plugin in it does.
if (!existsSync(MARKETPLACE)) {
console.error(`::error::${MARKETPLACE} is missing`)
process.exit(1)
}
const marketplaceCurrent = JSON.parse(readFileSync(MARKETPLACE, "utf8"))
.metadata.version
const marketplaceNext = bumpPatch(marketplaceCurrent, MARKETPLACE)
writeVersion(MARKETPLACE, marketplaceCurrent, marketplaceNext)
bumps.push({
name: "marketplace",
path: MARKETPLACE,
from: marketplaceCurrent,
to: marketplaceNext,
})
for (const b of bumps) {
console.error(`bumped ${b.name}: ${b.from} -> ${b.to}`)
}
if (outputPath) {
const lines = [
"### Versions bumped",
"",
...bumps.map((b) => `- \`${b.path}\` - ${b.from} to ${b.to}`),
"",
]
writeFileSync(outputPath, lines.join("\n"))
}
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`bumped=${bumps.map((b) => `${b.name}@${b.to}`).join(" ")}\n`
)
}
}
main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* Normalizes a `medusa-docs-updated` dispatch payload and the GitHub commit API
* response into a single changed-files list, attaches each file's patch, and
* drops paths that are not authored documentation.
*
* This does NOT decide whether the commit affects this repository -- that
* judgement is Claude's. The filtering here is only mechanical: build output,
* generated reference material, and docs apps whose content no skill restates.
*
* Env:
* EVENT_NAME "repository_dispatch" | "workflow_dispatch"
* PAYLOAD_JSON the raw github.event.client_payload as JSON ("" or "null" when absent)
*
* Args:
* --commit-api <file> JSON body of GET /repos/medusajs/medusa/commits/<sha>
* --output-dir <dir> where commit.json and changed-files.json are written
*
* The sender caps `changed_files` at 100 entries with no truncation flag, so a
* payload of exactly 100 is treated as truncated and the API response wins.
*/
import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs"
import { join } from "node:path"
const PAYLOAD_CAP = 100
/** Total bytes of unified diff handed to Claude. */
const PATCH_BUDGET_BYTES = 250_000
/** Docs apps whose content no skill in this repository restates. */
const IGNORED_APPS = [
["www/apps/docs/", "landing and redirect app, no prose"],
["www/apps/bloom/", "Bloom framework, not covered by any skill"],
["www/apps/user-guide/", "merchant-facing admin guide, not developer content"],
["www/apps/api-reference/", "OAS-generated endpoint reference"],
]
/**
* Generated or tooling paths. `www/apps/resources/references/` is the generated
* TSDoc source and `www/apps/resources/app/references/` is where it is mounted
* for the site -- neither is authored prose, and both churn on every release.
*/
const IGNORED_PATTERNS = [
[/^www\/apps\/resources\/references\//, "generated TSDoc reference source"],
[/^www\/apps\/resources\/app\/references\//, "generated TSDoc reference pages"],
[/\/generated\//, "build output"],
[/\/sidebar\.mjs$/, "generated sidebar"],
[/\/sidebars\//, "generated sidebar data"],
[/\/public\//, "static asset"],
[/^www\/(utils|packages)\//, "docs tooling, not documentation"],
]
const CONTENT_EXTENSIONS = [".mdx", ".md"]
function arg(name) {
const i = process.argv.indexOf(name)
return i === -1 ? null : process.argv[i + 1]
}
function normalizeApiStatus(status) {
switch (status) {
case "added":
case "copied":
return "A"
case "removed":
return "D"
default:
return "M"
}
}
/** Flattens the API's file list, expanding renames into a D + an A. */
function filesFromApi(commit) {
const out = []
for (const file of commit.files || []) {
if (file.status === "renamed") {
if (file.previous_filename) {
out.push({ status: "D", path: file.previous_filename, patch: null })
}
out.push({
status: "A",
path: file.filename,
previous_path: file.previous_filename || null,
patch: file.patch || null,
})
continue
}
out.push({
status: normalizeApiStatus(file.status),
path: file.filename,
patch: file.patch || null,
})
}
return out
}
/** Returns a reason string when the path is not authored documentation. */
function ignoreReason(path) {
if (!path.startsWith("www/apps/")) return "outside www/apps"
for (const [prefix, reason] of IGNORED_APPS) {
if (path.startsWith(prefix)) return reason
}
for (const [re, reason] of IGNORED_PATTERNS) {
if (re.test(path)) return reason
}
if (!CONTENT_EXTENSIONS.some((ext) => path.endsWith(ext))) {
return "not an authored content file"
}
return null
}
function main() {
const commitApiPath = arg("--commit-api")
const outputDir = arg("--output-dir")
if (!commitApiPath || !outputDir) {
console.error(
"usage: resolve-docs-commit.mjs --commit-api <file> --output-dir <dir>"
)
process.exit(1)
}
mkdirSync(outputDir, { recursive: true })
const commit = JSON.parse(readFileSync(commitApiPath, "utf8"))
const apiFiles = filesFromApi(commit)
let payload = null
const rawPayload = (process.env.PAYLOAD_JSON || "").trim()
if (rawPayload && rawPayload !== "null") {
try {
payload = JSON.parse(rawPayload)
} catch (err) {
console.error(`could not parse client_payload: ${err.message}`)
process.exit(1)
}
}
const payloadFiles = Array.isArray(payload?.changed_files)
? payload.changed_files
: null
let files
let source
if (!payloadFiles || payloadFiles.length === 0) {
// workflow_dispatch carries no payload; a dispatch with no files at all is
// handled by the caller before we get here.
files = apiFiles
source = "api (no changed_files in payload)"
} else if (payloadFiles.length >= PAYLOAD_CAP) {
files = apiFiles
source = `api (payload hit the ${PAYLOAD_CAP}-entry cap and is assumed truncated)`
} else {
// Trust the payload's list, but take patches and rename info from the API.
const byPath = new Map(apiFiles.map((f) => [f.path, f]))
files = payloadFiles.map((f) => {
const apiEntry = byPath.get(f.path)
return {
status: String(f.status || "M").toUpperCase().slice(0, 1),
path: f.path,
previous_path: apiEntry?.previous_path || null,
patch: apiEntry?.patch || null,
}
})
source = "payload (enriched with patches from the commit API)"
}
const considered = []
const ignored = []
for (const file of files) {
if (!file.path) continue
const reason = ignoreReason(file.path)
if (reason) {
ignored.push({ status: file.status, path: file.path, reason })
} else {
considered.push({
status: file.status,
path: file.path,
previous_path: file.previous_path || null,
patch: file.patch || null,
})
}
}
// Keep the prompt bounded. Patches beyond the budget are dropped, but the
// file stays in the list with patch_omitted set, so the omission is visible
// rather than silent -- Claude can read the page itself if it needs detail.
let budget = PATCH_BUDGET_BYTES
let patchesOmitted = 0
for (const file of considered) {
const size = file.patch ? file.patch.length : 0
if (size === 0) continue
if (size <= budget) {
budget -= size
} else {
file.patch = null
file.patch_omitted = true
patchesOmitted++
}
}
const commitInfo = {
sha: commit.sha,
url: commit.html_url,
message: (payload?.commit_message || commit.commit?.message || "")
.split("\n")[0]
.trim(),
repository: payload?.repository || "medusajs/medusa",
file_list_source: source,
counts: {
considered: considered.length,
ignored: ignored.length,
patches_omitted: patchesOmitted,
},
}
writeFileSync(
join(outputDir, "commit.json"),
JSON.stringify(commitInfo, null, 2)
)
writeFileSync(
join(outputDir, "changed-files.json"),
JSON.stringify({ considered, ignored }, null, 2)
)
console.error(`file list source: ${source}`)
console.error(
`${considered.length} docs file(s) to consider, ${ignored.length} ignored`
)
for (const f of considered) {
console.error(` [${f.status}] ${f.path}`)
}
if (patchesOmitted > 0) {
console.error(
`::warning::patch budget of ${PATCH_BUDGET_BYTES} bytes exhausted: ${patchesOmitted} of ${considered.length} patches omitted`
)
}
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`has_docs_files=${considered.length > 0}\n`
)
}
}
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* Integrity checks for the plugin/skill content in this repository.
*
* This repository has no package.json, so there is no lint/build/test command
* to run. These checks stand in for one: they catch the ways an automated edit
* can break a skill (malformed frontmatter, renamed skill, dead relative link,
* invalid plugin manifest) before a pull request is opened.
*
* Usage: node scripts/validate-skills.mjs
* Exits non-zero and prints one line per problem when anything fails.
*/
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"
import { join, dirname, resolve, relative } from "node:path"
const ROOT = resolve(new URL("..", import.meta.url).pathname)
const problems = []
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === ".git") continue
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
const allFiles = walk(ROOT)
const rel = (p) => relative(ROOT, p)
// 1. Every JSON file must parse.
for (const file of allFiles.filter((f) => f.endsWith(".json"))) {
try {
JSON.parse(readFileSync(file, "utf8"))
} catch (err) {
problems.push(`${rel(file)}: invalid JSON -- ${err.message}`)
}
}
// 2. Every plugin listed in the marketplace must exist and have a manifest.
const marketplacePath = join(ROOT, ".claude-plugin", "marketplace.json")
if (existsSync(marketplacePath)) {
try {
const marketplace = JSON.parse(readFileSync(marketplacePath, "utf8"))
for (const plugin of marketplace.plugins || []) {
const source = join(ROOT, plugin.source)
if (!existsSync(source)) {
problems.push(
`.claude-plugin/marketplace.json: plugin source does not exist -- ${plugin.source}`
)
continue
}
const manifest = join(source, ".claude-plugin", "plugin.json")
if (!existsSync(manifest)) {
problems.push(`${plugin.source}: missing .claude-plugin/plugin.json`)
}
}
} catch {
// already reported by the JSON check above
}
}
// 3. Every SKILL.md must have frontmatter with a slug `name` and a
// `description`, and no two skills may share a name (Claude Code resolves
// skills by the frontmatter name, not by directory).
const seenNames = new Map()
const skillFiles = allFiles.filter((f) => f.endsWith("/SKILL.md"))
if (skillFiles.length === 0) {
problems.push("no SKILL.md files found -- did the edit remove them all?")
}
for (const file of skillFiles) {
const content = readFileSync(file, "utf8")
if (!content.startsWith("---\n")) {
problems.push(`${rel(file)}: missing YAML frontmatter`)
continue
}
const end = content.indexOf("\n---", 4)
if (end === -1) {
problems.push(`${rel(file)}: frontmatter is not terminated by ---`)
continue
}
const frontmatter = content.slice(4, end)
const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim()
const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim()
if (!name) problems.push(`${rel(file)}: frontmatter has no "name"`)
if (!description)
problems.push(`${rel(file)}: frontmatter has no "description"`)
if (name && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
problems.push(
`${rel(file)}: frontmatter name "${name}" is not a lowercase kebab-case slug`
)
}
if (name && seenNames.has(name)) {
problems.push(
`${rel(file)}: duplicate skill name "${name}", also used by ${seenNames.get(name)}`
)
} else if (name) {
seenNames.set(name, rel(file))
}
}
// 4. Relative markdown links between skill files must resolve.
const LINK_RE = /\[[^\]]*\]\(([^)\s]+)\)/g
for (const file of allFiles.filter((f) => f.endsWith(".md"))) {
const content = readFileSync(file, "utf8")
for (const match of content.matchAll(LINK_RE)) {
const target = match[1]
if (/^(https?:|mailto:|#)/.test(target)) continue
const path = target.split("#")[0]
if (!path) continue
if (!existsSync(resolve(dirname(file), path))) {
problems.push(`${rel(file)}: broken relative link -- ${target}`)
}
}
}
if (problems.length > 0) {
console.error(`validate-skills: ${problems.length} problem(s) found`)
for (const p of problems) console.error(` ${p}`)
process.exit(1)
}
console.log(
`validate-skills: OK (${skillFiles.length} skills, ${allFiles.filter((f) => f.endsWith(".md")).length} markdown files)`
)