Files
common-workflow/.github/workflows/agent-marketplace-release.yaml
T
Walter ChengandClaude Opus 5 936760d68f feat: publish release contents to an npm registry
The generic archive and its installer are opaque to coding agents: something
external has to download and unpack them, which is why the generated installer
script exists. Agents can resolve npm packages on their own, so publishing the
same release contents to an npm registry lets them install and update without
an installer in the loop.

Opt in with `publish_npm`. The package is built from the same staged directory
as the generic archive, so the two cannot drift, and `package.json` is generated
with the release version so it stays in step with the plugin manifest versions
that release-please already bumps.

Two npm behaviours are handled explicitly:

- `npm pack <path>` without a leading `./` is parsed as a GitHub owner/repo
  spec, so npm attempts a clone instead of packing the directory.
- npm's ignore rules can drop dot-directories, and the agent catalogs live in
  `.agents/` and `.claude-plugin/`. The packed tarball is verified to contain
  every released path before publishing, rather than shipping a package that
  installs but has no catalog.

The generic archive, installer, and GitHub Release paths are unchanged, so
existing consumers are unaffected.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jm9AfbCJBD4tkxnxxnq7Wm
2026-07-26 14:15:10 -04:00

547 lines
23 KiB
YAML

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.
required: false
type: string
default: .claude-plugin/marketplace.json
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.
required: false
type: string
default: plugins/marketplace
cursor_plugin_root:
description: Cursor plugin root relative to the repository root.
required: false
type: string
default: plugins/marketplace
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
archive_name:
description: Prefix used for the release archive.
required: false
type: string
default: agent-marketplace
gitea_package_name:
description: Public Gitea generic package name for the release artifact.
required: false
type: string
default: agent-marketplace
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`. The scope must
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
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 }}
shell: bash
run: |
dir="${ARCHIVE_NAME}-${VERSION}"
mkdir -p "dist/${dir}"
for path in ${PACKAGE_PATHS}; do
cp -R "$path" "dist/${dir}/"
done
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 <path>` 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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
command="\${1:-}"
agent="\$command"
version="\${2:-${VERSION}}"
if [[ "\$command" == update ]]; then
agent="\${2:-all}"
version="latest"
fi
if [[ "\$command" == update && "\$agent" == all ]]; then
for target in claude codex opencode cursor; do
"\$0" "\$target" latest
done
exit 0
fi
case "\$agent" in
claude|codex|opencode|cursor) ;;
*) echo "Usage: \$0 {claude|codex|opencode|cursor} [version] | update [agent|all]" >&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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
version="\${1:-${VERSION}}"
install_dir="\${MEDIVA_PLUGIN_DIR:-\$HOME/.local/share/${ARCHIVE_NAME}/codex}"
archive_url="${GITEA_URL%/}/api/packages/${GITEA_USER}/generic/${CODEX_PACKAGE_NAME}/\${version}/${ARCHIVE_NAME}-codex-\${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"
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 '${CODEX_MARKETPLACE_NAME}' >/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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
version="\${1:-${VERSION}}"
install_dir="\${MEDIVA_PLUGIN_DIR:-\$HOME/.local/share/${ARCHIVE_NAME}/opencode}"
archive_url="${GITEA_URL%/}/api/packages/${GITEA_USER}/generic/${OPENCODE_PACKAGE_NAME}/\${version}/${ARCHIVE_NAME}-opencode-\${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"
npx --yes skills add "\$install_dir/skills" --agent opencode --skill '*' --yes --global
echo "Installed skills for OpenCode from Gitea."
EOF
chmod +x "dist/install-${ARCHIVE_NAME}-opencode-${VERSION}.sh"
cat > "dist/install-${ARCHIVE_NAME}-cursor-${VERSION}.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
version="\${1:-${VERSION}}"
install_dir="\${MEDIVA_PLUGIN_DIR:-\$HOME/.local/share/${ARCHIVE_NAME}/cursor}"
archive_url="${GITEA_URL%/}/api/packages/${GITEA_USER}/generic/${CURSOR_PACKAGE_NAME}/\${version}/${ARCHIVE_NAME}-cursor-\${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"
npx --yes skills add "\$install_dir/skills" --agent cursor --skill '*' --yes --global
echo "Installed skills for Cursor from Gitea."
EOF
chmod +x "dist/install-${ARCHIVE_NAME}-cursor-${VERSION}.sh"
fi
- name: Upload archive to Gitea
if: inputs.publish_gitea
id: gitea
env:
ARCHIVE_NAME: ${{ inputs.archive_name }}
GITEA_PACKAGE_NAME: ${{ inputs.gitea_package_name }}
GITEA_URL: ${{ inputs.gitea_url }}
GITEA_USER: ${{ inputs.gitea_user }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
run: |
: "${GITEA_URL:?Set gitea_url when publish_gitea is enabled}"
: "${GITEA_USER:?Set gitea_user when publish_gitea is enabled}"
: "${GITEA_TOKEN:?Pass GITEA_TOKEN when publish_gitea is enabled}"
archive="${ARCHIVE_NAME}-${VERSION}.tar.gz"
installer="install-${ARCHIVE_NAME}-${VERSION}.sh"
package_base="${GITEA_URL%/}/api/packages/${GITEA_USER}/generic/${GITEA_PACKAGE_NAME}/${VERSION}"
for upload in \
"dist/${archive}|${package_base}/${archive}" \
"dist/${installer}|${package_base}/${installer}"; do
file="${upload%%|*}"
url="${upload#*|}"
curl --fail-with-body --retry 3 \
--user "${GITEA_USER}:${GITEA_TOKEN}" \
--upload-file "$file" \
"$url"
done
echo "installer_url=${package_base}/${installer}" >> "$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: 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 }}
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 "$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"