-
Notifications
You must be signed in to change notification settings - Fork 66
330 lines (294 loc) · 14.3 KB
/
Copy pathgenerate-agents.yml
File metadata and controls
330 lines (294 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
name: Validate skill PRs and regenerate catalog
on:
pull_request:
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
push:
branches: [main]
paths:
- "scripts/**"
- "skills/**"
- ".claude-plugin/**"
- ".github/workflows/generate-agents.yml"
permissions:
contents: read
jobs:
validate-pr:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up uv
uses: astral-sh/setup-uv@v4
- name: Check PR scope (skill PR vs infra PR)
id: scope
continue-on-error: true
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: |
MAINTAINER=0
case ",$PR_LABELS," in
*,maintainer,*)
MAINTAINER=1
echo "::notice::maintainer label present — multi-skill/infra PR allowed"
;;
esac
CHANGED=$(git diff --name-only --diff-filter=ACMRT "origin/$BASE_REF...HEAD")
if [ -z "$CHANGED" ]; then
echo "No file changes detected."
exit 0
fi
echo "Changed files:"
echo "$CHANGED" | sed 's/^/ - /'
echo
SKILL_DIRS=$(echo "$CHANGED" | grep -oE '^skills/apify-[^/]+' | sort -u)
N_SKILLS=$(echo "$SKILL_DIRS" | grep -c . || true)
# Exactly two kinds of PR exist here:
# - skill PR: touches skills/apify-<name>/ and nothing else.
# The catalog is generated, and a skill submission has
# no business editing CI, the linters or the docs.
# - infra PR: everything else — CI, scripts/, docs, template.
# Maintainers only: without the 'maintainer' label it
# never passes (CODEOWNERS review is the target state
# once handles exist). Later steps run scripts/ out of
# the PR checkout, so an unlabeled PR must not be able
# to change what validates it. (GitHub resolves the
# workflow file itself from the PR too; that one is on
# the fork-approval gate and the human stage-1 rule in
# REVIEWING.md, not on this check.)
SKILL_PATHS='^skills/apify-[^/]+/'
GENERATED_PATHS='^(\.claude-plugin/marketplace\.json|agents/AGENTS\.md)$'
# The repo layout as a whole — nothing outside it, label or not.
# Only files that actually exist in the repo are listed; a path is
# added here when the file is added, not in advance.
ALLOWED='^(skills/apify-[^/]+/|skills/_template/|\.claude-plugin/marketplace\.json$|LICENSE$|README\.md$|CONTRIBUTING\.md$|REVIEWING\.md$|SECURITY\.md$|\.gitignore$|\.github/|scripts/|agents/)'
OUT_OF_BOUNDS=$(echo "$CHANGED" | grep -vE "$ALLOWED" || true)
# README is generated only between the skills-table markers; edits to
# the surrounding prose are ordinary doc changes. Both branches below
# need this distinction, so resolve it once.
README_TABLE_CHANGED=0
if echo "$CHANGED" | grep -qxF 'README.md'; then
BASE_BLOCK=$(git show "origin/$BASE_REF:README.md" | sed -n '/<!-- BEGIN_SKILLS_TABLE -->/,/<!-- END_SKILLS_TABLE -->/p')
HEAD_BLOCK=$(sed -n '/<!-- BEGIN_SKILLS_TABLE -->/,/<!-- END_SKILLS_TABLE -->/p' README.md)
if [ "$BASE_BLOCK" != "$HEAD_BLOCK" ]; then
README_TABLE_CHANGED=1
fi
fi
FAIL=0
if [ -n "$OUT_OF_BOUNDS" ]; then
echo "::error::PR touches files outside the repo layout (skill dirs, root meta files, .github/, scripts/, agents/):"
echo "$OUT_OF_BOUNDS" | sed 's/^/ - /'
FAIL=1
fi
if [ "$MAINTAINER" -eq 1 ]; then
echo "maintainer PR — the skill/infra split is not enforced."
if [ "$FAIL" -eq 1 ]; then
exit 1
fi
exit 0
fi
if [ "$N_SKILLS" -gt 1 ]; then
echo "::error::PR touches $N_SKILLS skills; one skill per PR (or ask a maintainer for the 'maintainer' label)."
echo "$SKILL_DIRS" | sed 's/^/ - /'
FAIL=1
fi
if [ -n "$SKILL_DIRS" ]; then
# --- skill PR ---
# Generated files first: they get the precise "edit frontmatter"
# reason instead of the generic out-of-your-skill-dir message.
GENERATED_TOUCHED=$(echo "$CHANGED" | grep -E "$GENERATED_PATHS" || true)
if [ "$README_TABLE_CHANGED" -eq 1 ]; then
GENERATED_TOUCHED=$(printf '%s\nREADME.md (skills table block)' "$GENERATED_TOUCHED")
fi
if [ -n "$GENERATED_TOUCHED" ]; then
echo "::error::.claude-plugin/marketplace.json: generated file — edit SKILL.md frontmatter instead, the bot regenerates the catalog after merge"
echo "$GENERATED_TOUCHED" | grep . | sed 's/^/ - /'
FAIL=1
fi
STRAY=$(echo "$CHANGED" | grep -vE "$SKILL_PATHS" | grep -vE "$GENERATED_PATHS" || true)
if [ -n "$STRAY" ]; then
echo "::error::a skill PR may only change files inside skills/apify-<name>/ — everything else is an infra PR, which is maintainer territory (the 'maintainer' label):"
echo "$STRAY" | sed 's/^/ - /'
FAIL=1
fi
else
# --- infra PR ---
# Nothing to regenerate from: the catalog is derived from skill
# frontmatter, so a hand-edited catalog with no skill is a dead end.
NON_CATALOG=$(echo "$CHANGED" | grep -vE "$GENERATED_PATHS" || true)
if [ "$README_TABLE_CHANGED" -eq 1 ]; then
NON_CATALOG=$(echo "$NON_CATALOG" | grep -vxF 'README.md' || true)
fi
if [ -z "$NON_CATALOG" ]; then
echo "::error::this PR only edits generated files (marketplace.json, AGENTS.md, the README skills table) and adds no skill — they are generated from skills/<name>/SKILL.md frontmatter after merge; submit a skill folder instead (see CONTRIBUTING.md)"
FAIL=1
fi
# No allowlist here: an infra PR is maintainer territory as a
# whole. Without the label it never passes — docs included.
echo "::error::this PR touches no skill directory, which makes it an infra PR — infra PRs are for maintainers and need the 'maintainer' label to pass:"
echo "$CHANGED" | sed 's/^/ - /'
FAIL=1
fi
if [ "$FAIL" -eq 1 ]; then
exit 1
fi
- name: Check PR checklist
id: checklist
continue-on-error: true
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
if [ -z "$PR_BODY" ]; then
echo "::error::PR description is empty — please fill in the template."
exit 1
fi
UNCHECKED=$(echo "$PR_BODY" | grep -nE '^- \[ \]' || true)
if [ -n "$UNCHECKED" ]; then
echo "::error::PR checklist has unchecked boxes — please complete them:"
echo "$UNCHECKED" | sed 's/^/ /'
exit 1
fi
# Both lints below run only on the skill dirs changed in the PR (same
# scope logic as the scope check above). Pre-existing findings on main
# are deliberately kept as exhibits (decision D2), so a full-repo run
# would fail every future PR. Full runs for local/maintainer use:
# bash scripts/lint_telemetry.sh
# uv run scripts/lint_references.py --check-actors
- name: Determine changed skill dirs
id: changed
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
CHANGED=$(git diff --name-only --diff-filter=ACMRT "origin/$BASE_REF...HEAD")
SKILL_DIRS=$(echo "$CHANGED" | grep -oE '^skills/apify-[^/]+' | sort -u)
N_SKILLS=$(echo "$SKILL_DIRS" | grep -c . || true)
echo "n_skills=$N_SKILLS" >> "$GITHUB_OUTPUT"
echo "skill_dirs=$(echo "$SKILL_DIRS" | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
echo "Changed skill dirs ($N_SKILLS):"
echo "$SKILL_DIRS" | sed 's/^/ - /'
- name: Lint telemetry (changed skills)
id: telemetry
continue-on-error: true
run: |
if [ "${{ steps.changed.outputs.n_skills }}" -eq 0 ]; then
echo "No skill directories changed — skipping telemetry lint."
exit 0
fi
bash scripts/lint_telemetry.sh ${{ steps.changed.outputs.skill_dirs }}
- name: Lint references (changed skills)
id: references
continue-on-error: true
run: |
N_SKILLS="${{ steps.changed.outputs.n_skills }}"
if [ "$N_SKILLS" -eq 0 ]; then
echo "No skill directories changed — skipping reference lint."
exit 0
fi
# Offline checks + online actor existence check for every changed
# skill (multi-skill PRs are maintainer-labeled and rare, so the
# extra API calls are cheap).
uv run scripts/lint_references.py ${{ steps.changed.outputs.skill_dirs }} --check-actors ${{ steps.changed.outputs.skill_dirs }}
- name: Validate skills and run a trial generation
id: generator
continue-on-error: true
run: uv run scripts/generate_agents.py
# Informational only: when a PR changes anything outside skill
# directories (CI, scripts, docs, template), show what the lints say
# across the WHOLE catalog — infra changes can shift what validates,
# and the author should see the blast radius before merge.
# Pre-existing findings on main are kept as exhibits
# (decision D2, see the comment above "Determine changed skill dirs"),
# so this must never gate the PR — it just gives the rule author (and
# reviewers) visibility into blast radius before merge.
- name: Full lint audit (infra change)
id: audit
continue-on-error: true
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
CHANGED=$(git diff --name-only --diff-filter=ACMRT "origin/$BASE_REF...HEAD")
NON_SKILL=$(echo "$CHANGED" | grep -vE '^skills/apify-[^/]+/' || true)
if [ -z "$NON_SKILL" ]; then
echo "PR changes skill directories only — skipping full-catalog audit."
exit 0
fi
echo "Files outside skill directories changed in this PR:"
echo "$NON_SKILL" | sed 's/^/ - /'
echo
echo "=== bash scripts/lint_telemetry.sh (full catalog) ==="
bash scripts/lint_telemetry.sh || true
echo
echo "=== uv run scripts/lint_references.py (full catalog, offline) ==="
uv run scripts/lint_references.py || true
echo "::notice::infra change — full-catalog audit above shows the current state of the whole catalog under these rules (informational, not gating)"
exit 0
# All checks run to completion (continue-on-error) so a contributor sees
# every problem in one CI run instead of iterating; this step is the
# single pass/fail gate. "audit" is deliberately excluded — it is
# informational and must never affect the verdict.
- name: Aggregate verdict
env:
V_SCOPE: ${{ steps.scope.outcome }}
V_CHECKLIST: ${{ steps.checklist.outcome }}
V_TELEMETRY: ${{ steps.telemetry.outcome }}
V_REFERENCES: ${{ steps.references.outcome }}
V_GENERATOR: ${{ steps.generator.outcome }}
run: |
echo "VERDICT scope=$V_SCOPE checklist=$V_CHECKLIST telemetry=$V_TELEMETRY references=$V_REFERENCES generator=$V_GENERATOR"
if [ "$V_SCOPE" = failure ] || [ "$V_CHECKLIST" = failure ] || [ "$V_TELEMETRY" = failure ] || [ "$V_REFERENCES" = failure ] || [ "$V_GENERATOR" = failure ]; then
echo "::error::One or more checks failed — see the VERDICT line and the failing steps above."
exit 1
fi
regenerate:
# GITHUB_TOKEN pushes do not trigger another push workflow run.
if: github.event_name == 'push'
permissions:
contents: write
# Serialize regen runs: two quick merges must not race each other's push
# (a lost race can leave main's catalog stale until the next push).
concurrency:
group: regenerate-main
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- name: Checkout latest main
uses: actions/checkout@v4
with:
ref: main
- name: Set up uv
uses: astral-sh/setup-uv@v4
- name: Resolve bot identity
id: bot
env:
GH_TOKEN: ${{ github.token }}
BOT_LOGIN: github-actions[bot]
run: |
USER_ID=$(gh api "/users/$BOT_LOGIN" --jq .id)
echo "name=$BOT_LOGIN" >> "$GITHUB_OUTPUT"
echo "email=${USER_ID}+${BOT_LOGIN}@users.noreply.github.com" >> "$GITHUB_OUTPUT"
- name: Regenerate and push catalog
run: |
git config user.name "${{ steps.bot.outputs.name }}"
git config user.email "${{ steps.bot.outputs.email }}"
for attempt in 1 2 3; do
uv run scripts/generate_agents.py
if git diff --quiet; then
echo "Catalog already at fixed point — nothing to push."
exit 0
fi
# Gate the push on a strict manifest validation — an invalid
# catalog must never land on main. (~2 s warm, exit 1 on invalid.)
npx --yes @anthropic-ai/claude-code plugin validate --strict .
git commit -am "chore: regenerate marketplace.json, AGENTS.md and README [bot]"
if git push origin HEAD:main; then
exit 0
fi
echo "Push rejected (main moved) — refreshing and retrying ($attempt/3)."
git fetch origin main
git reset --hard origin/main
done
echo "::error::regenerate: could not push after 3 attempts — main is moving too fast; re-run this job."
exit 1