From 58bd1275a644fb754a869d3dd9889a3a7b540eed Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:21:07 -0400 Subject: [PATCH] fix: align scaffold output with current ecosystem patterns The scaffold under scaffold/templates/ drifted from the patterns adopted across the 9 existing tool repos. New tool repos created from the scaffold required substantial post-creation fixes to reach current standards. This change brings scaffold output forward to current and adds CI regression protection. Per DTD#41, eight named gaps: - validate.yml.j2: add validate-counts job that compares skills/ and rules/ directory counts against README.md (DTD#39 pattern from Plaid). - drift-check.yml.j2: new template, pinned to drift-check@v1.9 with mode: self and gh-summary format. - release.yml.j2: now consumes release-doc-sync@v1, adopts Blender's initial-release version-handling branch (holds version at current manifest value when no prior tag exists), and adds floating major and major.minor tag automation (git tag -f then push --force). - label-sync.yml.j2: new template using Home-Lab's self-healing per-label gh label create --force pattern (DTD#4). - dependabot.yml.j2: new template with github-actions ecosystem entry plus pip ecosystem for the mcp-server directory when --mcp-server is set. - CLAUDE.md.j2: add **Version:** 0.1.0 / **License:** / **Author:** lines so release-doc-sync can rewrite the version line on every release. - ROADMAP.md.j2: add marker and **Current:** v0.1.0 line so release-doc-sync can update it. - AGENTS.md.j2: confirmed already carries the standards-version marker; no change needed. Two additional gaps from the Blender v0.1.1 compliance fix: - LICENSE.j2 (gap 9): the cc-by-nc-nd-4.0 default branch is now aligned byte-for-byte with Plaid's LICENSE: copyright "TM Hospitality Strategies" (the ecosystem entity name, not the GitHub org), the canonical CC text, and a trailing SPDX-License-Identifier: CC-BY-NC-ND-4.0 line. The previous text used {{ author_name }} (defaulting to "TMHSDigital") and was missing the SPDX identifier. - CONTRIBUTING.md.j2 (gap 10): standards/licensing.md mandates the inbound DCO + license grant in every CONTRIBUTING.md (verbatim grant paragraph plus Signed-off-by guidance). The scaffold now emits this section. Existing tool repos that predate this change need a backfill PR per repo, tracked separately. Pre-existing scaffold bugs surfaced and fixed in the same diff so the scaffold output actually parses as valid YAML now: - pages.yml.j2 used a per-line {% raw %}${{ }}{% endraw %} pattern that Jinja2's trim_blocks=True collapsed onto adjacent lines, producing invalid YAML at the environment.url / runs-on boundary. - release.yml.j2 had the same trim_blocks collapse pattern on every ${{ }} reference and additionally used the broken plain-scalar python3 -c "..." form in the version-update step. - validate.yml.j2 used the same broken plain-scalar python3 -c "..." form across three steps; rewritten as block-scalar python3 << 'PYEOF' heredocs which match Plaid's pattern. CI regression protection (.github/workflows/validate.yml): - validate-scaffold now installs PyYAML alongside Jinja2, and after the dry-run runs yaml.safe_load on every emitted .yml file. This catches any future trim_blocks-style escaping bug at PR time. - validate-scaffold gained a Scaffold regression checks for DTD#41 patterns step that greps for each named pattern (validate-counts, drift-check@v1.9, release-doc-sync@v1, initial-release branch, floating major tag, self-healing labels, github-actions ecosystem, **Version:** line, **Current:** line, standards-version markers, TM Hospitality Strategies copyright, SPDX identifier, DCO). - File-existence list extended to cover release.yml, stale.yml, drift-check.yml, label-sync.yml, dependabot.yml. Existing tool repos are unaffected by this change. Future scaffold- generated repos land at current standards from creation, with no post-creation cleanup required. Closes #41 Made-with: Cursor --- .github/workflows/validate.yml | 97 ++++++++++++++++++++- VERSION | 2 +- scaffold/create-tool.py | 5 ++ scaffold/templates/CLAUDE.md.j2 | 4 + scaffold/templates/CONTRIBUTING.md.j2 | 20 ++++- scaffold/templates/LICENSE.j2 | 26 ++++-- scaffold/templates/ROADMAP.md.j2 | 4 + scaffold/templates/dependabot.yml.j2 | 12 +++ scaffold/templates/drift-check.yml.j2 | 21 +++++ scaffold/templates/label-sync.yml.j2 | 68 +++++++++++++++ scaffold/templates/pages.yml.j2 | 4 +- scaffold/templates/release.yml.j2 | 117 +++++++++++++++++++------- scaffold/templates/validate.yml.j2 | 56 ++++++++++-- 13 files changed, 381 insertions(+), 55 deletions(-) create mode 100644 scaffold/templates/dependabot.yml.j2 create mode 100644 scaffold/templates/drift-check.yml.j2 create mode 100644 scaffold/templates/label-sync.yml.j2 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 292a368..44ca4cc 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -92,12 +92,12 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies - run: pip install Jinja2 + run: pip install Jinja2 PyYAML - name: Check scaffold syntax run: python3 -m py_compile scaffold/create-tool.py @@ -114,12 +114,101 @@ jobs: test -f /tmp/scaffold-test/ci-test-plugin/.cursor-plugin/plugin.json test -f /tmp/scaffold-test/ci-test-plugin/.github/workflows/validate.yml + test -f /tmp/scaffold-test/ci-test-plugin/.github/workflows/release.yml test -f /tmp/scaffold-test/ci-test-plugin/.github/workflows/pages.yml + test -f /tmp/scaffold-test/ci-test-plugin/.github/workflows/stale.yml + test -f /tmp/scaffold-test/ci-test-plugin/.github/workflows/drift-check.yml + test -f /tmp/scaffold-test/ci-test-plugin/.github/workflows/label-sync.yml + test -f /tmp/scaffold-test/ci-test-plugin/.github/dependabot.yml test -f /tmp/scaffold-test/ci-test-plugin/site.json test -f /tmp/scaffold-test/ci-test-plugin/mcp-tools.json test -f /tmp/scaffold-test/ci-test-plugin/mcp-server/server.py echo "Scaffold test passed" + - name: Scaffold YAML well-formed + run: | + # Catches Jinja2 trim_blocks bugs that collapse adjacent expression lines. + python3 -c " + import yaml, sys + paths = [ + '/tmp/scaffold-test/ci-test-plugin/.github/workflows/validate.yml', + '/tmp/scaffold-test/ci-test-plugin/.github/workflows/release.yml', + '/tmp/scaffold-test/ci-test-plugin/.github/workflows/pages.yml', + '/tmp/scaffold-test/ci-test-plugin/.github/workflows/stale.yml', + '/tmp/scaffold-test/ci-test-plugin/.github/workflows/drift-check.yml', + '/tmp/scaffold-test/ci-test-plugin/.github/workflows/label-sync.yml', + '/tmp/scaffold-test/ci-test-plugin/.github/dependabot.yml', + ] + fail = False + for p in paths: + try: + with open(p) as f: + yaml.safe_load(f) + print(f'OK {p}') + except yaml.YAMLError as e: + print(f'FAIL {p}: {e}', file=sys.stderr) + fail = True + sys.exit(1 if fail else 0) + " + + - name: Scaffold regression checks for DTD#41 patterns + run: | + # validate-counts job present + grep -q 'validate-counts' /tmp/scaffold-test/ci-test-plugin/.github/workflows/validate.yml \ + || { echo "::error::validate.yml missing validate-counts job"; exit 1; } + + # drift-check pinned to @v1.9 + grep -q 'drift-check@v1.9' /tmp/scaffold-test/ci-test-plugin/.github/workflows/drift-check.yml \ + || { echo "::error::drift-check.yml not pinned to @v1.9"; exit 1; } + + # release.yml consumes release-doc-sync@v1 + grep -q 'release-doc-sync@v1' /tmp/scaffold-test/ci-test-plugin/.github/workflows/release.yml \ + || { echo "::error::release.yml does not consume release-doc-sync@v1"; exit 1; } + + # release.yml has the initial-release version-handling branch + grep -q 'Initial release' /tmp/scaffold-test/ci-test-plugin/.github/workflows/release.yml \ + || { echo "::error::release.yml missing initial-release branch"; exit 1; } + + # release.yml does floating tag automation + grep -q 'tag -f "v\$major"' /tmp/scaffold-test/ci-test-plugin/.github/workflows/release.yml \ + || { echo "::error::release.yml missing floating major tag automation"; exit 1; } + + # label-sync uses self-healing per-label gh label create --force + grep -q 'gh label create.*--force' /tmp/scaffold-test/ci-test-plugin/.github/workflows/label-sync.yml \ + || { echo "::error::label-sync.yml missing self-healing label create"; exit 1; } + + # dependabot has github-actions ecosystem + grep -q 'package-ecosystem: "github-actions"' /tmp/scaffold-test/ci-test-plugin/.github/dependabot.yml \ + || { echo "::error::dependabot.yml missing github-actions ecosystem"; exit 1; } + + # CLAUDE.md has **Version:** line so release-doc-sync can update it + grep -q '^\*\*Version:\*\* 0\.1\.0' /tmp/scaffold-test/ci-test-plugin/CLAUDE.md \ + || { echo "::error::CLAUDE.md missing **Version:** 0.1.0 line"; exit 1; } + + # ROADMAP.md has standards-version marker and **Current:** line + grep -q ' + # Roadmap +**Current:** v0.1.0 + ## {{ name }} ### v0.1.x -- Foundation diff --git a/scaffold/templates/dependabot.yml.j2 b/scaffold/templates/dependabot.yml.j2 new file mode 100644 index 0000000..6c994cf --- /dev/null +++ b/scaffold/templates/dependabot.yml.j2 @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" +{% if has_mcp %} + - package-ecosystem: "pip" + directory: "/mcp-server" + schedule: + interval: "weekly" +{% endif %} diff --git a/scaffold/templates/drift-check.yml.j2 b/scaffold/templates/drift-check.yml.j2 new file mode 100644 index 0000000..4e858fc --- /dev/null +++ b/scaffold/templates/drift-check.yml.j2 @@ -0,0 +1,21 @@ +name: Ecosystem drift check + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +jobs: + drift-check: + name: Ecosystem drift check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: TMHSDigital/Developer-Tools-Directory/.github/actions/drift-check@v1.9 + with: + mode: self + format: gh-summary diff --git a/scaffold/templates/label-sync.yml.j2 b/scaffold/templates/label-sync.yml.j2 new file mode 100644 index 0000000..1b2baba --- /dev/null +++ b/scaffold/templates/label-sync.yml.j2 @@ -0,0 +1,68 @@ +name: Label PRs + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Auto-label by path + runs-on: ubuntu-latest + steps: +{% raw %} + - uses: actions/checkout@v4 + + - name: Get changed files + id: changed + run: | + FILES=$(gh pr diff ${{ github.event.pull_request.number }} --name-only) + echo "files<> "$GITHUB_OUTPUT" + echo "$FILES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Apply labels + run: | + LABELS="" + + if echo "$FILES" | grep -q "^skills/"; then + LABELS="$LABELS skills" + fi + + if echo "$FILES" | grep -q "^rules/"; then + LABELS="$LABELS rules" + fi +{% endraw %} +{% if has_mcp %} + + if echo "$FILES" | grep -q "^mcp-server/"; then + LABELS="$LABELS mcp-server" + fi +{% endif %} +{% raw %} + if echo "$FILES" | grep -q "^docs/"; then + LABELS="$LABELS documentation" + fi + + if echo "$FILES" | grep -q "^\.github/"; then + LABELS="$LABELS ci" + fi + + if [ -n "$LABELS" ]; then + for label in $LABELS; do + gh label create "$label" --force --color "ededed" 2>/dev/null || true + gh pr edit ${{ github.event.pull_request.number }} --add-label "$label" + done + echo "Applied labels:$LABELS" + else + echo "No labels to apply" + fi + env: + FILES: ${{ steps.changed.outputs.files }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +{% endraw %} diff --git a/scaffold/templates/pages.yml.j2 b/scaffold/templates/pages.yml.j2 index cb166d2..3647537 100644 --- a/scaffold/templates/pages.yml.j2 +++ b/scaffold/templates/pages.yml.j2 @@ -22,10 +22,12 @@ concurrency: jobs: build-and-deploy: +{% raw %} environment: name: github-pages - url: {% raw %}${{ steps.deployment.outputs.page_url }}{% endraw %} + url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest +{% endraw %} steps: - uses: actions/checkout@v4 diff --git a/scaffold/templates/release.yml.j2 b/scaffold/templates/release.yml.j2 index 0e48bca..f4eb68f 100644 --- a/scaffold/templates/release.yml.j2 +++ b/scaffold/templates/release.yml.j2 @@ -8,6 +8,7 @@ on: - "docs/**" - "*.md" - "LICENSE" + workflow_dispatch: {} permissions: contents: write @@ -20,20 +21,24 @@ jobs: version-and-release: name: Bump version, tag, and release runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - - uses: actions/checkout@v4 +{% raw %} + - uses: actions/checkout@v6 with: fetch-depth: 0 - token: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} + token: ${{ secrets.GITHUB_TOKEN }} - name: Get current version id: current run: | +{% endraw %} {% if type == 'cursor-plugin' %} version=$(python3 -c "import json; print(json.load(open('.cursor-plugin/plugin.json'))['version'])") {% else %} version=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") {% endif %} +{% raw %} echo "version=$version" >> "$GITHUB_OUTPUT" echo "Current version: $version" @@ -47,6 +52,9 @@ jobs: commits=$(git log "$last_tag"..HEAD --oneline --format="%s") fi + echo "Commits since last release:" + echo "$commits" + bump="patch" if echo "$commits" | grep -qiE "^(feat|feature)(\(.+\))?!:|BREAKING CHANGE"; then bump="major" @@ -55,25 +63,43 @@ jobs: fi echo "bump=$bump" >> "$GITHUB_OUTPUT" + echo "Bump type: $bump" - name: Compute new version id: new run: | - current="{% raw %}${{ steps.current.outputs.version }}{% endraw %}" - bump="{% raw %}${{ steps.bump.outputs.bump }}{% endraw %}" + current="${{ steps.current.outputs.version }}" + bump="${{ steps.bump.outputs.bump }}" + IFS='.' read -r major minor patch <<< "$current" - case "$bump" in - major) major=$((major + 1)); minor=0; patch=0 ;; - minor) minor=$((minor + 1)); patch=0 ;; - patch) patch=$((patch + 1)) ;; - esac - echo "version=$major.$minor.$patch" >> "$GITHUB_OUTPUT" - - - name: Check if version changed + + # Initial release: when there is no prior tag, hold version at the + # value already in the manifest. The first release is cut at the + # scaffolded version (typically 0.1.0) instead of bumping to 0.2.0 + # on the first feat: commit. After the first tag exists, normal + # conventional-commit-driven bumping resumes. + last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$last_tag" ]; then + new_version="$current" + else + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + new_version="$major.$minor.$patch" + fi + + echo "version=$new_version" >> "$GITHUB_OUTPUT" + echo "New version: $new_version" + + - name: Check if tag already exists id: check run: | - if [ "{% raw %}${{ steps.current.outputs.version }}{% endraw %}" = "{% raw %}${{ steps.new.outputs.version }}{% endraw %}" ]; then + new_version="${{ steps.new.outputs.version }}" + if git rev-parse "v$new_version" >/dev/null 2>&1; then echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Tag v$new_version already exists, skipping" else echo "skip=false" >> "$GITHUB_OUTPUT" fi @@ -81,16 +107,17 @@ jobs: - name: Update version files if: steps.check.outputs.skip == 'false' env: - NEW_VERSION: {% raw %}${{ steps.new.outputs.version }}{% endraw %} - OLD_VERSION: {% raw %}${{ steps.current.outputs.version }}{% endraw %} + NEW_VERSION: ${{ steps.new.outputs.version }} + OLD_VERSION: ${{ steps.current.outputs.version }} run: | python3 -c " import json, os new_version = os.environ['NEW_VERSION'] old_version = os.environ['OLD_VERSION'] - +{% endraw %} {% if type == 'cursor-plugin' %} + path = '.cursor-plugin/plugin.json' with open(path) as f: data = json.load(f) @@ -101,31 +128,57 @@ jobs: {% endif %} readme = 'README.md' - with open(readme) as f: - content = f.read() - content = content.replace( - f'version-{old_version}-blue', - f'version-{new_version}-blue' - ) - with open(readme, 'w') as f: - f.write(content) + if os.path.exists(readme): + with open(readme) as f: + content = f.read() + content = content.replace( + f'version-{old_version}-blue', + f'version-{new_version}-blue' + ) + with open(readme, 'w') as f: + f.write(content) " +{% raw %} + - name: Sync release docs + if: steps.check.outputs.skip == 'false' + uses: TMHSDigital/Developer-Tools-Directory/.github/actions/release-doc-sync@v1 + with: + plugin-version: ${{ steps.new.outputs.version }} + previous-version: ${{ steps.current.outputs.version }} - - name: Commit and tag + - name: Commit version bump if: steps.check.outputs.skip == 'false' run: | git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A - git commit -m "chore: bump version to {% raw %}${{ steps.new.outputs.version }}{% endraw %} [skip ci]" - git tag "v{% raw %}${{ steps.new.outputs.version }}{% endraw %}" - git push origin main --tags + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "chore: bump version to ${{ steps.new.outputs.version }} [skip ci]" + git push origin main + fi + + - name: Create and push tags + if: steps.check.outputs.skip == 'false' + run: | + new_version="${{ steps.new.outputs.version }}" + IFS='.' read -r major minor _patch <<< "$new_version" + + git tag "v$new_version" + git tag -f "v$major" + git tag -f "v$major.$minor" + + git push origin "v$new_version" + git push origin "v$major" --force + git push origin "v$major.$minor" --force - name: Create GitHub Release if: steps.check.outputs.skip == 'false' env: - GH_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "v{% raw %}${{ steps.new.outputs.version }}{% endraw %}" \ - --title "v{% raw %}${{ steps.new.outputs.version }}{% endraw %}" \ + gh release create "v${{ steps.new.outputs.version }}" \ + --title "v${{ steps.new.outputs.version }}" \ --generate-notes +{% endraw %} diff --git a/scaffold/templates/validate.yml.j2 b/scaffold/templates/validate.yml.j2 index 82db906..2539508 100644 --- a/scaffold/templates/validate.yml.j2 +++ b/scaffold/templates/validate.yml.j2 @@ -32,7 +32,8 @@ jobs: - uses: actions/checkout@v4 - name: Check required manifest fields - run: python3 -c " + run: | + python3 << 'PYEOF' import json, re m = json.load(open('.cursor-plugin/plugin.json')) required = ['name', 'displayName', 'description', 'version', 'author', 'license', 'skills', 'rules'] @@ -40,25 +41,27 @@ jobs: assert not missing, f'Missing fields: {missing}' assert re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', m['name']), 'name must be lowercase kebab-case' print('Plugin manifest valid') - " + PYEOF - name: Check skill files exist - run: python3 -c " + run: | + python3 << 'PYEOF' import json, os m = json.load(open('.cursor-plugin/plugin.json')) for skill in m.get('skills', []): assert os.path.exists(skill), f'Skill not found: {skill}' - print(f'All {len(m[\"skills\"])} skill files exist') - " + print(f'All {len(m["skills"])} skill files exist') + PYEOF - name: Check rule files exist - run: python3 -c " + run: | + python3 << 'PYEOF' import json, os m = json.load(open('.cursor-plugin/plugin.json')) for rule in m.get('rules', []): assert os.path.exists(rule), f'Rule not found: {rule}' - print(f'All {len(m[\"rules\"])} rule files exist') - " + print(f'All {len(m["rules"])} rule files exist') + PYEOF validate-content: name: Validate content quality @@ -100,6 +103,43 @@ jobs: fi echo "OK: $rule_file" done + + validate-counts: + name: Validate content counts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check content counts match README + run: | + python3 << 'PYEOF' + import os, sys + + errors = [] + + skill_count = len([ + d for d in os.listdir('skills') + if os.path.isdir(os.path.join('skills', d)) + and os.path.exists(os.path.join('skills', d, 'SKILL.md')) + ]) if os.path.isdir('skills') else 0 + rule_count = len([ + f for f in os.listdir('rules') + if f.endswith('.mdc') + ]) if os.path.isdir('rules') else 0 + + readme = open('README.md').read() + if f'{skill_count} skills' not in readme: + errors.append(f'README skill count mismatch (expected "{skill_count} skills")') + if f'{rule_count} rules' not in readme: + errors.append(f'README rule count mismatch (expected "{rule_count} rules")') + + if errors: + for e in errors: + print(f'::error::{e}', file=sys.stderr) + sys.exit(1) + + print(f'Counts verified: {skill_count} skills, {rule_count} rules') + PYEOF {% endif %} {% if has_mcp %}