Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/sts/skill-sync.sts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Allow only the main-branch skill sync workflow to open its generated
# update pull requests in this repository.
subject: repo:tempoxyz@211589300/plugins@1356140289:ref:refs/heads/main
claim_pattern:
job_workflow_ref: tempoxyz/plugins/\.github/workflows/sync-skills\.yml@refs/heads/main
permissions:
contents: write
pull_requests: write
101 changes: 101 additions & 0 deletions .github/workflows/sync-skills.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Sync skills

# Each plugin's skills are copied from the repositories listed in skills.json.
# One pull request per plugin keeps a failing source from blocking the rest;
# generated-only pull requests merge once validation passes.
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
repository_dispatch:
types: [skill-source-updated]

concurrency:
group: sync-skills
cancel-in-progress: false

permissions:
contents: read

jobs:
plugins:
name: List plugins
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
id-token: write
outputs:
plugins: ${{ steps.list.outputs.plugins }}
steps:
- name: Secure runner
uses: tempoxyz/gh-actions/actions/secure-runner@947d9a3ccb2425a9c94d563fd721cc0e33c89e26 # 2026-10-15T00-15-06Z-947d9a3c
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: list
run: echo "plugins=$(jq -c '[.skills[] | .plugin // .name] | unique' skills.json)" >> "$GITHUB_OUTPUT"

sync:
name: Sync ${{ matrix.plugin }}
needs: plugins
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJSON(needs.plugins.outputs.plugins) }}
steps:
- name: Secure runner
uses: tempoxyz/gh-actions/actions/secure-runner@947d9a3ccb2425a9c94d563fd721cc0e33c89e26 # 2026-10-15T00-15-06Z-947d9a3c
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Sync skills from their sources
id: sync
uses: tempoxyz/gh-actions/actions/skill-sync@caf7c59ea3a8359618ca5640d123a161a2a87721 # 2026-10-15T17-04-41Z-caf7c59e
with:
only: ${{ matrix.plugin }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.sync.outputs.changed == 'true'
with:
node-version: 24
cache: npm
- name: Bump the plugin version and regenerate manifests
if: steps.sync.outputs.changed == 'true'
env:
PLUGIN: ${{ matrix.plugin }}
run: |
npm ci --ignore-scripts
npx tsx scripts/bump-plugin-version.ts "$PLUGIN"
npm run build
npm run update:goldens
- name: Fetch GitHub token via STS
if: steps.sync.outputs.changed == 'true'
id: sts
uses: tempoxyz/gh-actions/actions/github-sts@947d9a3ccb2425a9c94d563fd721cc0e33c89e26 # 2026-10-15T00-15-06Z-947d9a3c
with:
policy: skill-sync
ttl: 10m
- name: Open or update the sync pull request
if: steps.sync.outputs.changed == 'true'
uses: tempoxyz/gh-actions/actions/create-pull-request@a4fa1d51aa4014d86628f43b1dff8ba88219e04d # 2026-10-15T00-03-55Z-a4fa1d51
with:
token: ${{ steps.sts.outputs.token }}
branch: automation/sync-skills/${{ matrix.plugin }}
commit-message: "chore(${{ matrix.plugin }}): sync skills"
title: "chore(${{ matrix.plugin }}): sync skills"
body: |
Generated from `skills.json` by `.github/workflows/sync-skills.yml`. Edit the source, not these copies.

${{ steps.sync.outputs.summary }}
add-paths: |
plugins/${{ matrix.plugin }}
registry
skills.lock.json
Comment thread
brendanjryan marked this conversation as resolved.
manifest.config.ts
test/fixtures/golden
auto-merge: squash
4 changes: 4 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ jobs:
with:
node-version: 24
cache: npm
- name: Check generated skills
uses: tempoxyz/gh-actions/actions/skill-sync@caf7c59ea3a8359618ca5640d123a161a2a87721 # 2026-10-15T17-04-41Z-caf7c59e
with:
mode: check
- run: npm ci --ignore-scripts
- run: npm test
- name: Validate Claude marketplace
Expand Down
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ the independently versioned plugin releases.
- Production MCP endpoints are smoke-tested daily.
- All changes must pass validation and use signed commits.
- Use the canonical Tempo and Mercator brand assets.
- The Mercator skill is generated upstream. Do not edit its generated copy.
- Upstream Mercator changes arrive through signed synchronization pull requests.
- Skills under `plugins/*/skills/` are generated from the repositories listed
in [`skills.json`](./skills.json). Edit the source repository, not the copy;
CI rejects copies that no longer match `skills.lock.json`.
- `.github/workflows/sync-skills.yml` opens one signed pull request per plugin
when a source changes, bumps that plugin's patch version, and merges once
validation passes.
- To distribute a new skill, add an entry to `skills.json`. A private source
repository also needs a `.github/sts/skill-sync-read.sts.yaml` read policy;
see the [skill-sync action](https://github.com/tempoxyz/gh-actions/tree/main/actions/skill-sync).
- Report marketplace compatibility changes through an issue and update the
corresponding manifest, test, and submission notes together.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ npm run test:mcp # Smoke-test production MCP endpoints
```

Edit [`manifest.config.ts`](./manifest.config.ts), not generated JSON manifests.
Repository tooling is TypeScript and tested with Vitest. The Mercator skill is
generated upstream; changes arrive here through signed synchronization pull
requests.
Repository tooling is TypeScript and tested with Vitest. Skills are generated
from the source repositories listed in [`skills.json`](./skills.json) and arrive
through signed synchronization pull requests; edit them upstream.

## Contributing

Expand Down
21 changes: 21 additions & 0 deletions scripts/bump-plugin-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const configPath = fileURLToPath(new URL('../manifest.config.ts', import.meta.url))

/** Bump one plugin's patch version so installed clients pick up synced skill changes. */
export const bumpPluginVersion = (config: string, name: string): string => {
const pattern = new RegExp(`(\\n name: '${name}',\\n version: ')(\\d+)\\.(\\d+)\\.(\\d+)(')`)
const match = pattern.exec(config)
if (!match) throw new Error(`manifest.config.ts has no version for plugin "${name}"`)
const [, prefix, major, minor, patch, suffix] = match
return config.replace(pattern, `${prefix}${major}.${minor}.${Number(patch) + 1}${suffix}`)
}

const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
const name = process.argv[2]
if (!name) throw new Error('usage: bump-plugin-version.ts <plugin>')
writeFileSync(configPath, bumpPluginVersion(readFileSync(configPath, 'utf8'), name))
}
32 changes: 32 additions & 0 deletions skills.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"skills": [
{
"name": "docs",
"plugin": "docs",
Comment thread
brendanjryan marked this conversation as resolved.
"repo": "tempoxyz/docs",
"path": "public/SKILL.md",
"ref": "main"
},
{
"name": "wallet",
"plugin": "wallet",
"repo": "tempoxyz/wallet-cli",
"path": "SKILL.md",
"ref": "release"
},
{
"name": "mercator",
"plugin": "mercator",
"repo": "tempoxyz/mercator",
"path": "skills/mercator",
"ref": "main"
},
{
"name": "mercator-setup",
"plugin": "mercator",
"repo": "tempoxyz/mercator",
"path": "skills/mercator-setup",
"ref": "main"
}
]
}
25 changes: 25 additions & 0 deletions skills.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"version": 1,
"skills": [
{
"name": "mercator",
Comment thread
brendanjryan marked this conversation as resolved.
"plugin": "mercator",
"repo": "tempoxyz/mercator",
"path": "skills/mercator",
"ref": "main",
"commit": "598f247026fbe99d87e10ccf1cef914918eb5389",
"digest": "sha256:2832e9166e61033bc8c16d7ecedb807c14f5e9dc8d95eef6f44c11fd478bc22e",
"dest": "plugins/mercator/skills/mercator"
},
{
"name": "mercator-setup",
"plugin": "mercator",
"repo": "tempoxyz/mercator",
"path": "skills/mercator-setup",
"ref": "main",
"commit": "598f247026fbe99d87e10ccf1cef914918eb5389",
"digest": "sha256:d7c7f672a8327800a09086604f508253b3a452689b338e7b337e5ec674a1d8d4",
"dest": "plugins/mercator/skills/mercator-setup"
}
]
}
22 changes: 22 additions & 0 deletions test/bump-plugin-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { bumpPluginVersion } from '../scripts/bump-plugin-version.ts'

const config = readFileSync(new URL('../manifest.config.ts', import.meta.url), 'utf8')
const version = (source: string, name: string): string | undefined =>
new RegExp(`\\n name: '${name}',\\n version: '([^']+)'`).exec(source)?.[1]

describe('bumpPluginVersion', () => {
it.each(['docs', 'wallet', 'mercator'])('bumps only the %s patch version', (name) => {
const bumped = bumpPluginVersion(config, name)
const [major, minor, patch] = version(config, name)!.split('.')
expect(version(bumped, name)).toBe(`${major}.${minor}.${Number(patch) + 1}`)
for (const other of ['docs', 'wallet', 'mercator'].filter((plugin) => plugin !== name)) {
expect(version(bumped, other)).toBe(version(config, other))
}
})

it('rejects an unknown plugin', () => {
expect(() => bumpPluginVersion(config, 'missing')).toThrow(/no version for plugin "missing"/)
})
})
29 changes: 27 additions & 2 deletions test/repository.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync } from 'node:fs'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, test } from 'vitest'
Expand Down Expand Up @@ -62,7 +62,7 @@ describe('repository validation', () => {

test('Tempo Docs skill stays read-only', () => {
const skill = readFileSync(join(root, 'plugins/docs/skills/docs/SKILL.md'), 'utf8')
expect(skill).toMatch(/This plugin is read-only/)
expect(skill).toMatch(/This (plugin|skill) is read-only/)
expect(skill).toMatch(/must not install software/)
})

Expand Down Expand Up @@ -141,6 +141,31 @@ describe('repository validation', () => {
.toMatch(/value: "mercator"/)
})

test('every synced skill ships in a cataloged plugin', () => {
const registry = readJson<{ skills: Array<{ name: string; plugin?: string }> }>('skills.json')
for (const skill of registry.skills) {
expect(pluginNames, skill.name).toContain(skill.plugin ?? skill.name)
}
})

test.each(pluginNames)('skill sync stages only paths that exist for %s', (name) => {
const workflow = readFileSync(join(root, '.github/workflows/sync-skills.yml'), 'utf8')
const addPaths = /add-paths: \|\n((?: {12}.+\n)+)/.exec(workflow)?.[1]
expect(addPaths).toBeDefined()
for (const pathspec of addPaths!.trim().split('\n')) {
const path = pathspec.trim().replace('${{ matrix.plugin }}', name)
expect(existsSync(join(root, path)), path).toBe(true)
}
})

test('skill sync trust is limited to its main-branch workflow', () => {
const policy = readFileSync(join(root, '.github/sts/skill-sync.sts.yaml'), 'utf8')
expect(policy).toMatch(/plugins@1356140289:ref:refs\/heads\/main/)
expect(policy).toMatch(/sync-skills\\\.yml@refs\/heads\/main/)
expect(policy).toMatch(/contents: write/)
expect(policy).toMatch(/pull_requests: write/)
})

test('Mercator sync trust is limited to its main-branch workflow', () => {
const policy = readFileSync(
join(root, '.github/sts/mercator-skill-sync.sts.yaml'),
Expand Down
Loading