diff --git a/.github/sts/skill-sync.sts.yaml b/.github/sts/skill-sync.sts.yaml new file mode 100644 index 0000000..ce93b2c --- /dev/null +++ b/.github/sts/skill-sync.sts.yaml @@ -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 diff --git a/.github/workflows/sync-skills.yml b/.github/workflows/sync-skills.yml new file mode 100644 index 0000000..6ffbc42 --- /dev/null +++ b/.github/workflows/sync-skills.yml @@ -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 + manifest.config.ts + test/fixtures/golden + auto-merge: squash diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7242a65..e06160a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 454b794..c542acd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 4c0582b..5de038a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/bump-plugin-version.ts b/scripts/bump-plugin-version.ts new file mode 100644 index 0000000..58b415a --- /dev/null +++ b/scripts/bump-plugin-version.ts @@ -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 ') + writeFileSync(configPath, bumpPluginVersion(readFileSync(configPath, 'utf8'), name)) +} diff --git a/skills.json b/skills.json new file mode 100644 index 0000000..c4fe1ec --- /dev/null +++ b/skills.json @@ -0,0 +1,32 @@ +{ + "skills": [ + { + "name": "docs", + "plugin": "docs", + "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" + } + ] +} diff --git a/skills.lock.json b/skills.lock.json new file mode 100644 index 0000000..744835e --- /dev/null +++ b/skills.lock.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "skills": [ + { + "name": "mercator", + "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" + } + ] +} diff --git a/test/bump-plugin-version.test.ts b/test/bump-plugin-version.test.ts new file mode 100644 index 0000000..9b03245 --- /dev/null +++ b/test/bump-plugin-version.test.ts @@ -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"/) + }) +}) diff --git a/test/repository.test.ts b/test/repository.test.ts index 9f8ec25..a80e314 100644 --- a/test/repository.test.ts +++ b/test/repository.test.ts @@ -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' @@ -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/) }) @@ -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'),