Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
370 lines
16 KiB
YAML
370 lines
16 KiB
YAML
name: Sync Tutti Agent Skills
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
paths:
|
|
- "services/tuttid/service/workspace/app_factory_reference/**"
|
|
- "services/tuttid/service/workspace/agent_workspace_app_reference/**"
|
|
workflow_dispatch:
|
|
inputs:
|
|
source_ref:
|
|
description: "Source ref to mirror, usually main or a commit SHA."
|
|
required: false
|
|
default: main
|
|
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
pull-requests: write
|
|
|
|
concurrency:
|
|
group: sync-tutti-agent-skills
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
sync:
|
|
runs-on: ubuntu-latest
|
|
|
|
env:
|
|
SKILLS_REPO: tutti-os/tutti-agent-skills
|
|
SYNC_BRANCH: sync/workspace-app-factory-skill
|
|
|
|
steps:
|
|
- name: Checkout Tutti main repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
path: source-repository
|
|
ref: ${{ github.event.inputs.source_ref || github.sha }}
|
|
|
|
- name: Checkout skills repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
repository: ${{ env.SKILLS_REPO }}
|
|
token: ${{ secrets.TUTTI_AGENT_SKILLS_SYNC_TOKEN }}
|
|
path: tutti-agent-skills
|
|
|
|
- name: Sync built-in skills
|
|
run: |
|
|
rsync -a --delete \
|
|
source-repository/services/tuttid/service/workspace/app_factory_reference/ \
|
|
tutti-agent-skills/skills/tutti-workspace-app-factory/
|
|
rsync -a --delete \
|
|
source-repository/services/tuttid/service/workspace/app_factory_reference/ \
|
|
tutti-agent-skills/plugins/tutti/skills/tutti-workspace-app-factory/
|
|
rsync -a --delete \
|
|
source-repository/services/tuttid/service/workspace/agent_workspace_app_reference/ \
|
|
tutti-agent-skills/skills/tutti-agent-workspace-app/
|
|
rsync -a --delete \
|
|
source-repository/services/tuttid/service/workspace/agent_workspace_app_reference/ \
|
|
tutti-agent-skills/plugins/tutti/skills/tutti-agent-workspace-app/
|
|
|
|
- name: Detect mirrored changes
|
|
id: changes
|
|
working-directory: tutti-agent-skills
|
|
run: |
|
|
versions_aligned="$(python3 - <<'PY'
|
|
import json
|
|
import pathlib
|
|
import re
|
|
|
|
paths = [
|
|
pathlib.Path(".codex-plugin/plugin.json"),
|
|
pathlib.Path("plugins/tutti/.codex-plugin/plugin.json"),
|
|
pathlib.Path("plugins/tutti/.claude-plugin/plugin.json"),
|
|
]
|
|
versions = [json.loads(path.read_text()).get("version") for path in paths]
|
|
valid = all(re.fullmatch(r"\d+\.\d+\.\d+", str(version or "")) for version in versions)
|
|
print("true" if valid and len(set(versions)) == 1 else "false")
|
|
PY
|
|
)"
|
|
if git diff --quiet -- \
|
|
skills/tutti-workspace-app-factory \
|
|
skills/tutti-agent-workspace-app \
|
|
plugins/tutti/skills/tutti-workspace-app-factory \
|
|
plugins/tutti/skills/tutti-agent-workspace-app \
|
|
&& [[ "$versions_aligned" == "true" ]]; then
|
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Bump plugin patch version
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
working-directory: tutti-agent-skills
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
def bump_patch(version):
|
|
base = str(version).split("+", 1)[0]
|
|
parts = base.split(".")
|
|
if len(parts) != 3 or not all(part.isdigit() for part in parts):
|
|
sys.exit(f"Unsupported plugin version: {version}")
|
|
parts[2] = str(int(parts[2]) + 1)
|
|
return ".".join(parts)
|
|
|
|
manifest_paths = [
|
|
pathlib.Path(".codex-plugin/plugin.json"),
|
|
pathlib.Path("plugins/tutti/.codex-plugin/plugin.json"),
|
|
pathlib.Path("plugins/tutti/.claude-plugin/plugin.json"),
|
|
]
|
|
root_manifest = json.loads(manifest_paths[0].read_text())
|
|
next_version = bump_patch(root_manifest.get("version", "0.1.0"))
|
|
for manifest_path in manifest_paths:
|
|
text = manifest_path.read_text()
|
|
text = re.sub(
|
|
r'("version"\s*:\s*")[^"]+(")',
|
|
rf"\g<1>{next_version}\2",
|
|
text,
|
|
count=1,
|
|
)
|
|
manifest_path.write_text(text)
|
|
PY
|
|
|
|
- name: Validate allowed changed paths
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
working-directory: tutti-agent-skills
|
|
run: |
|
|
git diff --name-only > /tmp/tutti-agent-skills-changed-files.txt
|
|
while IFS= read -r path; do
|
|
case "$path" in
|
|
skills/tutti-workspace-app-factory/*|skills/tutti-workspace-app-factory/**|skills/tutti-agent-workspace-app/*|skills/tutti-agent-workspace-app/**|plugins/tutti/skills/tutti-workspace-app-factory/*|plugins/tutti/skills/tutti-workspace-app-factory/**|plugins/tutti/skills/tutti-agent-workspace-app/*|plugins/tutti/skills/tutti-agent-workspace-app/**|.codex-plugin/plugin.json|plugins/tutti/.codex-plugin/plugin.json|plugins/tutti/.claude-plugin/plugin.json)
|
|
;;
|
|
*)
|
|
echo "Unexpected changed path: $path" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done < /tmp/tutti-agent-skills-changed-files.txt
|
|
|
|
- name: Validate skill discovery
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
working-directory: tutti-agent-skills
|
|
run: |
|
|
npx --yes skills add . --list
|
|
npx --yes skills add ./skills/tutti-workspace-app-factory --list
|
|
npx --yes skills add ./skills/tutti-agent-workspace-app --list
|
|
|
|
- name: Validate marketplace layout
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
working-directory: tutti-agent-skills
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
marketplace = json.loads(pathlib.Path(".agents/plugins/marketplace.json").read_text())
|
|
plugins = marketplace.get("plugins")
|
|
if not isinstance(plugins, list) or not plugins:
|
|
sys.exit("marketplace must contain at least one plugin")
|
|
entry = plugins[0]
|
|
if entry.get("name") != "tutti":
|
|
sys.exit("marketplace plugin name must be tutti")
|
|
if entry.get("source", {}).get("path") != "./plugins/tutti":
|
|
sys.exit("marketplace plugin source path must be ./plugins/tutti")
|
|
for root in (pathlib.Path("."), pathlib.Path("plugins/tutti")):
|
|
manifest = json.loads((root / ".codex-plugin/plugin.json").read_text())
|
|
if manifest.get("name") != "tutti":
|
|
sys.exit(f"{root} plugin name must be tutti")
|
|
if manifest.get("skills") != "./skills/":
|
|
sys.exit(f"{root} skills path must be ./skills/")
|
|
if manifest.get("interface", {}).get("websiteURL") != "https://tutti.sh/":
|
|
sys.exit(f"{root} websiteURL must be https://tutti.sh/")
|
|
for skill_name in ["tutti-workspace-app-factory", "tutti-agent-workspace-app"]:
|
|
if not (root / f"skills/{skill_name}/SKILL.md").exists():
|
|
sys.exit(f"{root} is missing {skill_name}")
|
|
|
|
root_codex = json.loads(pathlib.Path(".codex-plugin/plugin.json").read_text())
|
|
plugin_codex = json.loads(pathlib.Path("plugins/tutti/.codex-plugin/plugin.json").read_text())
|
|
if root_codex.get("version") != plugin_codex.get("version"):
|
|
sys.exit("root and marketplace Codex plugin versions must match")
|
|
|
|
claude = json.loads(pathlib.Path("plugins/tutti/.claude-plugin/plugin.json").read_text())
|
|
if root_codex.get("version") != claude.get("version"):
|
|
sys.exit("Codex and Claude plugin versions must match")
|
|
if claude.get("name") != "tutti":
|
|
sys.exit("Claude plugin name must be tutti")
|
|
if claude.get("skills") != "./skills/":
|
|
sys.exit("Claude plugin skills path must be ./skills/")
|
|
if claude.get("hooks") != "./hooks/claude-hooks.json":
|
|
sys.exit("Claude plugin hooks path must be ./hooks/claude-hooks.json")
|
|
for label, manifest in (("Codex", root_codex), ("Claude", claude)):
|
|
if not re.fullmatch(r"\d+\.\d+\.\d+", str(manifest.get("version", ""))):
|
|
sys.exit(f"{label} plugin version must be numeric semver")
|
|
PY
|
|
|
|
- name: Validate skill frontmatter
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
working-directory: tutti-agent-skills
|
|
run: |
|
|
python3 - <<'PY'
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
for expected_name in ["tutti-workspace-app-factory", "tutti-agent-workspace-app"]:
|
|
skill = pathlib.Path(f"skills/{expected_name}/SKILL.md")
|
|
text = skill.read_text()
|
|
if not text.startswith("---\n"):
|
|
sys.exit(f"{skill} must start with YAML frontmatter")
|
|
end = text.find("\n---\n", 4)
|
|
if end < 0:
|
|
sys.exit(f"{skill} must close YAML frontmatter")
|
|
frontmatter = text[4:end]
|
|
if not re.search(rf"^name:\s*{re.escape(expected_name)}\s*$", frontmatter, re.M):
|
|
sys.exit(f"{skill} missing expected name")
|
|
if not re.search(r"^description:\s*.+", frontmatter, re.M):
|
|
sys.exit(f"{skill} missing description")
|
|
allowed = {"name", "description"}
|
|
keys = {
|
|
line.split(":", 1)[0].strip()
|
|
for line in frontmatter.splitlines()
|
|
if ":" in line and not line.startswith(" ")
|
|
}
|
|
extra = keys - allowed
|
|
if extra:
|
|
sys.exit(f"{skill} frontmatter has unsupported keys: {sorted(extra)}")
|
|
PY
|
|
|
|
- name: Commit and push sync branch
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
working-directory: tutti-agent-skills
|
|
env:
|
|
TOKEN: ${{ secrets.TUTTI_AGENT_SKILLS_SYNC_TOKEN }}
|
|
run: |
|
|
git config user.name "tutti-agent-skills-sync"
|
|
git config user.email "tutti-agent-skills-sync@users.noreply.github.com"
|
|
git checkout -B "$SYNC_BRANCH"
|
|
git add \
|
|
skills/tutti-workspace-app-factory \
|
|
skills/tutti-agent-workspace-app \
|
|
plugins/tutti/skills/tutti-workspace-app-factory \
|
|
plugins/tutti/skills/tutti-agent-workspace-app \
|
|
.codex-plugin/plugin.json \
|
|
plugins/tutti/.codex-plugin/plugin.json \
|
|
plugins/tutti/.claude-plugin/plugin.json
|
|
git commit -m "Sync Tutti agent skills from Tutti main repository"
|
|
git push "https://x-access-token:${TOKEN}@github.com/${SKILLS_REPO}.git" "$SYNC_BRANCH" --force
|
|
|
|
- name: Open or update sync pull request
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
id: sync_pr
|
|
working-directory: tutti-agent-skills
|
|
env:
|
|
GH_TOKEN: ${{ secrets.TUTTI_AGENT_SKILLS_SYNC_TOKEN }}
|
|
run: |
|
|
pr_number="$(gh pr list \
|
|
--repo "$SKILLS_REPO" \
|
|
--head "$SYNC_BRANCH" \
|
|
--json number \
|
|
--jq '.[0].number // ""')"
|
|
if [ -z "$pr_number" ]; then
|
|
body="$(printf 'Mirrors Tutti main repository workspace app skills.\n\nSource commit: %s' "${GITHUB_SHA}")"
|
|
pr_url="$(gh pr create \
|
|
--repo "$SKILLS_REPO" \
|
|
--base main \
|
|
--head "$SYNC_BRANCH" \
|
|
--title "Sync Tutti agent skills" \
|
|
--body "$body")"
|
|
pr_number="${pr_url##*/}"
|
|
else
|
|
body="$(printf 'Mirrors Tutti main repository workspace app skills.\n\nSource commit: %s' "${GITHUB_SHA}")"
|
|
gh pr edit "$pr_number" \
|
|
--repo "$SKILLS_REPO" \
|
|
--title "Sync Tutti agent skills" \
|
|
--body "$body"
|
|
pr_url="https://github.com/${SKILLS_REPO}/pull/${pr_number}"
|
|
fi
|
|
echo "number=$pr_number" >> "$GITHUB_OUTPUT"
|
|
echo "url=https://github.com/${SKILLS_REPO}/pull/${pr_number}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Wait for sync pull request checks
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.TUTTI_AGENT_SKILLS_SYNC_TOKEN }}
|
|
run: |
|
|
for attempt in {1..30}; do
|
|
checks_json="$(gh pr checks \
|
|
"${{ steps.sync_pr.outputs.number }}" \
|
|
--repo "$SKILLS_REPO" \
|
|
--json name,state,bucket 2>/dev/null || true)"
|
|
checks_count="$(printf '%s' "$checks_json" | python3 -c 'import json,sys; print(len(json.load(sys.stdin) or []))' 2>/dev/null || echo 0)"
|
|
if [ "$checks_count" -gt 0 ]; then
|
|
break
|
|
fi
|
|
if [ "$attempt" -eq 30 ]; then
|
|
echo "No checks appeared on sync PR after waiting." >&2
|
|
exit 1
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
gh pr checks \
|
|
"${{ steps.sync_pr.outputs.number }}" \
|
|
--repo "$SKILLS_REPO" \
|
|
--watch \
|
|
--fail-fast
|
|
|
|
- name: Merge sync pull request
|
|
if: steps.changes.outputs.has_changes == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.TUTTI_AGENT_SKILLS_SYNC_TOKEN }}
|
|
run: |
|
|
gh pr merge \
|
|
"${{ steps.sync_pr.outputs.number }}" \
|
|
--repo "$SKILLS_REPO" \
|
|
--squash \
|
|
--delete-branch \
|
|
--admin
|
|
|
|
- name: Find source pull request
|
|
if: always()
|
|
id: source_pr
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
pr_number="$(gh api \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" \
|
|
--jq '.[0].number // ""')"
|
|
echo "number=$pr_number" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Comment sync result on source pull request
|
|
if: always() && steps.source_pr.outputs.number != ''
|
|
uses: actions/github-script@v8
|
|
with:
|
|
script: |
|
|
const prNumber = Number("${{ steps.source_pr.outputs.number }}");
|
|
const hasChanges = "${{ steps.changes.outputs.has_changes }}" === "true";
|
|
const syncUrl = "${{ steps.sync_pr.outputs.url }}";
|
|
const status = "${{ job.status }}";
|
|
const body = hasChanges
|
|
? `Tutti skills sync ${status}.\n\nExternal repository:\n tutti-os/tutti-agent-skills\n\nSync PR:\n ${syncUrl}\n\nMerge:\n automatically merged after external checks pass\n\nSource commit:\n ${context.sha}`
|
|
: `Tutti skills sync skipped.\n\nNo mirrored skill changes were detected for source commit ${context.sha}.`;
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body,
|
|
});
|
|
|
|
- name: Summarize sync
|
|
if: always()
|
|
run: |
|
|
{
|
|
echo "## Tutti Skills Sync"
|
|
echo
|
|
echo "- Source commit: \`${GITHUB_SHA}\`"
|
|
echo "- Mirrored changes: \`${{ steps.changes.outputs.has_changes || 'unknown' }}\`"
|
|
echo "- Sync PR: ${{ steps.sync_pr.outputs.url || 'not created' }}"
|
|
echo "- Job status: \`${{ job.status }}\`"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|