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
152 changes: 152 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
name: CI

# This repository has no build and no package manager. CI is therefore the
# lint/validate proxies documented in AGENTS.md, plus the sync-local regression
# suite, run on every push and pull request.
#
# Scope note: the repo-root config files synced from melodic-software/standards
# (.editorconfig, .gitattributes, _typos.toml, lychee.toml, .gitleaks.toml,
# .markdownlint-cli2.jsonc, .editorconfig-checker.json) are validated by their
# own tooling upstream and are deliberately not re-linted here.

on:
push:
branches: [main]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run CI on pushes to every branch

The branches: [main] filter prevents this workflow from running when changes are pushed to any feature branch without an open PR, despite both the workflow comment and AGENTS.md specifying that these checks run on every push and pull request. Remove the branch filter (or explicitly include all intended branches) so pre-PR pushes receive the documented validation.

AGENTS.md reference: AGENTS.md:L97-L101

Useful? React with 👍 / 👎.

pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
shell:
name: Shell (shellcheck + regression suite)
runs-on: ubuntu-latest
steps:
# actions/checkout v5.1.0 — pinned to a full-length commit SHA because this
# org requires it; a tag or short SHA is rejected at "Set up job".
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09

- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck

- name: shellcheck (must be zero findings)
run: shellcheck scripts/*.sh

- name: bash -n (syntax floor)
run: for f in scripts/*.sh; do bash -n "$f"; done

# pwsh is preinstalled on ubuntu-latest, so the suite exercises BOTH twins
# here and asserts their output is byte-identical.
- name: sync-local regression suite
run: bash scripts/test-sync-local.sh

powershell:
name: PowerShell (PSScriptAnalyzer)
runs-on: ubuntu-latest
steps:
# actions/checkout v5.1.0 — pinned to a full-length commit SHA because this
# org requires it; a tag or short SHA is rejected at "Set up job".
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09

- name: Install PSScriptAnalyzer
shell: pwsh
run: |
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force

# Suppressions in sync-local.ps1 are deliberate and each carries a written
# Justification; -SuppressedOnly is printed so a reviewer can see what is
# being suppressed rather than having it silently disappear.
- name: PSScriptAnalyzer (must be zero unsuppressed findings)
shell: pwsh
run: |
$suppressed = Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1 -SuppressedOnly
if ($suppressed) {
Write-Host "Deliberately suppressed (each carries a Justification):"
$suppressed | Format-Table RuleName, @{n='Count';e={1}} -AutoSize | Out-String | Write-Host
}
$findings = Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1
if ($findings) {
$findings | Format-Table Severity, RuleName, Line, Message -AutoSize -Wrap | Out-String | Write-Host
throw "PSScriptAnalyzer reported $($findings.Count) finding(s)."
}
Write-Host "PSScriptAnalyzer: 0 findings."

manifests:
name: Manifests (JSON + official Cursor schemas)
runs-on: ubuntu-latest
steps:
# actions/checkout v5.1.0 — pinned to a full-length commit SHA because this
# org requires it; a tag or short SHA is rejected at "Set up job".
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09

- name: jq — manifests are well-formed JSON
run: |
jq empty .cursor-plugin/marketplace.json
for f in plugins/*/.cursor-plugin/plugin.json; do jq empty "$f"; done

- name: Skill frontmatter — each SKILL.md opens with a YAML block
run: |
status=0
for f in plugins/*/skills/*/SKILL.md; do
if [ "$(head -1 "$f")" != "---" ]; then
echo "::error file=$f::SKILL.md must start with a --- YAML frontmatter block"
status=1
fi
done
exit $status

# Validated against Cursor's PUBLISHED schemas, fetched at run time. These
# are the authority: Cursor's prose reference and its schemas disagree in
# both directions, so a prose-derived checklist would pass files the
# schema rejects. A fetch failure fails the job rather than skipping the
# check silently.
- name: Validate manifests against the official schemas
run: |
set -euo pipefail
npm init -y >/dev/null 2>&1
npm install --no-fund --no-audit --silent ajv@8 ajv-formats
base=https://raw.githubusercontent.com/cursor/plugins/main/schemas
curl -fsS "$base/marketplace.schema.json" -o marketplace.schema.json
curl -fsS "$base/plugin.schema.json" -o plugin.schema.json
node scripts/validate-manifests.mjs

links:
name: Docs (internal link integrity)
runs-on: ubuntu-latest
steps:
# actions/checkout v5.1.0 — pinned to a full-length commit SHA because this
# org requires it; a tag or short SHA is rejected at "Set up job".
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09

# Relative links only. External URLs are deliberately NOT checked here:
# GitHub and cursor.directory return 403/429 to CI runners, which would
# make this job fail for reasons that say nothing about the repository.
#
# Inline code spans are stripped BEFORE extracting links. Without that, a
# regex inside backticks parses as link syntax and reports a false
# positive: the plugin-name pattern `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`
# looks exactly like ](...) to a naive matcher.
- name: Every relative markdown link resolves on disk
run: |
status=0
while IFS= read -r src; do
dir=$(dirname "$src")
while IFS= read -r link; do
case "$link" in http*|mailto:*|'') continue ;; esac
target=${link%%#*}
[ -z "$target" ] && continue
if [ ! -e "$dir/$target" ]; then
echo "::error file=$src::broken relative link -> $link"
status=1
fi
done < <(sed 's/`[^`]*`//g' "$src" |
grep -oE '\]\([^)#][^)]*\)' |
sed 's/^](//; s/)$//')
done < <(git ls-files '*.md')
exit $status
5 changes: 3 additions & 2 deletions .shellcheckrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# ShellCheck configuration for this repository.
#
# Scope: scripts/*.sh. Run `shellcheck scripts/sync-local.sh`; the default rule
# set must stay at zero findings.
# Scope: scripts/*.sh. Run `shellcheck scripts/*.sh`; the default rule set must
# stay at zero findings across every script in that directory. Site counts quoted
# below were measured against scripts/sync-local.sh, the largest of them.
#
# Nothing is disabled here. ShellCheck's default rules are all in force, and no
# `# shellcheck disable=` directive appears anywhere in the tree. This file exists
Expand Down
57 changes: 54 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,22 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and

### Environment / dependencies

- There is **no package manager, build step, or test framework**. Nothing needs to be
installed to work here — the required tools (`bash`, `git`, `python3`, `jq`) are all
present in the base image, so the startup update script is a no-op.
- There is **no package manager and no build step**. The tools the repo's own scripts
need at runtime (`bash`, `git`, `python3`, `jq`) are all present in the base image, so
the startup update script is a no-op.
- There **is** a test suite: `bash scripts/test-sync-local.sh`. See "Test" below.
- The lint and test tooling is **not** all preinstalled. Probe with `command -v` and
install what is missing; never assume:

| Tool | Needed for | If missing |
| --- | --- | --- |
| `shellcheck` | linting `scripts/*.sh` | `sudo apt-get install -y shellcheck` |
| `pwsh` | running/testing `sync-local.ps1` | see the caveat below — the `.sh` twin covers Linux |
| `PSScriptAnalyzer` | linting `sync-local.ps1` | `pwsh -c "Install-Module PSScriptAnalyzer -Scope CurrentUser -Force"` |
| `node` + `ajv` | validating manifests against Cursor's schemas | `npm install ajv@8 ajv-formats` |

Only `bash`, `git`, `python3` and `jq` are relied on at runtime; everything in that
table is for checking the repo, not for using it.

### Lint / validate (there is no configured linter — use these proxies)

Expand All @@ -29,6 +42,31 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and
- JSON manifests: `jq empty .cursor-plugin/marketplace.json` and
`jq empty plugins/*/.cursor-plugin/plugin.json`
- Skill frontmatter: each `plugins/*/skills/*/SKILL.md` must start with a `---` YAML block.
- PowerShell: `pwsh -c "Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1"` must report
**zero** findings. `sync-local.ps1` carries two file-level
`SuppressMessageAttribute` entries, each with a written Justification; add
`-SuppressedOnly` to see them. Do not add a suppression without one.
- Manifest **schemas**: `jq empty` only proves the JSON parses. Cursor publishes real
schemas, and its prose reference disagrees with them **in both directions** — the prose
lists plugin-entry fields the schema forbids, and marks `owner` required where the
schema does not. The schema wins. CI fetches both and runs
`node scripts/validate-manifests.mjs`; run it the same way locally.

### Test

- `bash scripts/test-sync-local.sh` — the sync-local regression suite. Every case runs
**both** twins against the same fixture and asserts the same exit code and
byte-identical stdout, because a divergence between documented parity twins is itself a
defect. It builds its fixtures under `$TMPDIR` and redirects `HOME` per case, so it
never touches your real `~/.cursor`.
- `pwsh` is optional: without it the PowerShell half reports as skipped and the bash half
still runs.
- Add a case whenever you fix a defect here. The suite already pins the ones that were
found the hard way: path traversal via a plugin `name` and via an entry `source`, a
`pluginRoot` that escapes the repo, an empty `plugins/` and `"plugins": []` (which used
to exit 0 printing `Synced (0)`), a nameless marketplace entry (which used to dump a
Python traceback), prefix-name sort order, and a symlinked plugin directory (which must
install as a **real** directory, not a link).

### Run (the "application")

Expand Down Expand Up @@ -56,6 +94,19 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and
that date". Never bump one without fetching. A 403/429 is a blocked fetch, not a
verification and not a dead link — record it as blocked and leave the old date.

### CI

`.github/workflows/ci.yml` runs the checks above on every push and pull request: shell
lint plus the regression suite, PSScriptAnalyzer, JSON and schema validation of the
manifests, and internal markdown link integrity. It deliberately does **not** check
external URLs — GitHub and cursor.directory answer CI runners with 403/429, which would
fail the build for reasons that say nothing about the repository. Note that GitHub's
runners preinstall `pwsh`, so CI exercises both twins even though a Cursor Cloud box may
not.

The repo-root files synced from `melodic-software/standards` are not re-linted here; they
are validated upstream, and this repo must not hand-edit them.

### Non-obvious caveats

- The PowerShell twin `scripts/sync-local.ps1` requires `pwsh` (PowerShell), which is
Expand Down
Loading