diff --git a/.github/scripts/bump-version.mjs b/.github/scripts/bump-version.mjs new file mode 100644 index 0000000..c41c6d9 --- /dev/null +++ b/.github/scripts/bump-version.mjs @@ -0,0 +1,52 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const MANIFESTS = [ + ".claude-plugin/plugin.json", + ".cursor-plugin/plugin.json", + "openai/plugin.json", + "gemini-extension.json", +]; +const VERSION = /^(\d+)\.(\d+)\.(\d+)$/; +const USAGE = 'Usage: node .github/scripts/bump-version.mjs "" ["" ...]'; + +function exit(message) { + console.error(`${message}\n${USAGE}`); + process.exit(1); +} + +function nextVersion(current, bump) { + const [, major, minor, patch] = current.match(VERSION).map(Number); + if (bump === "patch") return `${major}.${minor}.${patch + 1}`; + if (bump === "minor") return `${major}.${minor + 1}.0`; + if (bump === "major") return `${major + 1}.0.0`; + if (!VERSION.test(bump)) exit(`"${bump}" is not patch, minor, major or an X.Y.Z version.`); + return bump; +} + +function isNewer(candidate, current) { + const [a, b] = [candidate, current].map((version) => version.match(VERSION).slice(1).map(Number)); + const index = a.findIndex((part, i) => part !== b[i]); + return index !== -1 && a[index] > b[index]; +} + +const [bump, ...changes] = process.argv.slice(2); +if (!bump) exit("Missing the version to bump to."); +if (changes.length === 0) exit("Describe at least one change for the changelog."); + +const current = JSON.parse(readFileSync(MANIFESTS[0], "utf8")).version; +const version = nextVersion(current, bump); +if (!isNewer(version, current)) exit(`${version} must be newer than the current version, ${current}.`); + +const changelog = readFileSync("CHANGELOG.md", "utf8"); +if (changelog.includes(`\n## ${version}\n`)) exit(`CHANGELOG.md already has a ${version} section.`); +if (!changelog.startsWith("# Changelog\n\n")) exit('CHANGELOG.md must start with a "# Changelog" heading.'); + +for (const path of MANIFESTS) { + const manifest = readFileSync(path, "utf8"); + writeFileSync(path, manifest.replace(/"version": "[^"]+"/, `"version": "${version}"`)); +} + +const section = `## ${version}\n\n${changes.map((change) => `- ${change}`).join("\n")}\n\n`; +writeFileSync("CHANGELOG.md", changelog.replace("# Changelog\n\n", `# Changelog\n\n${section}`)); + +console.log(`Bumped ${current} → ${version} in ${MANIFESTS.join(", ")} and added it to CHANGELOG.md.`); diff --git a/.github/scripts/check-plugin.mjs b/.github/scripts/check-plugin.mjs index 9b14e11..f7d26a4 100644 --- a/.github/scripts/check-plugin.mjs +++ b/.github/scripts/check-plugin.mjs @@ -39,6 +39,12 @@ function checkVersions() { } } +function checkChangelog() { + const version = readJson(MANIFESTS[0]).version; + const sections = readFileSync("CHANGELOG.md", "utf8").match(/^## .+$/gm) ?? []; + if (!sections.includes(`## ${version}`)) fail(`CHANGELOG.md: no "## ${version}" section for the current version`); +} + function serverUrls(path, key) { const servers = readJson(path).mcpServers; return Object.fromEntries(Object.entries(servers).map(([name, server]) => [name, server[key]])); @@ -177,6 +183,7 @@ const cliFlag = process.argv.indexOf("--cli"); if (cliFlag === -1) { checkNames(); checkVersions(); + checkChangelog(); checkMcpServers(); checkSkills(); checkFiles(); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2040b1e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + branches: [main] + paths: + - CHANGELOG.md + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-node@v7.1.0 + with: + node-version: 24 + - run: node .github/scripts/check-plugin.mjs + + - id: version + run: | + version=$(jq -r .version .claude-plugin/plugin.json) + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + + - name: Build the OpenAI plugin ZIP + env: + DEMO_RECORDING_URL: ${{ secrets.OPENAI_DEMO_RECORDING_URL }} + VERSION: ${{ steps.version.outputs.version }} + run: | + stage="$RUNNER_TEMP/polar" + mkdir -p "$stage/.codex-plugin" + if [ -n "$DEMO_RECORDING_URL" ]; then + jq --arg url "$DEMO_RECORDING_URL" \ + '.extensions["com.openai"].review.demo_recording_url = $url' \ + openai/plugin.json > "$stage/.codex-plugin/plugin.json" + else + echo "::warning::The OPENAI_DEMO_RECORDING_URL secret isn't set, so OpenAI's portal will reject this ZIP." + cp openai/plugin.json "$stage/.codex-plugin/plugin.json" + fi + cp openai/mcp.json "$stage/.mcp.json" + cp -R skills README.md LICENSE "$stage/" + for asset in $(jq -r '.interface.logo, .interface.composerIcon' openai/plugin.json | sort -u); do + mkdir -p "$stage/$(dirname "$asset")" + cp "$asset" "$stage/$asset" + done + (cd "$stage" && zip -qrX "$RUNNER_TEMP/polar-openai-$VERSION.zip" .) + unzip -l "$RUNNER_TEMP/polar-openai-$VERSION.zip" + + - uses: actions/upload-artifact@v7 + with: + name: polar-openai-${{ steps.version.outputs.version }} + path: ${{ runner.temp }}/polar-openai-${{ steps.version.outputs.version }}.zip + retention-days: 30 + + - name: Create the tag and GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists." + exit 0 + fi + awk -v heading="## $VERSION" '$0 == heading { found = 1; next } /^## / { found = 0 } found' \ + CHANGELOG.md > "$RUNNER_TEMP/notes.md" + gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22b2e7b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- First release: the Polar MCP servers (`polar` and `polar-sandbox`) and the `setup-polar`, `polar-integration`, `polar-testing` and `polar-migration` skills, for Claude Code, Codex, Cursor, Gemini CLI and Antigravity. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..5cd9ac1 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,51 @@ +# Releasing a new version + +Every release gets a version number, a changelog entry, a `vX.Y.Z` tag and a GitHub release. The tag, the release and the OpenAI ZIP are created automatically when the release PR is merged. + +## 1. Bump the version and add a changelog entry + +On a new branch from `main`, run: + +```bash +node .github/scripts/bump-version.mjs patch 'Change 1', 'Change 2' +``` + +Use `patch`, `minor` or `major`, or an exact version such as `0.2.0`. Each quoted argument becomes one bullet. Use single quotes so backticks stay literal. The script sets the version in all four manifests (`.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json`, `openai/plugin.json` and `gemini-extension.json`) and adds a section for it at the top of `CHANGELOG.md`. That section becomes the GitHub release notes, so write it for people who use the plugin. You can still edit it before committing. + +## 2. Open the pull request + +The [Validate plugin](https://github.com/polarsource/agent-toolkit/actions/workflows/validate.yml) workflow checks that the four versions match and that `CHANGELOG.md` has a section for the new version. It also installs and validates the plugin in Claude Code, Codex, Cursor, Gemini CLI and Antigravity, and checks every `polar` CLI command the docs mention against the published CLI. Merge once it passes. + +## 3. Check the release run + +Merging runs the **Release** workflow. In the repository's Actions tab, check that it: + +1. passed the plugin checks, +2. created the `vX.Y.Z` tag and GitHub release with your changelog notes, +3. attached the `polar-openai-X.Y.Z` ZIP to the run as an artifact. + +If it fails, fix the cause on `main`, then start it again from **Actions → Release → Run workflow**. It skips the release if the tag already exists and always rebuilds the ZIP. + +## 4. Update each listing + +| Where | What to do | +| --- | --- | +| Anthropic's plugin directory | Nothing. It rescans `main`, and a reviewer approves the new version. Check its status at [claude.ai/directory/manage](https://claude.ai/directory/manage). | +| OpenAI's plugin directory | Only if skills, test cases, listing metadata or the MCP URL changed: download the `polar-openai-X.Y.Z` artifact from the release run, upload it at [platform.openai.com/plugins](https://platform.openai.com/plugins) and submit it for review. Changes to the MCP server's tools need no upload. OpenAI rescans the server daily and holds new tools for review. | +| Cursor Marketplace | Cursor reviews every update. Check the plugin's status in Cursor's publisher dashboard, and resubmit at [cursor.com/marketplace/publish](https://cursor.com/marketplace/publish) if the new version doesn't show up for review. | +| Gemini CLI extension gallery | Nothing. It indexes new tags daily, as long as the repository has the `gemini-cli-extension` topic. | +| GitHub installs | Nothing. Users of Claude Code, Codex, Cursor, Gemini CLI and Antigravity get the new version when they update the plugin. | + +## Recording a new demo video + +OpenAI's review needs a demo video of the test cases in `openai/plugin.json`. When the test cases or the tools they use change, record a new one and store its link as a repository secret: + +```bash +gh secret set OPENAI_DEMO_RECORDING_URL --repo polarsource/agent-toolkit +``` + +Then rebuild the ZIP from **Actions → Release → Run workflow**. The link must open without signing in, and the video shouldn't show the review account's login code. + +## Changing the MCP server URLs + +Each agent reads the servers from a different file, so update all four: `.mcp.json`, `gemini-extension.json`, `mcp_config.json` and `openai/mcp.json`. The plugin checks fail if they disagree.