diff --git a/.github/workflows/agent-marketplace-release.yaml b/.github/workflows/agent-marketplace-release.yaml index c3a6e38..dc7c805 100644 --- a/.github/workflows/agent-marketplace-release.yaml +++ b/.github/workflows/agent-marketplace-release.yaml @@ -98,6 +98,29 @@ on: 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. @@ -105,6 +128,11 @@ on: 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 @@ -166,6 +194,93 @@ jobs: 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 ` 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: @@ -363,11 +478,24 @@ jobs: 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' diff --git a/README.md b/README.md index da629a3..d23d7cf 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This repository intentionally contains only generic marketplace automation: - skill and plugin manifest validation - pull-request preview archives - release archives and installation summaries +- optional npm registry publishing for agents that support npm catalogs Container, infrastructure, deployment, and environment-specific workflows are kept in the private `Walter0697/common-workflow` repository. @@ -28,3 +29,36 @@ The validator is also available as: ```yaml uses: walter-base/common-workflow/actions/validate-agent-marketplace@master ``` + +## Publishing to an npm registry + +The generic archive and its installer are opaque to coding agents: something has +to download and unpack them, which is what the generated installer script does. +Agents can, however, resolve npm packages on their own, so publishing the same +release contents to an npm registry lets them install and update without an +installer. + +Enable it on the release workflow: + +```yaml +with: + publish_npm: true + npm_package_name: "@acme/agent-skills" + # npm_registry defaults to the Gitea npm registry for gitea_user +secrets: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} +``` + +Notes: + +- the package is built from the same staged directory as the generic archive, so + the two can never drift apart +- `package.json` is generated at publish time with the release version, which + keeps it in step with the plugin manifest versions that release-please bumps +- `npm_package_name` must be scoped, because the scope is what routes installs to + the registry in `.npmrc`. The scope does not have to match the registry owner: + Gitea's own example maps an `@test` scope to a `testuser` registry URL +- `NPM_TOKEN` is used when set; otherwise `GITEA_TOKEN` is reused +- publishing fails if any released path is absent from the packed tarball, which + guards against npm's ignore rules silently dropping `.agents/` or + `.claude-plugin/`