name: Agent marketplace release on: workflow_call: inputs: target_branch: description: Branch that receives release-please changes. required: false type: string default: main skill_root: description: Canonical skill directory relative to the repository root. required: false type: string default: skills claude_marketplace_file: description: >- Claude marketplace manifest relative to the repository root. Empty by default: a plugin repository no longer carries one, because the central marketplace is generated from the package registry. required: false type: string default: "" claude_plugin_file: description: Claude plugin manifest relative to the repository root. required: false type: string default: .claude-plugin/plugin.json codex_plugin_root: description: >- Codex plugin root relative to the repository root. Defaults to the repository itself: codex's npm plugin source has no path field, so the package root must be the plugin root. required: false type: string default: "." cursor_plugin_root: description: >- Cursor plugin root relative to the repository root. Empty disables cursor validation. It defaulted to a scaffold path, which meant every caller that stopped shipping a cursor bundle had to remember to blank it or CI would demand a directory that never existed. required: false type: string default: "" marketplace_url: description: >- URL of the central marketplace manifest, printed in the release notes as the way to install. Empty falls back to the per-release instructions. required: false type: string default: "" marketplace_dispatch_url: description: >- Gitea workflow-dispatch endpoint for the manifest generator. Called after publishing so the marketplace reflects a new plugin promptly rather than at the next scheduled run. Gitea's own registry_package event cannot be used: it only fires for packages linked to the repository, and these are published from here. required: false type: string default: "" release_config_file: description: Release Please configuration file. required: false type: string default: release-please-config.json release_manifest_file: description: Release Please manifest file. required: false type: string default: .release-please-manifest.json package_paths: description: Space-separated paths to package into the release archive. required: false type: string default: skills .claude-plugin .cursor-plugin .agents plugins README.md CHANGELOG.md catalog_file: description: Optional catalog metadata JSON file to publish beside the release artifacts. required: false type: string default: "" archive_name: description: Prefix used for the release archive. required: true type: string gitea_package_name: description: Public Gitea generic package name for the release artifact. required: true type: string claude_plugin_name: description: Claude plugin name used in the installation summary. required: false type: string default: marketplace codex_plugin_name: description: Codex plugin name used in the installation summary. required: false type: string default: marketplace claude_marketplace_name: description: Claude marketplace name used in the installation summary. required: false type: string default: marketplace codex_marketplace_name: description: Codex marketplace name shown in the installation summary. required: false type: string default: marketplace cursor_plugin_name: description: Cursor plugin name shown in the installation summary. required: false type: string default: marketplace publish_gitea: description: Upload public installers for Claude, Codex, OpenCode, and Cursor to Gitea. required: false type: boolean default: false gitea_url: description: Base URL of the Gitea instance. required: false type: string default: "" gitea_user: description: Gitea user or organization that owns the generic package. required: false type: string default: "" publish_npm: description: >- Publish the release contents as an npm package so agents that support npm marketplace catalogs can install and auto-update from the registry. required: false type: boolean default: false npm_package_name: description: >- Scoped npm package name, for example `@acme/agent-skills`. A scope is required because it is what routes installs to the registry, but it does not have to match the registry owner. Required when publish_npm is enabled. required: false type: string default: "" npm_registry: description: >- npm registry base URL, for example `https://gitea.example.com/api/packages/acme/npm/`. Defaults to the Gitea npm registry for gitea_user when gitea_url is set. required: false type: string default: "" secrets: GITHUB_PAT: description: Optional GitHub token for release-please pull requests. required: false MARKETPLACE_DISPATCH_TOKEN: description: >- Gitea token permitted to dispatch the manifest generator workflow. Only needed when marketplace_dispatch_url is set. required: false GITEA_TOKEN: description: Gitea token with permission to publish generic packages. required: false NPM_TOKEN: description: >- Token for the npm registry. Falls back to GITEA_TOKEN when unset, which covers publishing to a Gitea npm registry. required: false permissions: contents: write pull-requests: write concurrency: group: release-please cancel-in-progress: false jobs: release-please: runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} steps: - name: Create or update release PR id: release uses: googleapis/release-please-action@v4 with: token: ${{ secrets.GITHUB_PAT || github.token }} target-branch: ${{ inputs.target_branch }} config-file: ${{ inputs.release_config_file }} manifest-file: ${{ inputs.release_manifest_file }} publish: needs: release-please if: needs.release-please.outputs.release_created == 'true' runs-on: ubuntu-latest permissions: contents: write steps: - name: Check out released version uses: actions/checkout@v4 with: ref: ${{ needs.release-please.outputs.tag_name }} - name: Validate released marketplace uses: walter-base/common-workflow/actions/validate-agent-marketplace@master with: skill_root: ${{ inputs.skill_root }} claude_marketplace_file: ${{ inputs.claude_marketplace_file }} claude_plugin_file: ${{ inputs.claude_plugin_file }} codex_plugin_root: ${{ inputs.codex_plugin_root }} cursor_plugin_root: ${{ inputs.cursor_plugin_root }} - name: Build release archive env: VERSION: ${{ needs.release-please.outputs.version }} PACKAGE_PATHS: ${{ inputs.package_paths }} ARCHIVE_NAME: ${{ inputs.archive_name }} CATALOG_FILE: ${{ inputs.catalog_file }} shell: bash run: | set -euo pipefail dir="${ARCHIVE_NAME}-${VERSION}" mkdir -p "dist/${dir}" for path in ${PACKAGE_PATHS}; do cp -R "$path" "dist/${dir}/" done if [[ -n "$CATALOG_FILE" ]]; then jq empty "$CATALOG_FILE" cp "$CATALOG_FILE" dist/catalog.json fi tar -czf "dist/${ARCHIVE_NAME}-${VERSION}.tar.gz" -C dist "${dir}" - name: Set up Node if: inputs.publish_npm uses: actions/setup-node@v4 with: node-version: 20 - name: Publish npm package if: inputs.publish_npm id: npm env: ARCHIVE_NAME: ${{ inputs.archive_name }} GITEA_URL: ${{ inputs.gitea_url }} GITEA_USER: ${{ inputs.gitea_user }} NPM_PACKAGE_NAME: ${{ inputs.npm_package_name }} NPM_REGISTRY: ${{ inputs.npm_registry }} NPM_TOKEN: ${{ secrets.NPM_TOKEN || secrets.GITEA_TOKEN }} PACKAGE_PATHS: ${{ inputs.package_paths }} VERSION: ${{ needs.release-please.outputs.version }} shell: bash run: | set -euo pipefail : "${NPM_PACKAGE_NAME:?Set npm_package_name when publish_npm is enabled}" : "${NPM_TOKEN:?Pass NPM_TOKEN or GITEA_TOKEN when publish_npm is enabled}" registry="${NPM_REGISTRY}" if [[ -z "$registry" ]]; then : "${GITEA_URL:?Set npm_registry, or gitea_url and gitea_user, when publish_npm is enabled}" : "${GITEA_USER:?Set npm_registry, or gitea_url and gitea_user, when publish_npm is enabled}" registry="${GITEA_URL%/}/api/packages/${GITEA_USER}/npm/" fi [[ "$registry" == */ ]] || registry="${registry}/" scope="${NPM_PACKAGE_NAME%%/*}" if [[ "$scope" != @* ]]; then echo "npm_package_name must be scoped (for example @acme/agent-skills) so the registry mapping is unambiguous" >&2 exit 1 fi # The staged directory already holds exactly the released content, so the npm # package and the generic archive can never drift apart. pkg_dir="dist/${ARCHIVE_NAME}-${VERSION}" files_json="$(printf '%s\n' ${PACKAGE_PATHS} | jq -R . | jq -s .)" jq -n \ --arg name "$NPM_PACKAGE_NAME" \ --arg version "$VERSION" \ --argjson files "$files_json" \ '{name: $name, version: $version, files: $files, private: false}' \ > "${pkg_dir}/package.json" # `npm pack ` without a leading `./` is parsed as a GitHub owner/repo # spec, so npm tries to clone it instead of packing the directory. tarball_name="$(npm pack --pack-destination dist "./${pkg_dir}")" : "${tarball_name:?npm pack did not produce a tarball}" tarball="dist/${tarball_name}" # npm's default ignore rules have historically surprised people with # dot-directories, and the agent catalogs live in `.agents/` and # `.claude-plugin/`. Fail here rather than shipping an unusable package. contents="$(tar -tzf "$tarball")" missing=0 for path in ${PACKAGE_PATHS}; do [[ -e "$path" ]] || continue if ! grep -q "^package/${path}" <<<"$contents"; then echo "npm package is missing released path: ${path}" >&2 missing=1 fi done if [[ "$missing" -ne 0 ]]; then echo "Refusing to publish an npm package that does not match the release archive." >&2 exit 1 fi registry_auth="${registry#https://}" registry_auth="${registry_auth#http://}" { printf '%s:registry=%s\n' "$scope" "$registry" printf '//%s:_authToken=%s\n' "$registry_auth" "$NPM_TOKEN" } > "$HOME/.npmrc" trap 'rm -f "$HOME/.npmrc"' EXIT npm publish "./${tarball}" --registry "$registry" # Keep the packed tarball out of the GitHub Release upload glob. rm -f "$tarball" echo "npm_package=${NPM_PACKAGE_NAME}" >> "$GITHUB_OUTPUT" echo "npm_registry=${registry}" >> "$GITHUB_OUTPUT" - name: Build Gitea installers if: inputs.publish_gitea env: ARCHIVE_NAME: ${{ inputs.archive_name }} GITEA_PACKAGE_NAME: ${{ inputs.gitea_package_name }} GITEA_URL: ${{ inputs.gitea_url }} GITEA_USER: ${{ inputs.gitea_user }} VERSION: ${{ needs.release-please.outputs.version }} shell: bash run: | : "${GITEA_URL:?Set gitea_url when publish_gitea is enabled}" : "${GITEA_USER:?Set gitea_user when publish_gitea is enabled}" cat > "dist/install-${ARCHIVE_NAME}-${VERSION}.sh" <&2; exit 2 ;; esac if [[ "\$version" == latest ]]; then version="\$(curl --fail --location --retry 3 "${GITEA_URL%/}/api/v1/packages/${GITEA_USER}/generic/${GITEA_PACKAGE_NAME}/-/latest" | jq -r '.version // empty')" : "\${version:?Gitea did not return a latest package version}" fi install_dir="\${MEDIVA_PLUGIN_DIR:-\$HOME/.local/share/${ARCHIVE_NAME}/\$agent}" archive_url="${GITEA_URL%/}/api/packages/${GITEA_USER}/generic/${GITEA_PACKAGE_NAME}/\${version}/${ARCHIVE_NAME}-\${version}.tar.gz" tmp_dir="\$(mktemp -d)" trap 'rm -rf "\$tmp_dir"' EXIT curl --fail --location --retry 3 "\$archive_url" -o "\$tmp_dir/plugin.tar.gz" mkdir -p "\$tmp_dir/extracted" tar -xzf "\$tmp_dir/plugin.tar.gz" -C "\$tmp_dir/extracted" rm -rf "\$install_dir" mkdir -p "\$(dirname "\$install_dir")" mv "\$tmp_dir/extracted/${ARCHIVE_NAME}-\${version}" "\$install_dir" case "\$agent" in claude) marketplace_name="\$(jq -r '.name // empty' "\$install_dir/.claude-plugin/marketplace.json")" : "\${marketplace_name:?Claude marketplace manifest is missing a name}" claude plugin marketplace remove "\$marketplace_name" >/dev/null 2>&1 || true claude plugin marketplace add "\$install_dir" plugin_name="\$(jq -r '.plugins[0].name // empty' "\$install_dir/.claude-plugin/marketplace.json")" claude plugin install "\$plugin_name@\$marketplace_name" ;; codex) marketplace_name="\$(jq -r '.name // empty' "\$install_dir/.agents/plugins/marketplace.json")" : "\${marketplace_name:?Codex marketplace manifest is missing a name}" codex plugin marketplace remove "\$marketplace_name" >/dev/null 2>&1 || true codex plugin marketplace add "\$install_dir" plugin_name="\$(jq -r '.plugins[0].name // empty' "\$install_dir/.agents/plugins/marketplace.json")" codex plugin add "\$plugin_name@\$marketplace_name" ;; opencode|cursor) npx --yes skills add "\$install_dir/skills" --agent "\$agent" --skill '*' --yes --global ;; esac echo "Installed skills for \$agent from Gitea." EOF chmod +x "dist/install-${ARCHIVE_NAME}-${VERSION}.sh" if false; then cat > "dist/install-${ARCHIVE_NAME}-codex-${VERSION}.sh" </dev/null 2>&1 || true codex plugin marketplace remove "\$marketplace_name" >/dev/null 2>&1 || true codex plugin marketplace add "\$install_dir" codex plugin add "${CODEX_PLUGIN_NAME}@\$marketplace_name" echo "Installed ${CODEX_PLUGIN_NAME} for Codex from Gitea marketplace \$marketplace_name." EOF chmod +x "dist/install-${ARCHIVE_NAME}-codex-${VERSION}.sh" cat > "dist/install-${ARCHIVE_NAME}-opencode-${VERSION}.sh" < "dist/install-${ARCHIVE_NAME}-cursor-${VERSION}.sh" <> "$GITHUB_OUTPUT" - name: Upload archive to GitHub Release if: ${{ !inputs.publish_gitea }} uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.release-please.outputs.tag_name }} files: dist/* - name: Refresh the central marketplace # The manifest is derived from the package registry, so publishing is # all this job has to do — the generator re-derives the catalogue from # whatever the registry now holds. This call only makes that happen now # rather than at the generator's next scheduled run, and it matters for # a new version just as much as a new plugin: codex entries carry the # resolved version, so a release changes the catalogue's bytes. if: inputs.marketplace_dispatch_url != '' env: DISPATCH_URL: ${{ inputs.marketplace_dispatch_url }} DISPATCH_TOKEN: ${{ secrets.MARKETPLACE_DISPATCH_TOKEN }} shell: bash run: | set -euo pipefail if [[ -z "${DISPATCH_TOKEN:-}" ]]; then echo "marketplace_dispatch_url is set but MARKETPLACE_DISPATCH_TOKEN is not; skipping." >&2 exit 0 fi # Never fail the release over this: the package is already published, # and the generator's schedule will pick it up regardless. Failing here # would turn a delayed catalogue into a red release. status="$(curl --silent --show-error --output /dev/stderr --write-out '%{http_code}' \ --max-time 30 -X POST \ -H "Authorization: token ${DISPATCH_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"ref":"main"}' \ "$DISPATCH_URL" || true)" if [[ "$status" == "204" || "$status" == "201" || "$status" == "200" ]]; then echo "Requested marketplace regeneration (HTTP $status)." else echo "Could not reach the manifest generator (HTTP $status); the scheduled run will catch up." >&2 fi - name: Write GitHub installation summary env: VERSION: ${{ needs.release-please.outputs.version }} TAG: ${{ needs.release-please.outputs.tag_name }} ARCHIVE_NAME: ${{ inputs.archive_name }} REPOSITORY: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} SKILL_ROOT: ${{ inputs.skill_root }} CLAUDE_PLUGIN_NAME: ${{ inputs.claude_plugin_name }} CLAUDE_MARKETPLACE_NAME: ${{ inputs.claude_marketplace_name }} CODEX_PLUGIN_NAME: ${{ inputs.codex_plugin_name }} CODEX_MARKETPLACE_NAME: ${{ inputs.codex_marketplace_name }} CURSOR_PLUGIN_NAME: ${{ inputs.cursor_plugin_name }} GITEA_INSTALLER_URL: ${{ steps.gitea.outputs.installer_url }} NPM_PACKAGE: ${{ steps.npm.outputs.npm_package }} NPM_REGISTRY_URL: ${{ steps.npm.outputs.npm_registry }} MARKETPLACE_URL: ${{ inputs.marketplace_url }} run: | { printf '# Agent plugin %s\n\n' "$VERSION" printf 'Release tag: `%s`\n\n' "$TAG" if [[ -n "$NPM_PACKAGE" ]]; then printf '## npm registry\n\n' printf 'Published `%s@%s` to `%s`.\n\n' "$NPM_PACKAGE" "$VERSION" "$NPM_REGISTRY_URL" printf 'Agents that support npm marketplace catalogs can install this package ' printf 'directly and pick up later releases without re-running an installer. ' printf 'Authenticate first if the registry is private:\n\n' printf '%s\n' '```bash' printf 'npm config set %s:registry %s\n' "${NPM_PACKAGE%%/*}" "$NPM_REGISTRY_URL" printf '%s\n\n' '```' fi if [[ -n "$MARKETPLACE_URL" ]]; then # One marketplace lists every plugin, so these instructions do # not change per release and never name this version: the # manifest points at the package and npm resolves the newest. printf '## Claude Code\n\n' printf '%s\n' '```bash' printf 'claude plugin marketplace add %s\n' "$MARKETPLACE_URL" printf 'claude plugin install %s@%s\n' "$CLAUDE_PLUGIN_NAME" "$CLAUDE_MARKETPLACE_NAME" printf '%s\n\n' '```' printf 'Later releases arrive with:\n\n' printf '%s\n' '```bash' printf 'claude plugin marketplace update %s\n' "$CLAUDE_MARKETPLACE_NAME" printf '%s\n\n' '```' elif [[ -n "$GITEA_INSTALLER_URL" ]]; then printf '## Claude Code\n\n' printf '%s\n' '```bash' printf 'curl --fail --location --retry 3 "%s" | bash -s -- claude\n' "$GITEA_INSTALLER_URL" printf '%s\n\n' '```' printf '## Codex\n\n' printf '%s\n' '```bash' printf 'curl --fail --location --retry 3 "%s" | bash -s -- codex\n' "$GITEA_INSTALLER_URL" printf '%s\n\n' '```' else printf '## Claude Code\n\n' printf '%s\n' '```bash' printf 'claude plugin marketplace add %s@%s\n' "$REPOSITORY" "$TAG" printf 'claude plugin install %s@%s\n' "$CLAUDE_PLUGIN_NAME" "$CLAUDE_MARKETPLACE_NAME" printf '%s\n\n' '```' printf '## Codex\n\n' printf '%s\n' '```bash' printf 'codex plugin marketplace add %s --ref %s\n' "$REPOSITORY" "$TAG" printf 'codex plugin add %s@%s\n' "$CODEX_PLUGIN_NAME" "$CODEX_MARKETPLACE_NAME" printf '%s\n\n' '```' fi if [[ -n "$GITEA_INSTALLER_URL" ]]; then printf '## OpenCode\n\n' printf '%s\n' '```bash' printf 'curl --fail --location --retry 3 "%s" | bash -s -- opencode\n' "$GITEA_INSTALLER_URL" printf '%s\n\n' '```' printf '## Cursor\n\n' printf '%s\n' '```bash' printf 'curl --fail --location --retry 3 "%s" | bash -s -- cursor\n' "$GITEA_INSTALLER_URL" printf '%s\n\n' '```' else printf '## OpenCode and Cursor\n\n' printf '%s\n' '```bash' printf 'npx skills add %s/%s/tree/%s/%s --agent opencode --skill '\''*'\'' --yes --global\n' "$SERVER_URL" "$REPOSITORY" "$TAG" "$SKILL_ROOT" printf 'npx skills add %s/%s/tree/%s/%s --agent cursor --skill '\''*'\'' --yes --global\n' "$SERVER_URL" "$REPOSITORY" "$TAG" "$SKILL_ROOT" printf '%s\n' '```' fi if [[ -n "$GITEA_INSTALLER_URL" ]]; then printf '\nUpdate all installed agents later with:\n\n%s\n\n' '```bash' printf 'curl --fail --location --retry 3 "%s" | bash -s -- update all\n' "$GITEA_INSTALLER_URL" printf '%s\n' '```' printf 'Installers are public Gitea artifacts; no Gitea token is required.\n' fi } >> "$GITHUB_STEP_SUMMARY"