@gfargo/doorman 3.0.9 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ # Rule Authoring Reference
2
+
3
+ Complete reference for creating and configuring Doorman firewall rules.
4
+
5
+ ## Rule Structure
6
+
7
+ Every rule in the `rules` array has this shape:
8
+
9
+ ```json
10
+ {
11
+ "id": "rule_descriptive_name",
12
+ "name": "Human-Readable Name",
13
+ "description": "What this rule does and why",
14
+ "active": true,
15
+ "conditionGroup": [
16
+ { "conditions": [/* AND — all must match */] },
17
+ { "conditions": [/* OR — this group is an alternative */] }
18
+ ],
19
+ "action": { "mitigate": { "action": "deny" } }
20
+ }
21
+ ```
22
+
23
+ ### Fields
24
+
25
+ | Field | Type | Required | Description |
26
+ |-------|------|----------|-------------|
27
+ | `id` | string | No | Unique identifier. Convention: `rule_` prefix, snake_case. Auto-generated if omitted. |
28
+ | `name` | string | Yes | Display name shown in tables and logs. |
29
+ | `description` | string | No | Explains the rule's purpose. Improves health score. |
30
+ | `active` | boolean | Yes | `true` to enforce, `false` to disable without deleting. |
31
+ | `conditionGroup` | array | Yes | Array of condition groups (see below). |
32
+ | `action` | object | Yes | What to do when conditions match (see below). |
33
+
34
+ ## Condition Groups
35
+
36
+ ```json
37
+ "conditionGroup": [
38
+ { "conditions": [/* AND — all must match */] },
39
+ { "conditions": [/* OR — this group is an alternative */] }
40
+ ]
41
+ ```
42
+
43
+ **Logic**:
44
+ - Conditions **within** a group: AND (all must match)
45
+ - **Between** groups: OR (any group matching triggers the rule)
46
+
47
+ ## Conditions
48
+
49
+ Each condition has:
50
+
51
+ ```json
52
+ { "type": "field_type", "op": "operator", "value": "match_value", "key": "header_name", "neg": false }
53
+ ```
54
+
55
+ | Field | Type | Required | Description |
56
+ |-------|------|----------|-------------|
57
+ | `type` | string | Yes | What to match against (see types below). |
58
+ | `op` | string | Yes | How to compare (see operators below). |
59
+ | `value` | string/number/array | Yes | Value to match. Arrays for `inc` operator. |
60
+ | `key` | string | No | Required for `header`, `query`, `cookie` types. |
61
+ | `neg` | boolean | No | `true` to negate the condition (NOT logic). Default: `false`. |
62
+
63
+ ### Condition Types
64
+
65
+ | Type | Description | Example Value |
66
+ |------|-------------|---------------|
67
+ | `path` | URL path | `"/api/users"` |
68
+ | `method` | HTTP method | `"POST"` |
69
+ | `host` | Hostname | `"example.com"` |
70
+ | `user_agent` | User-Agent header | `"Googlebot"` |
71
+ | `ip_address` | Client IP | `"192.168.1.1"` |
72
+ | `header` | HTTP header (requires `key`) | `"application/json"` |
73
+ | `query` | Query parameter (requires `key`) | `"true"` |
74
+ | `cookie` | Cookie value (requires `key`) | `"session_abc"` |
75
+ | `geo_country` | Country code (ISO 3166-1) | `"US"` or `["US","CA"]` |
76
+ | `geo_city` | City name | `"New York"` |
77
+ | `geo_continent` | Continent code | `"NA"` |
78
+ | `geo_country_region` | Region/state code | `"CA"` |
79
+ | `geo_as_number` | ASN number | `13335` |
80
+ | `scheme` | URL scheme | `"https"` |
81
+ | `protocol` | HTTP protocol version | `"HTTP/2"` |
82
+
83
+ **Vercel-only types** (not available on Cloudflare):
84
+ - `environment` — deployment environment (`"production"`, `"preview"`)
85
+ - `ja3_digest` — TLS fingerprint
86
+ - `ja4_digest` — TLS fingerprint v4
87
+ - `region` — Vercel edge region
88
+ - `rate_limit_api_id` — rate limit API identifier
89
+
90
+ ### Operators
91
+
92
+ | Operator | Name | Description | Value Type |
93
+ |----------|------|-------------|------------|
94
+ | `eq` | Equals | Exact match | string/number |
95
+ | `pre` | Prefix | Starts with | string |
96
+ | `suf` | Suffix | Ends with | string |
97
+ | `sub` | Substring | Contains | string |
98
+ | `inc` | Includes | Is any of (array match) | string[] |
99
+ | `re` | Regex | Regular expression match | string (regex pattern) |
100
+ | `ex` | Exists | Field/header exists | `true` |
101
+ | `nex` | Not Exists | Field/header does not exist | `true` |
102
+
103
+ ### Using `key` for Header/Query/Cookie
104
+
105
+ ```json
106
+ { "type": "header", "op": "eq", "value": "application/json", "key": "Content-Type" }
107
+ { "type": "query", "op": "eq", "value": "true", "key": "debug" }
108
+ { "type": "cookie", "op": "ex", "value": true, "key": "session_id" }
109
+ ```
110
+
111
+ ### Using `neg` for Negation
112
+
113
+ ```json
114
+ { "type": "geo_country", "op": "inc", "value": ["US", "CA", "GB"], "neg": true }
115
+ ```
116
+
117
+ This matches requests NOT from US, CA, or GB.
118
+
119
+ ## Actions
120
+
121
+ ### Deny (Block)
122
+
123
+ ```json
124
+ { "action": { "mitigate": { "action": "deny" } } }
125
+ ```
126
+
127
+ ### Deny with Duration
128
+
129
+ ```json
130
+ { "action": { "mitigate": { "action": "deny", "actionDuration": "1h" } } }
131
+ ```
132
+
133
+ Duration formats: `"30s"`, `"5m"`, `"1h"`, `"1d"`, `"permanent"`
134
+
135
+ ### Challenge (CAPTCHA)
136
+
137
+ ```json
138
+ { "action": { "mitigate": { "action": "challenge" } } }
139
+ ```
140
+
141
+ ### Rate Limit
142
+
143
+ ```json
144
+ {
145
+ "action": {
146
+ "mitigate": {
147
+ "action": "rate_limit",
148
+ "rateLimit": {
149
+ "requests": 100,
150
+ "window": "60s"
151
+ }
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
157
+ | Field | Type | Required | Description |
158
+ |-------|------|----------|-------------|
159
+ | `requests` | number | Yes | Max requests allowed in window. |
160
+ | `window` | string | Yes | Time window: `"10s"`, `"1m"`, `"5m"`, `"1h"` |
161
+ | `characteristics` | string[] | No | What to rate limit by. Default: `["ip.src"]`. Options: `"ip.src"`, `"http.request.uri.path"`, `"http.request.headers[\"user-agent\"]"` |
162
+ | `mitigationTimeout` | number | No | How long (seconds) to block after limit exceeded. Default: 3600. |
163
+ | `countingExpression` | string | No | Cloudflare-specific: expression for what counts toward the limit. |
164
+
165
+ ### Redirect
166
+
167
+ ```json
168
+ {
169
+ "action": {
170
+ "mitigate": {
171
+ "action": "redirect",
172
+ "redirect": { "location": "https://example.com/blocked", "permanent": false }
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ | Field | Type | Description |
179
+ |-------|------|-------------|
180
+ | `location` | string | Redirect URL (absolute or relative path). |
181
+ | `permanent` | boolean | `true` for 301, `false` for 302. |
182
+
183
+ ### Log Only
184
+
185
+ ```json
186
+ { "action": { "mitigate": { "action": "log" } } }
187
+ ```
188
+
189
+ ### Bypass
190
+
191
+ ```json
192
+ { "action": { "mitigate": { "action": "bypass" } } }
193
+ ```
194
+
195
+ ## IP Blocking Rules
196
+
197
+ IP rules go in the `ips` array, separate from `rules`:
198
+
199
+ ```json
200
+ {
201
+ "ips": [
202
+ {
203
+ "id": "ip_malicious_actor",
204
+ "ip": "192.168.1.100/32",
205
+ "hostname": "attacker.example.com",
206
+ "action": "deny",
207
+ "notes": "Blocked 2024-01-15: repeated brute force attempts"
208
+ }
209
+ ]
210
+ }
211
+ ```
212
+
213
+ | Field | Type | Required | Description |
214
+ |-------|------|----------|-------------|
215
+ | `id` | string | No | Unique identifier. |
216
+ | `ip` | string | Yes | IP address or CIDR range. Use `/32` for single IPs. |
217
+ | `hostname` | string | No | Associated hostname (documentation only). |
218
+ | `action` | string | Yes | `"deny"` (only supported value currently). |
219
+ | `notes` | string | No | Why this IP was blocked. |
220
+
221
+ ### CIDR Notation
222
+
223
+ ```
224
+ 192.168.1.1/32 -> single IP
225
+ 192.168.1.0/24 -> 256 IPs (192.168.1.0 - 192.168.1.255)
226
+ 10.0.0.0/8 -> class A range
227
+ 0.0.0.0/0 -> all IPv4 (use with caution!)
228
+ ```
229
+
230
+ ## Common Patterns
231
+
232
+ ### Block multiple countries
233
+
234
+ ```json
235
+ {
236
+ "name": "OFAC Sanctions Compliance",
237
+ "active": true,
238
+ "conditionGroup": [
239
+ { "conditions": [{ "type": "geo_country", "op": "inc", "value": ["CU", "IR", "KP", "SY", "RU"] }] }
240
+ ],
241
+ "action": { "mitigate": { "action": "deny" } }
242
+ }
243
+ ```
244
+
245
+ ### Allow only specific countries (block everyone else)
246
+
247
+ ```json
248
+ {
249
+ "name": "Allow Only US/CA/GB",
250
+ "active": true,
251
+ "conditionGroup": [
252
+ { "conditions": [{ "type": "geo_country", "op": "inc", "value": ["US", "CA", "GB"], "neg": true }] }
253
+ ],
254
+ "action": { "mitigate": { "action": "deny" } }
255
+ }
256
+ ```
257
+
258
+ ### Rate limit API with path + method
259
+
260
+ ```json
261
+ {
262
+ "name": "Rate Limit POST to API",
263
+ "active": true,
264
+ "conditionGroup": [
265
+ { "conditions": [
266
+ { "type": "path", "op": "pre", "value": "/api/" },
267
+ { "type": "method", "op": "eq", "value": "POST" }
268
+ ]}
269
+ ],
270
+ "action": {
271
+ "mitigate": {
272
+ "action": "rate_limit",
273
+ "rateLimit": { "requests": 50, "window": "1m" },
274
+ "actionDuration": "5m"
275
+ }
276
+ }
277
+ }
278
+ ```
279
+
280
+ ### Block by header (missing auth)
281
+
282
+ ```json
283
+ {
284
+ "name": "Block Missing API Key",
285
+ "active": true,
286
+ "conditionGroup": [
287
+ { "conditions": [
288
+ { "type": "path", "op": "pre", "value": "/api/" },
289
+ { "type": "header", "op": "nex", "value": true, "key": "X-API-Key" }
290
+ ]}
291
+ ],
292
+ "action": { "mitigate": { "action": "deny" } }
293
+ }
294
+ ```
295
+
296
+ ### Challenge suspicious user agents
297
+
298
+ ```json
299
+ {
300
+ "name": "Challenge Suspicious Bots",
301
+ "active": true,
302
+ "conditionGroup": [
303
+ { "conditions": [{ "type": "user_agent", "op": "sub", "value": "curl" }] },
304
+ { "conditions": [{ "type": "user_agent", "op": "sub", "value": "wget" }] },
305
+ { "conditions": [{ "type": "user_agent", "op": "sub", "value": "python-requests" }] }
306
+ ],
307
+ "action": { "mitigate": { "action": "challenge" } }
308
+ }
309
+ ```
310
+
311
+ ### Block WordPress attack paths (regex)
312
+
313
+ ```json
314
+ {
315
+ "name": "Block WordPress Paths",
316
+ "active": true,
317
+ "conditionGroup": [
318
+ { "conditions": [{ "type": "path", "op": "re", "value": "/(wp-admin|wp-login\\.php|xmlrpc\\.php|wp-content)" }] }
319
+ ],
320
+ "action": { "mitigate": { "action": "deny" } }
321
+ }
322
+ ```
323
+
324
+ ### Redirect old paths
325
+
326
+ ```json
327
+ {
328
+ "name": "Redirect Legacy API",
329
+ "active": true,
330
+ "conditionGroup": [
331
+ { "conditions": [{ "type": "path", "op": "pre", "value": "/v1/api/" }] }
332
+ ],
333
+ "action": {
334
+ "mitigate": {
335
+ "action": "redirect",
336
+ "redirect": { "location": "/v2/api/", "permanent": true }
337
+ }
338
+ }
339
+ }
340
+ ```
341
+
342
+ ## Health Score Tips
343
+
344
+ To maximize your configuration health score:
345
+
346
+ - Add `description` to every rule
347
+ - Use `id` fields with `rule_` prefix and snake_case
348
+ - Avoid regex when simpler operators work
349
+ - Remove disabled rules you no longer need
350
+ - Include rate limiting for API endpoints
351
+ - Include bot protection rules
352
+ - Use IP blocking for known threats
@@ -0,0 +1,191 @@
1
+ # Templates Reference
2
+
3
+ Pre-built rule templates for common security patterns. Add them to your config with a single command.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ doorman template # Browse available templates interactively
9
+ doorman template <name> # Add a specific template
10
+ doorman template <name> --config ./path/to/config.json # Add to specific config
11
+ ```
12
+
13
+ Templates append rules to your existing config. Run `doorman validate` after adding to confirm compatibility.
14
+
15
+ ## Available Templates
16
+
17
+ ### ai-bots
18
+
19
+ **Block AI Bots Firewall Rule**
20
+
21
+ Detects and logs known AI crawlers and training data scrapers. Default action is `log` — change to `deny` or `challenge` after reviewing traffic.
22
+
23
+ ```bash
24
+ doorman template ai-bots
25
+ ```
26
+
27
+ Detected bots include: GPTBot, ChatGPT-User, ClaudeBot, Claude-Web, Bytespider, CCBot, Google-Extended, GoogleOther, Amazonbot, Applebot-Extended, Meta-ExternalAgent, PerplexityBot, OAI-SearchBot, Diffbot, anthropic-ai, cohere-ai, and many more.
28
+
29
+ **Rule added:**
30
+ ```json
31
+ {
32
+ "id": "rule_detect_ai_bots",
33
+ "name": "Detect AI Bots",
34
+ "active": true,
35
+ "conditionGroup": [
36
+ { "conditions": [{ "type": "user_agent", "op": "re", "value": "GPTBot|ChatGPT-User|ClaudeBot|..." }] }
37
+ ],
38
+ "action": { "mitigate": { "action": "log" } }
39
+ }
40
+ ```
41
+
42
+ **Customization**: Change `"action": "log"` to `"action": "deny"` to block, or `"action": "challenge"` to CAPTCHA.
43
+
44
+ **Note**: Uses `re` (regex) operator — works on all Vercel plans, but Cloudflare Enterprise only. For Cloudflare non-Enterprise, split into multiple `sub` (contains) conditions instead.
45
+
46
+ ---
47
+
48
+ ### bad-bots
49
+
50
+ **Block Bad Bots Firewall Rule**
51
+
52
+ Detects known malicious bots, scrapers, vulnerability scanners, and attack tools. Comprehensive list of 500+ known bad user agents.
53
+
54
+ ```bash
55
+ doorman template bad-bots
56
+ ```
57
+
58
+ Includes: scrapers (HTTrack, WebCopier, SiteRipper), vulnerability scanners (Nikto, Nmap, Acunetix, SQLmap, WPScan, Nuclei), SEO crawlers (AhrefsBot, SemrushBot, MJ12bot, DotBot), and spam/attack tools (Zeus, Havij, Dirbuster).
59
+
60
+ **Rule added:**
61
+ ```json
62
+ {
63
+ "id": "rule_detect_bad_bots",
64
+ "name": "Detect Bad Bots",
65
+ "active": true,
66
+ "conditionGroup": [
67
+ { "conditions": [{ "type": "user_agent", "op": "re", "value": "AhrefsBot|SemrushBot|MJ12bot|..." }] }
68
+ ],
69
+ "action": { "mitigate": { "action": "log" } }
70
+ }
71
+ ```
72
+
73
+ **Customization**: Same as ai-bots — change action to `deny` for production blocking. Consider `challenge` for borderline bots.
74
+
75
+ ---
76
+
77
+ ### block-ofac-sanctioned-countries
78
+
79
+ **Block OFAC-Sanctioned Countries**
80
+
81
+ Blocks traffic from countries under US OFAC (Office of Foreign Assets Control) sanctions. Enforces a one-hour persistent block after first violation.
82
+
83
+ ```bash
84
+ doorman template block-ofac-sanctioned-countries
85
+ ```
86
+
87
+ Countries blocked: Syria (SY), Iran (IR), Russia (RU), Cuba (CU), North Korea (KP).
88
+
89
+ **Rule added:**
90
+ ```json
91
+ {
92
+ "id": "rule_block_traffic_from_ofac_sanctioned_countries",
93
+ "name": "Block traffic from OFAC-sanctioned countries",
94
+ "description": "Blocks traffic from OFAC-sanctioned countries and enforces a one-hour persistent block after the first violation.",
95
+ "active": true,
96
+ "conditionGroup": [
97
+ { "conditions": [{ "type": "geo_country", "op": "inc", "value": ["SY", "IR", "RU", "CU", "KP"] }] }
98
+ ],
99
+ "action": { "mitigate": { "action": "deny", "actionDuration": "1h" } }
100
+ }
101
+ ```
102
+
103
+ **Customization**: Add or remove country codes as sanctions change. Consult https://sanctionssearch.ofac.treas.gov/ for the current list.
104
+
105
+ ---
106
+
107
+ ### wordpress
108
+
109
+ **Deny Common WordPress URLs Firewall Rule**
110
+
111
+ Blocks requests to WordPress-specific paths that are common attack vectors on non-WordPress sites. If your site does not run WordPress, these paths should never receive legitimate traffic.
112
+
113
+ ```bash
114
+ doorman template wordpress
115
+ ```
116
+
117
+ Paths blocked: `/wp-admin`, `/wp-login.php`, `/xmlrpc.php`, `/wp-content`, `/wp-includes`, `/wp-signup.php`, `/wp-activate.php`, `/register.php`, `/wp-register.php`
118
+
119
+ **Rule added:**
120
+ ```json
121
+ {
122
+ "name": "Deny WordPress URLs",
123
+ "active": true,
124
+ "conditionGroup": [
125
+ { "conditions": [{ "type": "path", "op": "re", "value": "/(wp-admin|wp-login\\.php|xmlrpc\\.php|wp-content|wp-includes|wp-signup\\.php|wp-activate\\.php|register\\.php|wp-register\\.php)" }] }
126
+ ],
127
+ "action": { "mitigate": { "action": "deny" } }
128
+ }
129
+ ```
130
+
131
+ **Note**: Uses regex — for Cloudflare non-Enterprise, split into multiple `pre` (prefix) conditions:
132
+ ```json
133
+ "conditionGroup": [
134
+ { "conditions": [{ "type": "path", "op": "pre", "value": "/wp-admin" }] },
135
+ { "conditions": [{ "type": "path", "op": "pre", "value": "/wp-login.php" }] },
136
+ { "conditions": [{ "type": "path", "op": "pre", "value": "/xmlrpc.php" }] },
137
+ { "conditions": [{ "type": "path", "op": "pre", "value": "/wp-content" }] }
138
+ ]
139
+ ```
140
+
141
+ ## Creating Custom Templates
142
+
143
+ Templates are TypeScript modules in `src/lib/templates/rules/`. Each exports a `Template` object:
144
+
145
+ ```typescript
146
+ import { Template } from '../types'
147
+
148
+ export const myTemplate: Template = {
149
+ metadata: {
150
+ title: 'My Custom Template',
151
+ reference: 'https://example.com/docs',
152
+ },
153
+ config: {
154
+ rules: [
155
+ {
156
+ id: 'rule_my_template',
157
+ name: 'My Rule',
158
+ description: 'What it does',
159
+ active: true,
160
+ conditionGroup: [
161
+ { conditions: [{ type: 'path', op: 'pre', value: '/protected' }] }
162
+ ],
163
+ action: { mitigate: { action: 'deny' } },
164
+ },
165
+ ],
166
+ },
167
+ }
168
+ ```
169
+
170
+ Register it in `src/lib/templates/index.ts` to make it available via `doorman template <name>`.
171
+
172
+ ## Template Best Practices
173
+
174
+ 1. **Start with `log` action** — observe traffic before blocking. Switch to `deny` once confident.
175
+ 2. **Add descriptions** — templates without descriptions lower the health score.
176
+ 3. **Combine templates** — use multiple templates together for layered security.
177
+ 4. **Validate after adding** — always run `doorman validate` after adding a template.
178
+ 5. **Review regex patterns** — template regex patterns are comprehensive but may need tuning for your specific use case.
179
+
180
+ ## Recommended Starter Stack
181
+
182
+ ```bash
183
+ doorman template ai-bots
184
+ doorman template bad-bots
185
+ doorman template wordpress
186
+ doorman template block-ofac-sanctioned-countries
187
+ doorman validate
188
+ doorman sync
189
+ ```
190
+
191
+ This gives you bot protection, attack path blocking, and geo-compliance in under a minute.
@@ -0,0 +1,43 @@
1
+ # Auto-Sync to gfargo/skills
2
+
3
+ These files are templates for setting up auto-sync to the [gfargo/skills](https://github.com/gfargo/skills) central skills repository.
4
+
5
+ ## Setup Steps
6
+
7
+ 1. **In `gfargo/skills`**, create the plugin directory structure:
8
+
9
+ ```
10
+ plugins/security/
11
+ plugins/security/.claude-plugin/plugin.json <- copy plugin.json here
12
+ plugins/security/skills/doorman/ <- synced from this repo
13
+ ```
14
+
15
+ 2. **Copy `sync-doorman.yml`** to `gfargo/skills/.github/workflows/sync-doorman.yml`
16
+
17
+ 3. **Update `gfargo/skills/.claude-plugin/marketplace.json`** to add the security plugin:
18
+
19
+ ```json
20
+ {
21
+ "name": "security",
22
+ "source": "./plugins/security",
23
+ "description": "Web application firewall management as code with Doorman: create rules, block IPs, rate limit, protect against bots, and deploy WAF configs to Vercel and Cloudflare.",
24
+ "version": "1.0.0",
25
+ "author": { "name": "Griffen Fargo" }
26
+ }
27
+ ```
28
+
29
+ 4. The workflow runs daily and on `workflow_dispatch`. It:
30
+ - Checks the latest release tag on `gfargo/doorman`
31
+ - Compares against `.source-version` in the destination
32
+ - If newer, clones at that tag, mirrors `skills/doorman/` content
33
+ - Bumps the security plugin version, commits, tags, and creates a release
34
+
35
+ ## Installation (end users)
36
+
37
+ Once published:
38
+
39
+ ```bash
40
+ npx skills add gfargo/skills --skill doorman
41
+ ```
42
+
43
+ Or via the `gfargo/skills` README install instructions.
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "security",
3
+ "description": "Web application firewall management as code with Doorman: create rules, block IPs, rate limit, protect against bots, and deploy WAF configs to Vercel and Cloudflare.",
4
+ "version": "1.0.0",
5
+ "author": {
6
+ "name": "Griffen Fargo",
7
+ "email": "ghfargo@gmail.com"
8
+ },
9
+ "homepage": "https://github.com/gfargo/doorman",
10
+ "keywords": [
11
+ "security",
12
+ "firewall",
13
+ "waf",
14
+ "vercel",
15
+ "cloudflare",
16
+ "doorman",
17
+ "iac",
18
+ "rules",
19
+ "ip-blocking",
20
+ "rate-limiting",
21
+ "bot-protection"
22
+ ]
23
+ }
@@ -0,0 +1,80 @@
1
+ name: Sync doorman skill from source
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '17 14 * * *'
6
+ workflow_dispatch: {}
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ env:
12
+ SOURCE_REPO: gfargo/doorman
13
+ SOURCE_SKILL_PATH: skills/doorman
14
+ DEST_SKILL_PATH: plugins/security/skills/doorman
15
+ PLUGIN_JSON: plugins/security/.claude-plugin/plugin.json
16
+ MARKETPLACE_JSON: .claude-plugin/marketplace.json
17
+
18
+ jobs:
19
+ sync:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Check latest doorman release
25
+ id: check
26
+ env:
27
+ GH_TOKEN: ${{ github.token }}
28
+ run: |
29
+ latest=$(gh api "repos/${SOURCE_REPO}/releases/latest" --jq '.tag_name')
30
+ current=$(cat "${DEST_SKILL_PATH}/.source-version" 2>/dev/null || echo "none")
31
+ echo "latest=$latest" >> "$GITHUB_OUTPUT"
32
+ echo "current=$current" >> "$GITHUB_OUTPUT"
33
+ if [ "$latest" = "$current" ]; then
34
+ echo "up_to_date=true" >> "$GITHUB_OUTPUT"
35
+ else
36
+ echo "up_to_date=false" >> "$GITHUB_OUTPUT"
37
+ fi
38
+
39
+ - name: Fetch source at latest tag
40
+ if: steps.check.outputs.up_to_date == 'false'
41
+ run: |
42
+ git clone --depth 1 --branch "${{ steps.check.outputs.latest }}" \
43
+ "https://github.com/${SOURCE_REPO}.git" /tmp/source
44
+
45
+ - name: Mirror skill content
46
+ if: steps.check.outputs.up_to_date == 'false'
47
+ run: |
48
+ rsync -a --delete --exclude='sync/' "/tmp/source/${SOURCE_SKILL_PATH}/" "${DEST_SKILL_PATH}/"
49
+ echo "${{ steps.check.outputs.latest }}" > "${DEST_SKILL_PATH}/.source-version"
50
+
51
+ - name: Bump security plugin version
52
+ if: steps.check.outputs.up_to_date == 'false'
53
+ id: bump
54
+ run: |
55
+ old=$(jq -r '.version' "${PLUGIN_JSON}")
56
+ IFS='.' read -r major minor patch <<< "$old"
57
+ new="${major}.${minor}.$((patch + 1))"
58
+ jq --arg v "$new" '.version = $v' "${PLUGIN_JSON}" > "${PLUGIN_JSON}.tmp" && mv "${PLUGIN_JSON}.tmp" "${PLUGIN_JSON}"
59
+ jq --arg v "$new" '(.plugins[] | select(.name == "security") | .version) = $v' "${MARKETPLACE_JSON}" > "${MARKETPLACE_JSON}.tmp" && mv "${MARKETPLACE_JSON}.tmp" "${MARKETPLACE_JSON}"
60
+ echo "new_version=$new" >> "$GITHUB_OUTPUT"
61
+
62
+ - name: Commit and tag
63
+ if: steps.check.outputs.up_to_date == 'false'
64
+ run: |
65
+ git config user.name "github-actions[bot]"
66
+ git config user.email "github-actions[bot]@users.noreply.github.com"
67
+ git add "${DEST_SKILL_PATH}" "${PLUGIN_JSON}" "${MARKETPLACE_JSON}"
68
+ git commit -m "sync: doorman ${{ steps.check.outputs.latest }} (security v${{ steps.bump.outputs.new_version }})"
69
+ git tag "v${{ steps.bump.outputs.new_version }}"
70
+ git push origin HEAD:main
71
+ git push origin "v${{ steps.bump.outputs.new_version }}"
72
+
73
+ - name: Create release
74
+ if: steps.check.outputs.up_to_date == 'false'
75
+ env:
76
+ GH_TOKEN: ${{ github.token }}
77
+ run: |
78
+ gh release create "v${{ steps.bump.outputs.new_version }}" \
79
+ --title "security v${{ steps.bump.outputs.new_version }} — doorman ${{ steps.check.outputs.latest }}" \
80
+ --notes "Synced security:doorman to [gfargo/doorman@${{ steps.check.outputs.latest }}](https://github.com/${SOURCE_REPO}/releases/tag/${{ steps.check.outputs.latest }})"