Skip to content

Commit da31ddb

Browse files
dtomsclaude
andcommitted
feat: AppFolio spec-kit fork — specify-af-cli with bundled extensions
Single commit containing all AppFolio fork changes on top of upstream v0.6.1. Designed to sit as one commit for clean upstream merges. - Rename package to specify-af-cli with specify-af binary - Bundle af-appfolio extension (auto-installed on init) - Add upgrade command to sync extensions to bundled versions - Fix version resolution via packages_distributions() - Rewire catalog URL to appfolio/spec-kit af-main branch - Add release-please workflow with af-v* tag convention - Add FORK.md documenting changes and maintenance procedures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2499c9a commit da31ddb

31 files changed

Lines changed: 1056 additions & 19 deletions

.github/workflows/af-release.yml

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
name: AF Release
2+
3+
on:
4+
push:
5+
branches:
6+
- af-main
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
jobs:
13+
release:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Generate GitHub App token
17+
id: generate_token
18+
uses: appfolio/github-app-token-generator@v1
19+
with:
20+
app_id: 164337
21+
private_key: ${{ secrets.APPFOLIO_GITHUB_ACTIONS_PRIVATE_KEY }}
22+
23+
- name: Checkout
24+
uses: actions/checkout@v6
25+
with:
26+
fetch-depth: 0
27+
fetch-tags: true
28+
token: ${{ steps.generate_token.outputs.token }}
29+
30+
- name: Setup Python
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: "3.11"
34+
35+
- name: Compute next version
36+
id: version
37+
run: |
38+
# Read current version from pyproject.toml
39+
CURRENT=$(python3 -c "
40+
import tomllib
41+
with open('pyproject.toml', 'rb') as f:
42+
print(tomllib.load(f)['project']['version'])
43+
")
44+
echo "current=$CURRENT" >> $GITHUB_OUTPUT
45+
46+
# Find latest af-v* tag
47+
LATEST_TAG=$(git tag -l 'af-v*' --sort=-version:refname | head -n 1)
48+
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
49+
50+
# Bump fourth segment
51+
IFS='.' read -ra PARTS <<< "$CURRENT"
52+
if [ ${#PARTS[@]} -le 3 ]; then
53+
NEXT="${CURRENT}.1"
54+
else
55+
LAST=${PARTS[3]}
56+
NEXT="${PARTS[0]}.${PARTS[1]}.${PARTS[2]}.$((LAST + 1))"
57+
fi
58+
echo "next=$NEXT" >> $GITHUB_OUTPUT
59+
60+
# Check if this version is already tagged
61+
if git tag -l "af-v${NEXT}" | grep -q .; then
62+
echo "skip=true" >> $GITHUB_OUTPUT
63+
else
64+
echo "skip=false" >> $GITHUB_OUTPUT
65+
fi
66+
67+
- name: Generate changelog
68+
if: steps.version.outputs.skip == 'false'
69+
id: changelog
70+
run: |
71+
LATEST_TAG="${{ steps.version.outputs.latest_tag }}"
72+
NEXT="${{ steps.version.outputs.next }}"
73+
74+
if [ -z "$LATEST_TAG" ]; then
75+
# First release — use commits since the upstream base tag
76+
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges v0.6.1..HEAD)
77+
else
78+
COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges "${LATEST_TAG}..HEAD")
79+
fi
80+
81+
DATE=$(date +%Y-%m-%d)
82+
ENTRY="## ${NEXT} (${DATE})"$'\n\n'"${COMMITS}"
83+
84+
# Write to file for later use (avoids multiline env var issues)
85+
echo "$ENTRY" > /tmp/changelog_entry.txt
86+
echo "date=$DATE" >> $GITHUB_OUTPUT
87+
88+
- name: Check for existing release PR
89+
if: steps.version.outputs.skip == 'false'
90+
id: existing_pr
91+
run: |
92+
PR_NUM=$(gh pr list --repo ${{ github.repository }} \
93+
--head "af-release" --base "af-main" \
94+
--json number --jq '.[0].number // empty')
95+
echo "number=${PR_NUM}" >> $GITHUB_OUTPUT
96+
env:
97+
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
98+
99+
- name: Create or update release branch
100+
if: steps.version.outputs.skip == 'false'
101+
run: |
102+
NEXT="${{ steps.version.outputs.next }}"
103+
104+
git config user.name "${{ steps.generate_token.outputs.user_name }}"
105+
git config user.email "${{ steps.generate_token.outputs.user_email }}"
106+
107+
# Create/reset release branch from af-main
108+
git checkout -B af-release
109+
110+
# Bump version in pyproject.toml
111+
python3 -c "
112+
import re
113+
with open('pyproject.toml', 'r') as f:
114+
content = f.read()
115+
content = re.sub(
116+
r'(version\s*=\s*\")([^\"]+)(\")',
117+
lambda m: m.group(1) + '${NEXT}' + m.group(3),
118+
content,
119+
count=1
120+
)
121+
with open('pyproject.toml', 'w') as f:
122+
f.write(content)
123+
"
124+
125+
# Prepend changelog to FORK.md at the marker
126+
python3 << 'PYEOF'
127+
marker = "<!-- release-please writes changelog entries below this line -->"
128+
with open("/tmp/changelog_entry.txt") as f:
129+
changelog = f.read().strip()
130+
with open("FORK.md", "r") as f:
131+
content = f.read()
132+
if marker in content:
133+
content = content.replace(marker, marker + "\n\n" + changelog)
134+
else:
135+
content += "\n\n" + changelog
136+
with open("FORK.md", "w") as f:
137+
f.write(content)
138+
PYEOF
139+
140+
git add pyproject.toml FORK.md
141+
git commit -m "chore: release af-v${NEXT}"
142+
git push -f origin af-release
143+
144+
- name: Create or update PR
145+
if: steps.version.outputs.skip == 'false'
146+
run: |
147+
NEXT="${{ steps.version.outputs.next }}"
148+
EXISTING="${{ steps.existing_pr.outputs.number }}"
149+
CHANGELOG=$(cat /tmp/changelog_entry.txt)
150+
151+
BODY="$(cat <<EOF
152+
## Release af-v${NEXT}
153+
154+
${CHANGELOG}
155+
156+
---
157+
Merging this PR will trigger a tagged release with extension ZIPs.
158+
EOF
159+
)"
160+
161+
if [ -n "$EXISTING" ]; then
162+
gh pr edit "$EXISTING" \
163+
--title "chore: release af-v${NEXT}" \
164+
--body "$BODY"
165+
echo "Updated PR #${EXISTING}"
166+
else
167+
gh pr create \
168+
--title "chore: release af-v${NEXT}" \
169+
--body "$BODY" \
170+
--head af-release \
171+
--base af-main
172+
fi
173+
env:
174+
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: AF Tag Release
2+
3+
on:
4+
pull_request:
5+
types: [closed]
6+
branches:
7+
- af-main
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
tag:
14+
if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'af-release')
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Generate GitHub App token
18+
id: generate_token
19+
uses: appfolio/github-app-token-generator@v1
20+
with:
21+
app_id: 164337
22+
private_key: ${{ secrets.APPFOLIO_GITHUB_ACTIONS_PRIVATE_KEY }}
23+
24+
- name: Checkout
25+
uses: actions/checkout@v6
26+
with:
27+
token: ${{ steps.generate_token.outputs.token }}
28+
29+
- name: Read version and tag
30+
run: |
31+
VERSION=$(python3 -c "
32+
import tomllib
33+
with open('pyproject.toml', 'rb') as f:
34+
print(tomllib.load(f)['project']['version'])
35+
")
36+
TAG="af-v${VERSION}"
37+
38+
echo "Tagging ${TAG}"
39+
git tag "$TAG"
40+
git push origin "$TAG"

.github/workflows/release.yml

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: Create Release
33
on:
44
push:
55
tags:
6-
- 'v*'
6+
- 'af-v*'
77

88
jobs:
99
release:
@@ -42,10 +42,10 @@ jobs:
4242
if: steps.check_release.outputs.exists == 'false'
4343
run: |
4444
VERSION="${{ steps.version.outputs.tag }}"
45-
VERSION_NO_V=${VERSION#v}
45+
VERSION_NO_PREFIX=${VERSION#af-v}
4646
4747
# Find previous tag
48-
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${VERSION}$" | head -n 1)
48+
PREVIOUS_TAG=$(git tag -l 'af-v*' --sort=-version:refname | grep -v "^${VERSION}$" | head -n 1)
4949
if [ -z "$PREVIOUS_TAG" ]; then
5050
PREVIOUS_TAG=""
5151
fi
@@ -66,8 +66,8 @@ jobs:
6666
## Install
6767
6868
\`\`\`bash
69-
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@${VERSION}
70-
specify init my-project
69+
uv tool install specify-af-cli --from git+https://github.com/appfolio/spec-kit.git@${VERSION}
70+
specify-af init my-project
7171
\`\`\`
7272
7373
NOTES_EOF
@@ -76,14 +76,30 @@ jobs:
7676
echo "" >> release_notes.md
7777
echo "$COMMITS" >> release_notes.md
7878
79+
- name: Build extension ZIPs
80+
if: steps.check_release.outputs.exists == 'false'
81+
run: |
82+
mkdir -p dist/extensions
83+
for ext_dir in extensions/af-*/; do
84+
ext_name=$(basename "$ext_dir")
85+
if [ -f "$ext_dir/extension.yml" ]; then
86+
(cd extensions && zip -r "../dist/extensions/${ext_name}.zip" "$ext_name")
87+
echo "Built ${ext_name}.zip"
88+
fi
89+
done
90+
7991
- name: Create GitHub Release
8092
if: steps.check_release.outputs.exists == 'false'
8193
run: |
8294
VERSION="${{ steps.version.outputs.tag }}"
83-
VERSION_NO_V=${VERSION#v}
95+
VERSION_NO_PREFIX=${VERSION#af-v}
96+
ASSETS=""
97+
for zip in dist/extensions/*.zip; do
98+
[ -f "$zip" ] && ASSETS="$ASSETS $zip"
99+
done
84100
gh release create "$VERSION" \
85-
--title "Spec Kit - $VERSION_NO_V" \
86-
--notes-file release_notes.md
101+
--title "Specify AF CLI - $VERSION_NO_PREFIX" \
102+
--notes-file release_notes.md \
103+
$ASSETS
87104
env:
88105
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
89-

FORK.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# AppFolio Spec Kit Fork (specify-af-cli)
2+
3+
This is AppFolio's fork of [github/spec-kit](https://github.com/github/spec-kit). It bundles AppFolio-specific extensions into the CLI for zero-config developer setup.
4+
5+
## What's Changed from Upstream
6+
7+
| Area | Change |
8+
|------|--------|
9+
| Package name | `specify-cli``specify-af-cli` |
10+
| Binary name | `specify``specify-af` |
11+
| Catalog URL | Points at `appfolio/spec-kit` `af-main` branch |
12+
| Init flow | Auto-installs bundled AF extensions after scaffold |
13+
| New command | `specify-af upgrade` — syncs AF extensions to bundled versions |
14+
| Version resolution | Uses `packages_distributions()` instead of hardcoded package name |
15+
| Release workflow | release-please with `af-v*` tags on `af-main` |
16+
17+
## Conflict-Prone Files
18+
19+
When merging upstream releases, expect conflicts in these files:
20+
21+
- `pyproject.toml` — keep AF name, console script, and force-include entries
22+
- `src/specify_cli/__init__.py` — keep version fix (`_get_distribution_name`), init hook (`install_af_extensions`), upgrade command, panel title
23+
- `src/specify_cli/extensions.py` — keep AF catalog URL
24+
- `src/specify_cli/presets.py` — keep AF package name in error message
25+
- `extensions/catalog.json` — keep AF entries and catalog URL
26+
- `.github/workflows/release.yml` — keep `af-v*` tag filter, AF install URL, extension ZIP step
27+
28+
## How to Maintain
29+
30+
### Local Development Setup
31+
32+
```bash
33+
git clone git@github.com:appfolio/spec-kit.git
34+
cd spec-kit
35+
git checkout af-main
36+
uv tool install . --force
37+
specify-af version
38+
```
39+
40+
### Making Changes
41+
42+
```bash
43+
git checkout -b feat/my-feature af-main
44+
# Make changes (use Conventional Commits: feat:, fix:, chore:, etc.)
45+
uv tool install . --force --reinstall
46+
cd /path/to/test-project && specify-af init --here --ai claude
47+
git push -u origin feat/my-feature
48+
# Open PR targeting af-main
49+
```
50+
51+
### Adding a New AF Extension
52+
53+
1. Create `extensions/af-<name>/` with `extension.yml`, `README.md`, `commands/`
54+
2. Add `"af-<name>"` to `AF_EXTENSION_IDS` in `src/specify_cli/af_init.py`
55+
3. Add `"extensions/af-<name>" = "specify_cli/core_pack/extensions/af-<name>"` to `force-include` in `pyproject.toml`
56+
4. Add entry to `extensions/catalog.json`
57+
58+
### Integrating an Upstream Release
59+
60+
```bash
61+
git fetch upstream --tags
62+
git checkout af-main
63+
git merge v<upstream-version>
64+
# Resolve conflicts (see Conflict-Prone Files above)
65+
# Test: uv tool install . --force --reinstall && specify-af init --here
66+
```
67+
68+
If integration work is needed:
69+
```bash
70+
git checkout -b feat/integrate-v<version>
71+
# Fix, test, open PR to af-main
72+
```
73+
74+
## Release History
75+
76+
<!-- release-please writes changelog entries below this line -->

extensions/af-appfolio/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# AppFolio Extension for Spec Kit
2+
3+
AppFolio lifecycle hooks and commands for Spec Kit. This extension provides hook points at every stage of the spec-kit workflow, allowing AppFolio-specific behavior to be injected before and after each operation.
4+
5+
## Hook Points
6+
7+
| Lifecycle Phase | Before | After |
8+
|----------------|--------|-------|
9+
| specify | `before_specify` | `after_specify` |
10+
| plan | `before_plan` | `after_plan` |
11+
| tasks | `before_tasks` | `after_tasks` |
12+
| implement | `before_implement` | `after_implement` |
13+
| analyze | `before_analyze` | `after_analyze` |
14+
| checklist | `before_checklist` | `after_checklist` |
15+
| clarify | `before_clarify` | `after_clarify` |
16+
| constitution | `before_constitution` | `after_constitution` |
17+
| taskstoissues | `before_taskstoissues` | `after_taskstoissues` |
18+
19+
## Installation
20+
21+
This extension is bundled with `af-specify-cli` and installed automatically during `af-specify init`.

0 commit comments

Comments
 (0)