From 5d9a2dc0d2b52881ee9c11b3a7572f9279c9551c Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 25 Jun 2026 11:06:41 +0200 Subject: [PATCH 1/8] CEXT-6367: implement skills release process and promotion workflow --- .changeset/commerce-plugin-bootstrap.md | 6 + .github/DEVELOPMENT.md | 20 + .github/workflows/publish-public.yml | 38 +- plugins/commerce/AGENTS.md | 58 +- plugins/commerce/README.md | 22 +- plugins/commerce/app-management/README.md | 64 +-- plugins/commerce/app-management/package.json | 8 + .../skills/commerce-app-admin-ui/SKILL.md | 2 - .../commerce-app-business-config/SKILL.md | 2 - .../skills/commerce-app-eventing/SKILL.md | 2 - .../skills/commerce-app-init/SKILL.md | 2 - .../skills/commerce-app-storage/SKILL.md | 2 - .../skills/commerce-app-webhooks/SKILL.md | 2 - .../app-migration/.claude-plugin/plugin.json | 1 + plugins/commerce/app-migration/README.md | 10 +- plugins/commerce/app-migration/package.json | 8 + .../commerce/scripts/sync-plugin-version.mjs | 43 ++ pnpm-lock.yaml | 4 + pnpm-workspace.yaml | 1 + scripts/source/ci/release/promote-skills.ts | 461 +++++++++++++++ .../unit/ci/release/promote-skills.test.ts | 523 ++++++++++++++++++ .../unit/plugins/version-consistency.test.ts | 44 ++ .../CEXT-6367-skills-release-process.md | 2 +- 23 files changed, 1236 insertions(+), 89 deletions(-) create mode 100644 .changeset/commerce-plugin-bootstrap.md create mode 100644 plugins/commerce/app-management/package.json create mode 100644 plugins/commerce/app-migration/package.json create mode 100644 plugins/commerce/scripts/sync-plugin-version.mjs create mode 100644 scripts/source/ci/release/promote-skills.ts create mode 100644 scripts/test/unit/ci/release/promote-skills.test.ts create mode 100644 scripts/test/unit/plugins/version-consistency.test.ts diff --git a/.changeset/commerce-plugin-bootstrap.md b/.changeset/commerce-plugin-bootstrap.md new file mode 100644 index 000000000..2052938e8 --- /dev/null +++ b/.changeset/commerce-plugin-bootstrap.md @@ -0,0 +1,6 @@ +--- +"@adobe/aio-commerce-plugin-app-management": minor +"@adobe/aio-commerce-plugin-app-migration": minor +--- + +Promote Commerce plugins through the stable skills release process. diff --git a/.github/DEVELOPMENT.md b/.github/DEVELOPMENT.md index 9c78a4881..28965737c 100644 --- a/.github/DEVELOPMENT.md +++ b/.github/DEVELOPMENT.md @@ -496,6 +496,17 @@ When ready to publish to npm, use the **Promote to Release** workflow dispatch ( If there were snapshot versions like `1.2.5-beta-20260313T120000` on Artifactory, the resulting stable release is `1.2.5`. +Commerce plugins under `plugins/commerce/*` use the same changeset flow, but they are private +workspace packages and are not published to npm. A changeset targeting a plugin package is the +release intent for the stable skills channel. When the Release PR bumps a plugin version, the +public release workflow opens or updates a promotion PR in +[`adobe/skills`](https://github.com/adobe/skills) from the fixed branch +`promote/adobe-aio-commerce-sdk`. + +Skill-only Release PRs are valid. They do not publish npm packages and do not send the package +Slack notification, but they still run the `adobe/skills` promotion step and back-sync the +plugin version/changelog updates to `main`. + #### Back-sync After a public release, the back-sync is automatic — the workflow merges `release` directly into `main` via a merge commit. No manual step needed. @@ -516,3 +527,12 @@ If the deploy key ever needs to be rotated: 2. Add the public key under **Settings → Deploy keys** (enable write access) and note the new key ID. 3. Update the `DEPLOY_KEY` Actions secret with the new private key. 4. Update the ruleset bypass actor with the new key ID via `gh api`. + +The Commerce plugin promotion step also requires an `ADOBE_SKILLS_TOKEN` Actions secret. Provision +it as a fine-grained GitHub personal access token with the following settings: + +- **Resource owner:** `adobe` +- **Repository access:** `adobe/skills` only +- **Permissions:** Metadata (read), Contents (read/write), Pull requests (read/write) +- **Expiration:** 366 days (the maximum); set a calendar reminder to regenerate it before it + expires, then update the `ADOBE_SKILLS_TOKEN` Actions secret with the new value. diff --git a/.github/workflows/publish-public.yml b/.github/workflows/publish-public.yml index 0b871ff83..7865085b6 100644 --- a/.github/workflows/publish-public.yml +++ b/.github/workflows/publish-public.yml @@ -16,6 +16,7 @@ jobs: outputs: published: ${{ steps.changesets.outputs.published }} publishedPackages: ${{ steps.changesets.outputs.publishedPackages }} + promotedSkills: ${{ steps.promote-skills.outputs.promotedSkills }} permissions: contents: write pull-requests: write @@ -23,6 +24,8 @@ jobs: id-token: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 2 - uses: ./.github/actions/setup-release with: @@ -53,10 +56,43 @@ jobs: release-channel: public slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + - name: Detect changed Commerce plugin versions + id: changed-plugins + if: steps.changesets.outputs.hasChangesets == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SOURCE_REPOSITORY_PATH: ${{ github.workspace }} + with: + script: | + const { detectChangedCommercePluginVersions } = await import('${{ github.workspace }}/scripts/source/ci/release/promote-skills.ts'); + await detectChangedCommercePluginVersions(core, exec, process.env.SOURCE_REPOSITORY_PATH); + + - name: Checkout adobe/skills + if: steps.changed-plugins.outputs.changed == 'true' + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: adobe/skills + path: adobe-skills + token: ${{ secrets.ADOBE_SKILLS_TOKEN }} + fetch-depth: 0 + + - name: Promote changed Commerce plugins to adobe/skills + id: promote-skills + if: steps.changed-plugins.outputs.changed == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SOURCE_REPOSITORY_PATH: ${{ github.workspace }} + SKILLS_REPOSITORY_PATH: ${{ github.workspace }}/adobe-skills + with: + github-token: ${{ secrets.ADOBE_SKILLS_TOKEN }} + script: | + const { default: execPromoteSkills } = await import('${{ github.workspace }}/scripts/source/ci/release/promote-skills.ts'); + await execPromoteSkills(core, exec, github, context); + back-sync: name: Back-sync to main needs: release - if: needs.release.outputs.published == 'true' + if: needs.release.outputs.published == 'true' || needs.release.outputs.promotedSkills == 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/plugins/commerce/AGENTS.md b/plugins/commerce/AGENTS.md index 60547cb87..d527c96e5 100644 --- a/plugins/commerce/AGENTS.md +++ b/plugins/commerce/AGENTS.md @@ -2,24 +2,24 @@ Rules for modifying skills in `plugins/commerce/`. Follow these whenever you add, edit, or review a skill. -## Skill frontmatter +## Plugin releases -Every skill SKILL.md must include `metadata.version`. Increment it whenever the skill changes meaningfully (wording, logic, chaining, examples): +Each plugin is a private pnpm workspace package. Use changesets to express release intent: -```yaml -metadata: - author: adobe - sdk-package: "@adobe/aio-commerce-lib-app" - version: "0.0.1" # increment when the skill changes meaningfully -``` +1. Run `pnpm changeset add`. +2. Select the plugin package, for example `@adobe/aio-commerce-plugin-app-management`. +3. Choose the semver bump for the plugin change. +4. Write a concise, user-facing changeset message. -## Plugin version files +Do not manually bump versions in `tile.json` or `.claude-plugin/plugin.json`. The plugin +`package.json` is the authoritative version source; release automation syncs that version into +the plugin manifests. -`tile.json` and `.claude-plugin/plugin.json` at the plugin root carry the same `version` and `summary`. Bump both in lockstep whenever skills in the plugin change: +Plugin semver rules: -```json -{ "version": "1.1.1" } -``` +- `patch` — wording, examples, or minor clarifications with no behavioral change. +- `minor` — new skills, new references or assets, or additive behavioral changes. +- `major` — removed skills, renamed skills that break installs, or behavioral changes that break existing usage patterns. When adding a new skill to a plugin, also add a matching entry to `tile.json`: @@ -31,6 +31,38 @@ When adding a new skill to a plugin, also add a matching entry to `tile.json`: } ``` +## Plugin README files + +Plugin `README.md` files are copied verbatim to `adobe/skills` and must be stable-channel-ready: + +- Installation commands reference `adobe/skills`, not `adobe/aio-commerce-sdk`. +- Do not add experimental banners. +- Do not include contributor-only sections such as local testing, quality review, or eval workflows. Put contributor guidance in this file or `plugins/commerce/README.md`. + +## Skill frontmatter + +Each `SKILL.md` must open with a YAML frontmatter block. Required fields: + +- `name` — kebab-case identifier; must match the key in `tile.json` +- `description` — multi-line routing description (use `>` block scalar); written to tell the agent _when_ to invoke the skill, not to describe its contents +- `license` — always `Apache-2.0` +- `compatibility` — runtime and package requirements (use `>` block scalar) +- `metadata.author` — always `adobe` + +```yaml +--- +name: commerce-app-example +description: > + One or two sentences that tell the agent when to use this skill. +license: Apache-2.0 +compatibility: > + Requires Node.js 22+, aio CLI, and @adobe/aio-commerce-lib-app. + Requires a base app initialized with commerce-app-init. +metadata: + author: adobe +--- +``` + ## Inline examples are required Asset files (`assets/`) are loaded on demand and are **not** visible to the agent when it first reads the skill. Every skill must include at least one minimal inline code example in the body — enough that an agent can act without fetching the asset file. diff --git a/plugins/commerce/README.md b/plugins/commerce/README.md index b9b57bc02..c050adec6 100644 --- a/plugins/commerce/README.md +++ b/plugins/commerce/README.md @@ -18,8 +18,28 @@ Skills are available through two channels with different stability guarantees: | **Latest** | This repo (`adobe/aio-commerce-sdk`) | Experimental — may change at any time | Contributors, early adopters, unreleased features | | **Stable** | [`adobe/skills`](https://github.com/adobe/skills) | Production-ready, tested releases | Everyone else | -For most developers, install from `adobe/skills`. The commands in each plugin's README install from this repository and are intended for contributors and early adopters. +For most developers, install from `adobe/skills`. The commands in each plugin's README target the stable channel. ## Contributing See [AGENTS.md](./AGENTS.md) for authoring guidelines. + +### Local testing + +Install skills directly from a local checkout into a target project: + +```sh +cd ~/my-commerce-app +pnpx skills add /path/to/aio-commerce-sdk/plugins/commerce/app-management --yes +pnpx skills add /path/to/aio-commerce-sdk/plugins/commerce/app-migration --yes +``` + +### Quality review and evals + +Before shipping a skill, run the Tessl reviewer from the plugin directory: + +```sh +pnpx tessl skill review skills/ +``` + +Implemented skills may include evals in `skills//evals/evals.json`. Eval results are excluded from version control; only `evals/evals.json` is committed. diff --git a/plugins/commerce/app-management/README.md b/plugins/commerce/app-management/README.md index bc18cda38..c8f61d7bf 100644 --- a/plugins/commerce/app-management/README.md +++ b/plugins/commerce/app-management/README.md @@ -1,7 +1,5 @@ # Commerce App Management Skills -> ⚠️ **Experimental** — These skills are under active development and subject to change. - Agent skills for Adobe Commerce App Management, following the [agentskills.io](https://agentskills.io) open standard. Compatible with Claude Code, Cursor, VS Code Copilot, Gemini CLI, and other supported agents. ## Strategy @@ -37,67 +35,23 @@ A developer creating an app that needs events and webhooks would run `commerce-a **Claude Code plugin:** ```sh -/plugin marketplace add adobe/aio-commerce-sdk -/plugin install commerce-app-management@adobe-aio-commerce-sdk +/plugin marketplace add adobe/skills +/plugin install commerce-app-management@adobe-skills ``` **Tessl CLI:** ```sh -tessl install github:adobe/aio-commerce-sdk --skills commerce-app-management +tessl install github:adobe/skills --skills commerce-app-management ``` **pnpx skills:** ```sh -pnpx skills add adobe/aio-commerce-sdk --skill commerce-app-init -pnpx skills add adobe/aio-commerce-sdk --skill commerce-app-eventing -pnpx skills add adobe/aio-commerce-sdk --skill commerce-app-webhooks -pnpx skills add adobe/aio-commerce-sdk --skill commerce-app-business-config -pnpx skills add adobe/aio-commerce-sdk --skill commerce-app-storage -pnpx skills add adobe/aio-commerce-sdk --skill commerce-app-admin-ui -``` - -## Contributing - -### Local testing - -Install skills directly from a local checkout into a target project: - -```sh -cd ~/my-commerce-app - -# Install all skills -pnpx skills add /path/to/aio-commerce-sdk/plugins/commerce/app-management --yes - -# Install a specific skill only -pnpx skills add /path/to/aio-commerce-sdk/plugins/commerce/app-management --yes --skill commerce-app-init -``` - -### Quality review - -Before shipping a skill, run the tessl reviewer to catch structural and quality issues: - -```sh -pnpx tessl skill review skills/ +pnpx skills add adobe/skills --skill commerce-app-init +pnpx skills add adobe/skills --skill commerce-app-eventing +pnpx skills add adobe/skills --skill commerce-app-webhooks +pnpx skills add adobe/skills --skill commerce-app-business-config +pnpx skills add adobe/skills --skill commerce-app-storage +pnpx skills add adobe/skills --skill commerce-app-admin-ui ``` - -A passing skill has 0 errors and 0 warnings. The judge score should be ≥ 90%. - -### Evaluations - -Implemented skills ship with evals in `skills//evals/evals.json`, following the [agentskills.io eval format](https://agentskills.io/skill-creation/evaluating-skills.md). - -Install the `skill-creator` skill to automate the eval loop: - -```sh -pnpx skills add anthropics/skills --skill skill-creator -``` - -Then ask your agent: - -``` -Run evals for the commerce-app-init skill -``` - -The agent runs each prompt with and without the skill, grades assertions, and writes results to `skills/-workspace/`. Eval results are excluded from version control — only `evals/evals.json` is committed. diff --git a/plugins/commerce/app-management/package.json b/plugins/commerce/app-management/package.json new file mode 100644 index 000000000..dd038e8db --- /dev/null +++ b/plugins/commerce/app-management/package.json @@ -0,0 +1,8 @@ +{ + "name": "@adobe/aio-commerce-plugin-app-management", + "version": "1.1.1", + "private": true, + "scripts": { + "version": "node ../scripts/sync-plugin-version.mjs" + } +} diff --git a/plugins/commerce/app-management/skills/commerce-app-admin-ui/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-admin-ui/SKILL.md index 6d71f0564..88ce6fe53 100644 --- a/plugins/commerce/app-management/skills/commerce-app-admin-ui/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-admin-ui/SKILL.md @@ -15,8 +15,6 @@ compatibility: > Requires a base app initialized with commerce-app-init. metadata: author: adobe - sdk-package: "@adobe/aio-commerce-sdk" - version: "0.0.2" --- # Configure Commerce App Admin UI diff --git a/plugins/commerce/app-management/skills/commerce-app-business-config/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-business-config/SKILL.md index e0fe54d28..e65e57f45 100644 --- a/plugins/commerce/app-management/skills/commerce-app-business-config/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-business-config/SKILL.md @@ -12,8 +12,6 @@ compatibility: > Requires a base app initialized with commerce-app-init. metadata: author: adobe - sdk-package: "@adobe/aio-commerce-lib-app" - version: "0.0.2" --- # Configure Commerce App Business Config diff --git a/plugins/commerce/app-management/skills/commerce-app-eventing/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-eventing/SKILL.md index 94163ea1e..d1e0d855a 100644 --- a/plugins/commerce/app-management/skills/commerce-app-eventing/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-eventing/SKILL.md @@ -12,8 +12,6 @@ compatibility: > Requires a base app initialized with commerce-app-init. metadata: author: adobe - sdk-package: "@adobe/aio-commerce-lib-app" - version: "0.0.2" --- # Configure Commerce App Eventing diff --git a/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md index b468122cd..05e3b2cbf 100644 --- a/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md @@ -17,8 +17,6 @@ compatibility: > npx skills add adobe/skills --skill appbuilder-project-init -y metadata: author: adobe - sdk-package: "@adobe/aio-commerce-lib-app" - version: "0.0.2" --- # Initialize a new Commerce App diff --git a/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md index 2be4d1b88..1940dda87 100644 --- a/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md @@ -13,8 +13,6 @@ compatibility: > in the Adobe Developer Console. Requires a base app initialized with commerce-app-init. metadata: author: adobe - sdk-package: "@adobe/aio-commerce-lib-app" - version: "0.0.2" --- # Add Database Storage to a Commerce App diff --git a/plugins/commerce/app-management/skills/commerce-app-webhooks/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-webhooks/SKILL.md index 0f2b6f6a7..37ff16222 100644 --- a/plugins/commerce/app-management/skills/commerce-app-webhooks/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-webhooks/SKILL.md @@ -11,8 +11,6 @@ compatibility: > Requires a base app initialized with commerce-app-init. metadata: author: adobe - sdk-package: "@adobe/aio-commerce-lib-app" - version: "0.0.2" --- # Configure Commerce App Webhooks diff --git a/plugins/commerce/app-migration/.claude-plugin/plugin.json b/plugins/commerce/app-migration/.claude-plugin/plugin.json index 525c7d8f0..d3102f99c 100644 --- a/plugins/commerce/app-migration/.claude-plugin/plugin.json +++ b/plugins/commerce/app-migration/.claude-plugin/plugin.json @@ -6,6 +6,7 @@ "name": "Adobe", "email": "commerce-app-management@adobe.com" }, + "repository": "https://github.com/adobe/aio-commerce-sdk", "keywords": [ "adobe-commerce", "app-builder", diff --git a/plugins/commerce/app-migration/README.md b/plugins/commerce/app-migration/README.md index b395ba02e..f9665eb06 100644 --- a/plugins/commerce/app-migration/README.md +++ b/plugins/commerce/app-migration/README.md @@ -1,7 +1,5 @@ # Commerce App Migration Skills -> ⚠️ **Experimental** — These skills are under active development and subject to change. - Agent skills for migrating Adobe Commerce App Builder projects to the App Management approach, following the [agentskills.io](https://agentskills.io) open standard. Compatible with Claude Code, Cursor, VS Code Copilot, Gemini CLI, and other supported agents. ## Available skills @@ -48,18 +46,18 @@ Orchestrates the full migration workflow: project detection → domain analysis **Claude Code plugin:** ```sh -/plugin marketplace add adobe/aio-commerce-sdk -/plugin install commerce-app-migration@adobe-aio-commerce-sdk +/plugin marketplace add adobe/skills +/plugin install commerce-app-migration@adobe-skills ``` **Tessl CLI:** ```sh -tessl install github:adobe/aio-commerce-sdk --skills commerce-app-migrate +tessl install github:adobe/skills --skills commerce-app-migrate ``` **npx skills:** ```sh -npx skills add adobe/aio-commerce-sdk --skill commerce-app-migrate +npx skills add adobe/skills --skill commerce-app-migrate ``` diff --git a/plugins/commerce/app-migration/package.json b/plugins/commerce/app-migration/package.json new file mode 100644 index 000000000..59a66dd4e --- /dev/null +++ b/plugins/commerce/app-migration/package.json @@ -0,0 +1,8 @@ +{ + "name": "@adobe/aio-commerce-plugin-app-migration", + "version": "1.0.0", + "private": true, + "scripts": { + "version": "node ../scripts/sync-plugin-version.mjs" + } +} diff --git a/plugins/commerce/scripts/sync-plugin-version.mjs b/plugins/commerce/scripts/sync-plugin-version.mjs new file mode 100644 index 000000000..5efa7d8c2 --- /dev/null +++ b/plugins/commerce/scripts/sync-plugin-version.mjs @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const PLUGIN_ROOT = process.cwd(); + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf-8")); +} + +async function writeJson(path, data) { + await writeFile(path, `${JSON.stringify(data, null, 2)}\n`); +} + +async function main() { + const packageJson = await readJson(join(PLUGIN_ROOT, "package.json")); + const tileJsonPath = join(PLUGIN_ROOT, "tile.json"); + const pluginJsonPath = join(PLUGIN_ROOT, ".claude-plugin/plugin.json"); + + const tileJson = await readJson(tileJsonPath); + tileJson.version = packageJson.version; + await writeJson(tileJsonPath, tileJson); + + const pluginJson = await readJson(pluginJsonPath); + pluginJson.version = packageJson.version; + await writeJson(pluginJsonPath, pluginJson); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecb1891c9..85f172946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,10 @@ importers: specifier: 'catalog:' version: 6.0.3 + plugins/commerce/app-management: {} + + plugins/commerce/app-migration: {} + scripts: devDependencies: '@actions/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 33b980d4f..e5ecbd907 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - configs/* - packages/* - packages-private/* + - plugins/commerce/* - scripts - turbo/* diff --git a/scripts/source/ci/release/promote-skills.ts b/scripts/source/ci/release/promote-skills.ts new file mode 100644 index 000000000..5ad66a679 --- /dev/null +++ b/scripts/source/ci/release/promote-skills.ts @@ -0,0 +1,461 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +import { runGitHubScript } from "./utils.ts"; + +import type { AsyncFunctionArguments } from "./types.ts"; + +const SOURCE_REPOSITORY = "adobe/aio-commerce-sdk"; +const TARGET_OWNER = "adobe"; +const TARGET_REPO = "skills"; +const TARGET_REPOSITORY_URL = "https://github.com/adobe/skills"; +const PROMOTION_BRANCH = "promote/adobe-aio-commerce-sdk"; +const PROMOTED_REPOSITORY_FIELD = "https://github.com/adobe/aio-commerce-sdk"; + +type PluginPackageJson = { + name: string; + version: string; +}; + +type TileJson = { + name: string; + version: string; +}; + +type PluginPromotion = { + packagePath: string; + sourcePath: string; + targetPath: string; + packageName: string; + displayName: string; + version: string; + changelogEntries: string[]; +}; + +type PromotionOptions = { + sourceRepositoryPath: string; + skillsRepositoryPath: string; +}; + +type PullRequestBodySection = { + displayName: string; + version: string; + changelogEntries: string[]; +}; + +export default async function main( + core: AsyncFunctionArguments["core"], + exec: AsyncFunctionArguments["exec"], + github: AsyncFunctionArguments["github"], + context: AsyncFunctionArguments["context"], +) { + return await runGitHubScript(core, async () => { + await promoteSkills(core, exec, github, context, { + sourceRepositoryPath: requireEnv("SOURCE_REPOSITORY_PATH"), + skillsRepositoryPath: requireEnv("SKILLS_REPOSITORY_PATH"), + }); + }); +} + +export async function promoteSkills( + core: AsyncFunctionArguments["core"], + exec: AsyncFunctionArguments["exec"], + github: AsyncFunctionArguments["github"], + context: AsyncFunctionArguments["context"], + options: PromotionOptions, +) { + const packagePaths = await getChangedPluginPackagePaths( + exec, + options.sourceRepositoryPath, + ); + + if (!hasChangedCommercePluginVersions(packagePaths)) { + core.info("No changed Commerce plugin package versions found."); + core.setOutput("promotedSkills", "false"); + return; + } + + await checkoutPromotionBranch(exec, options.skillsRepositoryPath); + + const promotions = await preparePromotionArtifacts( + packagePaths, + options.sourceRepositoryPath, + options.skillsRepositoryPath, + ); + + const committed = await commitAndPushPromotion( + exec, + options.skillsRepositoryPath, + context.sha, + ); + + if (!committed) { + core.info( + "adobe/skills already reflects the promoted content. No changes to push.", + ); + core.setOutput("promotedSkills", "false"); + return; + } + + await upsertPromotionPullRequest(github, context.sha, promotions); + + core.setOutput("promotedSkills", "true"); + core.info( + `Promoted ${promotions.length} Commerce plugin(s): ${promotions + .map((promotion) => `${promotion.displayName}@${promotion.version}`) + .join(", ")}`, + ); +} + +export async function getChangedPluginPackagePaths( + exec: AsyncFunctionArguments["exec"], + sourceRepositoryPath: string, +) { + const diff = await exec.getExecOutput( + "git", + [ + "-C", + sourceRepositoryPath, + "diff", + "--name-only", + "HEAD^1", + "HEAD", + "--", + "plugins/commerce/*/package.json", + ], + { silent: true }, + ); + + const packagePaths = diff.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const changedPackagePaths: string[] = []; + + for (const packagePath of packagePaths) { + if (await packageVersionChanged(exec, sourceRepositoryPath, packagePath)) { + changedPackagePaths.push(packagePath); + } + } + + return changedPackagePaths; +} + +export function hasChangedCommercePluginVersions(packagePaths: string[]) { + return packagePaths.length > 0; +} + +export async function detectChangedCommercePluginVersions( + core: AsyncFunctionArguments["core"], + exec: AsyncFunctionArguments["exec"], + sourceRepositoryPath: string, +) { + const packagePaths = await getChangedPluginPackagePaths( + exec, + sourceRepositoryPath, + ); + const changed = hasChangedCommercePluginVersions(packagePaths); + + core.setOutput("changed", changed ? "true" : "false"); + core.info( + changed + ? `Changed Commerce plugin package versions found: ${packagePaths.join(", ")}` + : "No changed Commerce plugin package versions found.", + ); + + return changed; +} + +export async function preparePromotionArtifacts( + packagePaths: string[], + sourceRepositoryPath: string, + skillsRepositoryPath: string, +) { + const promotions: PluginPromotion[] = []; + + for (const packagePath of packagePaths) { + const pluginRelativePath = dirname(packagePath); + const sourcePath = join(sourceRepositoryPath, pluginRelativePath); + const targetPath = join(skillsRepositoryPath, pluginRelativePath); + const packageJson = await readJson( + join(sourcePath, "package.json"), + ); + const tileJson = await readJson(join(sourcePath, "tile.json")); + const displayName = tileJson.name.split("/").at(-1) ?? basename(sourcePath); + + await rm(targetPath, { recursive: true, force: true }); + await mkdir(targetPath, { recursive: true }); + + await cp(join(sourcePath, "tile.json"), join(targetPath, "tile.json")); + await cp(join(sourcePath, "README.md"), join(targetPath, "README.md")); + await cp(join(sourcePath, "skills"), join(targetPath, "skills"), { + recursive: true, + }); + await cp( + join(sourcePath, ".claude-plugin"), + join(targetPath, ".claude-plugin"), + { recursive: true }, + ); + await rewritePluginRepository(targetPath); + + promotions.push({ + packagePath, + sourcePath, + targetPath, + packageName: packageJson.name, + displayName, + version: packageJson.version, + changelogEntries: await readChangelogEntries( + join(sourcePath, "CHANGELOG.md"), + packageJson.version, + ), + }); + } + + return promotions; +} + +export function buildPromotionPullRequestBody( + sections: PullRequestBodySection[], +) { + return sections + .map((section) => { + const entries = + section.changelogEntries.length > 0 + ? section.changelogEntries + : [`- Promote ${section.displayName} v${section.version}.`]; + + return [ + `## ${section.displayName} v${section.version}`, + "", + ...entries, + ].join("\n"); + }) + .join("\n\n"); +} + +async function packageVersionChanged( + exec: AsyncFunctionArguments["exec"], + sourceRepositoryPath: string, + packagePath: string, +) { + const currentPackageJson = await readJson( + join(sourceRepositoryPath, packagePath), + ); + const previousPackageJson = await readPreviousPackageJson( + exec, + sourceRepositoryPath, + packagePath, + ); + + return previousPackageJson?.version !== currentPackageJson.version; +} + +async function readPreviousPackageJson( + exec: AsyncFunctionArguments["exec"], + sourceRepositoryPath: string, + packagePath: string, +) { + const result = await exec.getExecOutput( + "git", + ["-C", sourceRepositoryPath, "show", `HEAD^1:${packagePath}`], + { ignoreReturnCode: true, silent: true }, + ); + + if (result.exitCode !== 0) { + return; + } + + return JSON.parse(result.stdout) as PluginPackageJson; +} + +async function rewritePluginRepository(targetPath: string) { + const pluginJsonPath = join(targetPath, ".claude-plugin/plugin.json"); + const pluginJson = await readJson>(pluginJsonPath); + + if (pluginJson.repository !== PROMOTED_REPOSITORY_FIELD) { + throw new Error( + `Expected ${pluginJsonPath} repository to be ${PROMOTED_REPOSITORY_FIELD}.`, + ); + } + + pluginJson.repository = TARGET_REPOSITORY_URL; + await writeJson(pluginJsonPath, pluginJson); +} + +async function readChangelogEntries(changelogPath: string, version: string) { + try { + const changelog = await readFile(changelogPath, "utf-8"); + const lines = changelog.split("\n"); + const sectionStart = lines.indexOf(`## ${version}`); + + if (sectionStart === -1) { + return []; + } + + const remainingLines = lines.slice(sectionStart + 1); + const nextSection = remainingLines.findIndex((line) => + line.startsWith("## "), + ); + const sectionLines = + nextSection === -1 + ? remainingLines + : remainingLines.slice(0, nextSection); + + return sectionLines + .map((line) => line.trim()) + .filter((line) => line.startsWith("- ")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return []; + } + + throw error; + } +} + +async function checkoutPromotionBranch( + exec: AsyncFunctionArguments["exec"], + skillsRepositoryPath: string, +) { + await exec.exec("git", [ + "-C", + skillsRepositoryPath, + "config", + "user.email", + "github-actions[bot]@users.noreply.github.com", + ]); + await exec.exec("git", [ + "-C", + skillsRepositoryPath, + "config", + "user.name", + "github-actions[bot]", + ]); + await exec.exec("git", [ + "-C", + skillsRepositoryPath, + "fetch", + "origin", + "main", + ]); + await exec.exec("git", [ + "-C", + skillsRepositoryPath, + "checkout", + "-B", + PROMOTION_BRANCH, + "origin/main", + ]); +} + +async function commitAndPushPromotion( + exec: AsyncFunctionArguments["exec"], + skillsRepositoryPath: string, + sourceSha: string, +): Promise { + await exec.exec("git", ["-C", skillsRepositoryPath, "add", "."]); + + const status = await exec.getExecOutput( + "git", + ["-C", skillsRepositoryPath, "status", "--porcelain"], + { silent: true }, + ); + + if (status.stdout.trim().length === 0) { + return false; + } + + await exec.exec("git", [ + "-C", + skillsRepositoryPath, + "commit", + "-m", + `feat(commerce): promote plugins from ${SOURCE_REPOSITORY}@${shortSha( + sourceSha, + )}`, + ]); + await exec.exec("git", [ + "-C", + skillsRepositoryPath, + "push", + "--force", + "origin", + `HEAD:${PROMOTION_BRANCH}`, + ]); + + return true; +} + +async function upsertPromotionPullRequest( + github: AsyncFunctionArguments["github"], + sourceSha: string, + promotions: PluginPromotion[], +) { + const title = `feat(commerce): promote plugins from ${SOURCE_REPOSITORY}@${shortSha( + sourceSha, + )}`; + const body = buildPromotionPullRequestBody(promotions); + const openPullRequests = await github.rest.pulls.list({ + owner: TARGET_OWNER, + repo: TARGET_REPO, + head: `${TARGET_OWNER}:${PROMOTION_BRANCH}`, + state: "open", + }); + const [pullRequest] = openPullRequests.data; + + if (pullRequest) { + await github.rest.pulls.update({ + owner: TARGET_OWNER, + repo: TARGET_REPO, + pull_number: pullRequest.number, + title, + body, + }); + return; + } + + await github.rest.pulls.create({ + owner: TARGET_OWNER, + repo: TARGET_REPO, + base: "main", + head: PROMOTION_BRANCH, + title, + body, + }); +} + +async function readJson(path: string) { + return JSON.parse(await readFile(path, "utf-8")) as T; +} + +async function writeJson(path: string, value: unknown) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function requireEnv(name: string) { + const value = process.env[name]; + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function shortSha(sha: string) { + return sha.slice(0, 7); +} diff --git a/scripts/test/unit/ci/release/promote-skills.test.ts b/scripts/test/unit/ci/release/promote-skills.test.ts new file mode 100644 index 000000000..eeb87d76a --- /dev/null +++ b/scripts/test/unit/ci/release/promote-skills.test.ts @@ -0,0 +1,523 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { withTempFiles } from "@aio-commerce-sdk/scripting-utils/filesystem"; +import { describe, expect, test, vi } from "vitest"; + +import { + buildPromotionPullRequestBody, + detectChangedCommercePluginVersions, + getChangedPluginPackagePaths, + hasChangedCommercePluginVersions, + preparePromotionArtifacts, + promoteSkills, +} from "#ci/release/promote-skills"; +import { + asCore, + asExec, + createCoreMock, + createExecMock, +} from "#test/fixtures/release"; + +async function fileExists(path: string) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +describe("release/promote-skills.ts", () => { + test("reports whether Commerce plugin package versions changed", async () => { + await withTempFiles({}, async (tempDir) => { + const core = createCoreMock(); + const exec = createExecMock(); + exec.getExecOutput.mockResolvedValueOnce({ + stdout: "", + stderr: "", + exitCode: 0, + }); + + await expect( + detectChangedCommercePluginVersions( + asCore(core), + asExec(exec), + tempDir, + ), + ).resolves.toBe(false); + + expect(core.setOutput).toHaveBeenCalledWith("changed", "false"); + }); + }); + + test("returns true when changed Commerce plugin package versions exist", () => { + expect( + hasChangedCommercePluginVersions([ + "plugins/commerce/app-management/package.json", + ]), + ).toBe(true); + }); + + test("skips promotion when no plugin package versions changed", async () => { + await withTempFiles({}, async (tempDir) => { + const core = createCoreMock(); + const exec = createExecMock(); + const github = { + rest: { + pulls: { + create: vi.fn(), + list: vi.fn(), + update: vi.fn(), + }, + }, + }; + const context = { sha: "1234567890abcdef" }; + exec.getExecOutput.mockResolvedValueOnce({ + stdout: "", + stderr: "", + exitCode: 0, + }); + + await promoteSkills( + asCore(core), + asExec(exec), + github as never, + context as never, + { + sourceRepositoryPath: tempDir, + skillsRepositoryPath: join(tempDir, "skills"), + }, + ); + + expect(core.setOutput).toHaveBeenCalledWith("promotedSkills", "false"); + expect(github.rest.pulls.create).not.toHaveBeenCalled(); + }); + }); + + test("returns no changed plugin packages when no package versions changed", async () => { + await withTempFiles({}, async (tempDir) => { + const exec = createExecMock(); + exec.getExecOutput.mockResolvedValueOnce({ + stdout: "", + stderr: "", + exitCode: 0, + }); + + await expect( + getChangedPluginPackagePaths(asExec(exec), tempDir), + ).resolves.toEqual([]); + }); + }); + + test("detects plugin package version changes", async () => { + await withTempFiles( + { + "plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + }), + }, + async (tempDir) => { + const exec = createExecMock(); + exec.getExecOutput + .mockResolvedValueOnce({ + stdout: "plugins/commerce/app-management/package.json\n", + stderr: "", + exitCode: 0, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "0.0.0", + }), + stderr: "", + exitCode: 0, + }); + + await expect( + getChangedPluginPackagePaths(asExec(exec), tempDir), + ).resolves.toEqual(["plugins/commerce/app-management/package.json"]); + }, + ); + }); + + test("ignores plugin package changes when the version is unchanged", async () => { + await withTempFiles( + { + "plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + }), + }, + async (tempDir) => { + const exec = createExecMock(); + exec.getExecOutput + .mockResolvedValueOnce({ + stdout: "plugins/commerce/app-management/package.json\n", + stderr: "", + exitCode: 0, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + }), + stderr: "", + exitCode: 0, + }); + + await expect( + getChangedPluginPackagePaths(asExec(exec), tempDir), + ).resolves.toEqual([]); + }, + ); + }); + + test("detects newly added plugin package versions", async () => { + await withTempFiles( + { + "plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + }), + }, + async (tempDir) => { + const exec = createExecMock(); + exec.getExecOutput + .mockResolvedValueOnce({ + stdout: "plugins/commerce/app-management/package.json\n", + stderr: "", + exitCode: 0, + }) + .mockResolvedValueOnce({ + stdout: "", + stderr: "fatal: path does not exist", + exitCode: 128, + }); + + await expect( + getChangedPluginPackagePaths(asExec(exec), tempDir), + ).resolves.toEqual(["plugins/commerce/app-management/package.json"]); + }, + ); + }); + + test("copies promotion artifacts and excludes SDK-only files", async () => { + await withTempFiles( + { + "source/plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + }), + "source/plugins/commerce/app-management/CHANGELOG.md": [ + "# @adobe/aio-commerce-plugin-app-management", + "", + "## 1.1.0", + "", + "- Keep future entries out.", + "", + "## 1.0.0", + "", + "- Promote App Management skills.", + "", + "## 0.1.0", + "", + "- Keep previous entries out.", + "", + ].join("\n"), + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.0.0", + }), + "source/plugins/commerce/app-management/README.md": + "# App Management\n", + "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": + "# Skill\n", + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + version: "1.0.0", + repository: "https://github.com/adobe/aio-commerce-sdk", + }), + "skills/plugins/commerce/app-management/package.json": "{}", + }, + async (tempDir) => { + const sourceRoot = join(tempDir, "source"); + const skillsRoot = join(tempDir, "skills"); + + const promotions = await preparePromotionArtifacts( + ["plugins/commerce/app-management/package.json"], + sourceRoot, + skillsRoot, + ); + + const targetRoot = join(skillsRoot, "plugins/commerce/app-management"); + const pluginJson = JSON.parse( + await readFile( + join(targetRoot, ".claude-plugin/plugin.json"), + "utf-8", + ), + ); + + expect(promotions).toEqual([ + expect.objectContaining({ + displayName: "commerce-app-management", + packageName: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + changelogEntries: ["- Promote App Management skills."], + }), + ]); + expect(pluginJson.repository).toBe("https://github.com/adobe/skills"); + await expect( + readFile( + join(targetRoot, "skills/commerce-app-init/SKILL.md"), + "utf-8", + ), + ).resolves.toBe("# Skill\n"); + await expect( + fileExists(join(targetRoot, "package.json")), + ).resolves.toBe(false); + await expect( + fileExists(join(targetRoot, "CHANGELOG.md")), + ).resolves.toBe(false); + }, + ); + }); + + test("rejects plugin manifests that do not point to the SDK repository", async () => { + await withTempFiles( + { + "source/plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + }), + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.0.0", + }), + "source/plugins/commerce/app-management/README.md": + "# App Management\n", + "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": + "# Skill\n", + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + version: "1.0.0", + repository: "https://github.com/adobe/skills", + }), + }, + async (tempDir) => { + await expect( + preparePromotionArtifacts( + ["plugins/commerce/app-management/package.json"], + join(tempDir, "source"), + join(tempDir, "skills"), + ), + ).rejects.toThrow("repository"); + }, + ); + }); + + test("promotes changed plugins through the full git path", async () => { + await withTempFiles( + { + "source/plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.2.0", + }), + "source/plugins/commerce/app-management/CHANGELOG.md": [ + "# @adobe/aio-commerce-plugin-app-management", + "", + "## 1.2.0", + "", + "- Add new skill.", + "", + ].join("\n"), + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.2.0", + }), + "source/plugins/commerce/app-management/README.md": + "# App Management\n", + "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": + "# Skill\n", + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + version: "1.2.0", + repository: "https://github.com/adobe/aio-commerce-sdk", + }), + }, + async (tempDir) => { + const core = createCoreMock(); + const exec = createExecMock(); + const github = { + rest: { + pulls: { + create: vi.fn().mockResolvedValue({ data: { number: 42 } }), + list: vi.fn().mockResolvedValue({ data: [] }), + update: vi.fn(), + }, + }, + }; + const context = { sha: "abcdef1234567890" }; + + exec.getExecOutput + // git diff --name-only (detect changed plugins) + .mockResolvedValueOnce({ + stdout: "plugins/commerce/app-management/package.json\n", + stderr: "", + exitCode: 0, + }) + // git show HEAD^1:package.json (previous version) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.1.1", + }), + stderr: "", + exitCode: 0, + }) + // git status --porcelain (has changes to commit) + .mockResolvedValueOnce({ + stdout: " M plugins/commerce/app-management/tile.json\n", + stderr: "", + exitCode: 0, + }); + + await promoteSkills( + asCore(core), + asExec(exec), + github as never, + context as never, + { + sourceRepositoryPath: join(tempDir, "source"), + skillsRepositoryPath: join(tempDir, "skills"), + }, + ); + + expect(core.setOutput).toHaveBeenCalledWith("promotedSkills", "true"); + expect(github.rest.pulls.create).toHaveBeenCalledOnce(); + expect(github.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "adobe", + repo: "skills", + head: "promote/adobe-aio-commerce-sdk", + base: "main", + }), + ); + }, + ); + }); + + test("is a no-op when adobe/skills already reflects the promoted content", async () => { + await withTempFiles( + { + "source/plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.2.0", + }), + "source/plugins/commerce/app-management/CHANGELOG.md": "", + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.2.0", + }), + "source/plugins/commerce/app-management/README.md": + "# App Management\n", + "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": + "# Skill\n", + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + version: "1.2.0", + repository: "https://github.com/adobe/aio-commerce-sdk", + }), + }, + async (tempDir) => { + const core = createCoreMock(); + const exec = createExecMock(); + const github = { + rest: { + pulls: { + create: vi.fn(), + list: vi.fn(), + update: vi.fn(), + }, + }, + }; + const context = { sha: "abcdef1234567890" }; + + exec.getExecOutput + .mockResolvedValueOnce({ + stdout: "plugins/commerce/app-management/package.json\n", + stderr: "", + exitCode: 0, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.1.1", + }), + stderr: "", + exitCode: 0, + }) + // git status --porcelain returns empty: skills already up to date + .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0 }); + + await promoteSkills( + asCore(core), + asExec(exec), + github as never, + context as never, + { + sourceRepositoryPath: join(tempDir, "source"), + skillsRepositoryPath: join(tempDir, "skills"), + }, + ); + + expect(core.setOutput).toHaveBeenCalledWith("promotedSkills", "false"); + expect(github.rest.pulls.create).not.toHaveBeenCalled(); + }, + ); + }); + + test("builds the promotion pull request body from changelog entries", () => { + expect( + buildPromotionPullRequestBody([ + { + displayName: "commerce-app-management", + version: "1.0.0", + changelogEntries: ["- Add App Management skills."], + }, + { + displayName: "commerce-app-migration", + version: "1.0.0", + changelogEntries: [], + }, + ]), + ).toBe( + [ + "## commerce-app-management v1.0.0", + "", + "- Add App Management skills.", + "", + "## commerce-app-migration v1.0.0", + "", + "- Promote commerce-app-migration v1.0.0.", + ].join("\n"), + ); + }); +}); diff --git a/scripts/test/unit/plugins/version-consistency.test.ts b/scripts/test/unit/plugins/version-consistency.test.ts new file mode 100644 index 000000000..be7697df4 --- /dev/null +++ b/scripts/test/unit/plugins/version-consistency.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, test } from "vitest"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../.."); + +const plugins = [ + "plugins/commerce/app-management", + "plugins/commerce/app-migration", +]; + +describe("plugin version consistency", () => { + for (const plugin of plugins) { + test(`${plugin}: package.json version matches tile.json and .claude-plugin/plugin.json`, async () => { + const [packageJson, tileJson, pluginJson] = await Promise.all([ + readFile(join(repoRoot, plugin, "package.json"), "utf-8").then( + JSON.parse, + ), + readFile(join(repoRoot, plugin, "tile.json"), "utf-8").then(JSON.parse), + readFile( + join(repoRoot, plugin, ".claude-plugin/plugin.json"), + "utf-8", + ).then(JSON.parse), + ]); + + expect(tileJson.version).toBe(packageJson.version); + expect(pluginJson.version).toBe(packageJson.version); + }); + } +}); diff --git a/specs/features/CEXT-6367-skills-release-process.md b/specs/features/CEXT-6367-skills-release-process.md index c6bc94ff3..97df0387a 100644 --- a/specs/features/CEXT-6367-skills-release-process.md +++ b/specs/features/CEXT-6367-skills-release-process.md @@ -2,7 +2,7 @@ - **Ticket:** [CEXT-6367](https://jira.corp.adobe.com/browse/CEXT-6367) - **Created:** 2026-06-23 -- [ ] **Implemented** +- [x] **Implemented** ## Summary From d4a0809ef458c8ed4a0eaa67a927ff4dd4d113c9 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 25 Jun 2026 11:11:56 +0200 Subject: [PATCH 2/8] remove drop() from storage uninstall example --- .../skills/commerce-app-storage/SKILL.md | 14 ++--------- .../assets/setup-database.ts | 23 ++++--------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md index 1940dda87..4009eace9 100644 --- a/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md @@ -209,18 +209,8 @@ export default defineCustomInstallationStep({ } }, uninstall: async (config, context) => { - let client; - try { - const authProvider = getImsAuthProvider( - resolveImsAuthParams(context.params), - ); - const token = await authProvider.getAccessToken(); - const db = await initDb({ token, region: "emea" }); - client = await db.connect(); - await client.collection("held_orders").drop(); - } finally { - if (client) await client.close(); - } + // Tear down your database state here. + // Leave empty to preserve data across reinstalls. }, }); ``` diff --git a/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts b/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts index 62b3f7d61..9d67fc09b 100644 --- a/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts +++ b/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts @@ -3,7 +3,7 @@ // // What it does: // - install: creates the "held_orders" collection and a UNIQUE index on order_id -// - uninstall: drops the collection to reverse the install +// - uninstall: tear down your database state here; leave empty to preserve data across reinstalls // // Why a custom installation step (vs. ad-hoc setup on first request): // It runs exactly once when the app is installed from the Commerce Admin, @@ -90,23 +90,8 @@ export default defineCustomInstallationStep({ } }, - uninstall: async (config, context) => { - const { logger } = context; - logger.info(`Removing storage for ${config.metadata.displayName}...`); - - let client: Awaited> | undefined; - try { - client = await openClient(context); - await client.collection(COLLECTION).drop(); - logger.info(`Dropped "${COLLECTION}"`); - } finally { - if (client) { - await client - .close() - .catch((e: Error) => - logger.warn("Failed to close DB client", e.message), - ); - } - } + uninstall: async (_config, _context) => { + // Tear down your database state here. + // Leave empty to preserve data across reinstalls. }, }); From f131c0115ef0e57894098843ca1ee5170572b45f Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Mon, 6 Jul 2026 10:52:20 +0200 Subject: [PATCH 3/8] Revert "remove drop() from storage uninstall example" This reverts commit d4a0809ef458c8ed4a0eaa67a927ff4dd4d113c9. --- .../skills/commerce-app-storage/SKILL.md | 14 +++++++++-- .../assets/setup-database.ts | 23 +++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md b/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md index 4009eace9..1940dda87 100644 --- a/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md +++ b/plugins/commerce/app-management/skills/commerce-app-storage/SKILL.md @@ -209,8 +209,18 @@ export default defineCustomInstallationStep({ } }, uninstall: async (config, context) => { - // Tear down your database state here. - // Leave empty to preserve data across reinstalls. + let client; + try { + const authProvider = getImsAuthProvider( + resolveImsAuthParams(context.params), + ); + const token = await authProvider.getAccessToken(); + const db = await initDb({ token, region: "emea" }); + client = await db.connect(); + await client.collection("held_orders").drop(); + } finally { + if (client) await client.close(); + } }, }); ``` diff --git a/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts b/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts index 9d67fc09b..62b3f7d61 100644 --- a/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts +++ b/plugins/commerce/app-management/skills/commerce-app-storage/assets/setup-database.ts @@ -3,7 +3,7 @@ // // What it does: // - install: creates the "held_orders" collection and a UNIQUE index on order_id -// - uninstall: tear down your database state here; leave empty to preserve data across reinstalls +// - uninstall: drops the collection to reverse the install // // Why a custom installation step (vs. ad-hoc setup on first request): // It runs exactly once when the app is installed from the Commerce Admin, @@ -90,8 +90,23 @@ export default defineCustomInstallationStep({ } }, - uninstall: async (_config, _context) => { - // Tear down your database state here. - // Leave empty to preserve data across reinstalls. + uninstall: async (config, context) => { + const { logger } = context; + logger.info(`Removing storage for ${config.metadata.displayName}...`); + + let client: Awaited> | undefined; + try { + client = await openClient(context); + await client.collection(COLLECTION).drop(); + logger.info(`Dropped "${COLLECTION}"`); + } finally { + if (client) { + await client + .close() + .catch((e: Error) => + logger.warn("Failed to close DB client", e.message), + ); + } + } }, }); From 6a123805d84d380b29d12718e8ba2de8ef14eb59 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Mon, 6 Jul 2026 11:57:27 +0200 Subject: [PATCH 4/8] address PR #540 review comments on promote-skills and plugin versioning - derive TARGET_REPOSITORY_URL from TARGET_OWNER/TARGET_REPO - collapse promotion cp calls into a loop over PROMOTED_ENTRIES - explain why the changed-plugins workflow step gates on hasChangesets - move sync-plugin-version.mjs into scripts/ as an sdk-sync-plugin-version bin, wired the same way as sdk-prepack/sdk-postpack, and dedupe its readJson/writeJson helpers with promote-skills.ts via ci/release/utils.ts --- .github/workflows/publish-public.yml | 5 ++ plugins/commerce/app-management/package.json | 5 +- plugins/commerce/app-migration/package.json | 5 +- .../commerce/scripts/sync-plugin-version.mjs | 43 --------- pnpm-lock.yaml | 14 ++- scripts/package.json | 3 +- scripts/source/ci/release/promote-skills.ts | 30 ++----- scripts/source/ci/release/utils.ts | 12 +++ scripts/source/sync-plugin-version.ts | 49 +++++++++++ scripts/test/unit/ci/release/utils.test.ts | 39 +++++++- scripts/test/unit/sync-plugin-version.test.ts | 88 +++++++++++++++++++ 11 files changed, 223 insertions(+), 70 deletions(-) delete mode 100644 plugins/commerce/scripts/sync-plugin-version.mjs create mode 100755 scripts/source/sync-plugin-version.ts create mode 100644 scripts/test/unit/sync-plugin-version.test.ts diff --git a/.github/workflows/publish-public.yml b/.github/workflows/publish-public.yml index 7865085b6..32e2e30fd 100644 --- a/.github/workflows/publish-public.yml +++ b/.github/workflows/publish-public.yml @@ -56,6 +56,11 @@ jobs: release-channel: public slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + # hasChangesets is false only on the run that lands right after the "Version + # Packages" PR merges, which is the one commit where diffing against HEAD^1 + # reflects the actual version bump. `published` can't gate this instead: + # skill-only releases never publish to npm, so `published` stays false even + # when a plugin version legitimately changed. - name: Detect changed Commerce plugin versions id: changed-plugins if: steps.changesets.outputs.hasChangesets == 'false' diff --git a/plugins/commerce/app-management/package.json b/plugins/commerce/app-management/package.json index 4a8584f67..29aeccd43 100644 --- a/plugins/commerce/app-management/package.json +++ b/plugins/commerce/app-management/package.json @@ -3,6 +3,9 @@ "version": "1.1.2", "private": true, "scripts": { - "version": "node ../scripts/sync-plugin-version.mjs" + "version": "sdk-sync-plugin-version" + }, + "devDependencies": { + "@aio-commerce-sdk/scripts": "workspace:*" } } diff --git a/plugins/commerce/app-migration/package.json b/plugins/commerce/app-migration/package.json index 59a66dd4e..65c87b781 100644 --- a/plugins/commerce/app-migration/package.json +++ b/plugins/commerce/app-migration/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "private": true, "scripts": { - "version": "node ../scripts/sync-plugin-version.mjs" + "version": "sdk-sync-plugin-version" + }, + "devDependencies": { + "@aio-commerce-sdk/scripts": "workspace:*" } } diff --git a/plugins/commerce/scripts/sync-plugin-version.mjs b/plugins/commerce/scripts/sync-plugin-version.mjs deleted file mode 100644 index 5efa7d8c2..000000000 --- a/plugins/commerce/scripts/sync-plugin-version.mjs +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -const PLUGIN_ROOT = process.cwd(); - -async function readJson(path) { - return JSON.parse(await readFile(path, "utf-8")); -} - -async function writeJson(path, data) { - await writeFile(path, `${JSON.stringify(data, null, 2)}\n`); -} - -async function main() { - const packageJson = await readJson(join(PLUGIN_ROOT, "package.json")); - const tileJsonPath = join(PLUGIN_ROOT, "tile.json"); - const pluginJsonPath = join(PLUGIN_ROOT, ".claude-plugin/plugin.json"); - - const tileJson = await readJson(tileJsonPath); - tileJson.version = packageJson.version; - await writeJson(tileJsonPath, tileJson); - - const pluginJson = await readJson(pluginJsonPath); - pluginJson.version = packageJson.version; - await writeJson(pluginJsonPath, pluginJson); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d9a4817d..22ad2ef98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -739,9 +739,17 @@ importers: specifier: 'catalog:' version: 6.0.3 - plugins/commerce/app-management: {} + plugins/commerce/app-management: + devDependencies: + '@aio-commerce-sdk/scripts': + specifier: workspace:* + version: link:../../../scripts - plugins/commerce/app-migration: {} + plugins/commerce/app-migration: + devDependencies: + '@aio-commerce-sdk/scripts': + specifier: workspace:* + version: link:../../../scripts scripts: devDependencies: @@ -814,6 +822,7 @@ packages: '@adobe/aio-lib-core-networking@5.1.0': resolution: {integrity: sha512-RZgA73yUHhikjIGy7k0DRobPbIXbtuI/0ixrBXOVqj19WTqFFrEuyHtzOuRsBAmu0OvVkf6slQHtDTL9oPLxfg==} engines: {node: '>=18'} + bundledDependencies: [] '@adobe/aio-lib-core-tvm@4.0.3': resolution: {integrity: sha512-zwl4GeU5CryZBozpub4jI9tnGW2ewD6mMLaXX8HhO8DqKzcjnjYr+MbUS0c/QzGd8gTzZWbikm4J7tfpCn3cAA==} @@ -822,6 +831,7 @@ packages: '@adobe/aio-lib-env@3.0.1': resolution: {integrity: sha512-UaLosV8jBowEA2ho4BNmWuHhrNCFbx26kJAr2SAIdEm4lZ/D8av8FUSMOEyAKJ/dfO2HCnLKMy77ie2AU7HI3g==} engines: {node: '>=18'} + bundledDependencies: [] '@adobe/aio-lib-files@4.1.4': resolution: {integrity: sha512-YhUu5YV4hnFm6arsMYhl96bjbxnOpmMgE5WiwUvLlm9Dh4PGNGAaY52nED5xnbI+ODbj0vSd9s6KwC3kTmo6tQ==} diff --git a/scripts/package.json b/scripts/package.json index ff5cd46d0..97c0b3807 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -15,7 +15,8 @@ }, "bin": { "sdk-prepack": "./source/prepack.ts", - "sdk-postpack": "./source/postpack.ts" + "sdk-postpack": "./source/postpack.ts", + "sdk-sync-plugin-version": "./source/sync-plugin-version.ts" }, "scripts": { "assist": "biome check --formatter-enabled=false --linter-enabled=false --assist-enabled=true --no-errors-on-unmatched", diff --git a/scripts/source/ci/release/promote-skills.ts b/scripts/source/ci/release/promote-skills.ts index 5ad66a679..b4ef26f8f 100644 --- a/scripts/source/ci/release/promote-skills.ts +++ b/scripts/source/ci/release/promote-skills.ts @@ -10,19 +10,20 @@ * governing permissions and limitations under the License. */ -import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, readFile, rm } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { runGitHubScript } from "./utils.ts"; +import { readJson, runGitHubScript, writeJson } from "./utils.ts"; import type { AsyncFunctionArguments } from "./types.ts"; const SOURCE_REPOSITORY = "adobe/aio-commerce-sdk"; const TARGET_OWNER = "adobe"; const TARGET_REPO = "skills"; -const TARGET_REPOSITORY_URL = "https://github.com/adobe/skills"; +const TARGET_REPOSITORY_URL = `https://github.com/${TARGET_OWNER}/${TARGET_REPO}`; const PROMOTION_BRANCH = "promote/adobe-aio-commerce-sdk"; const PROMOTED_REPOSITORY_FIELD = "https://github.com/adobe/aio-commerce-sdk"; +const PROMOTED_ENTRIES = ["tile.json", "README.md", "skills", ".claude-plugin"]; type PluginPackageJson = { name: string; @@ -199,16 +200,11 @@ export async function preparePromotionArtifacts( await rm(targetPath, { recursive: true, force: true }); await mkdir(targetPath, { recursive: true }); - await cp(join(sourcePath, "tile.json"), join(targetPath, "tile.json")); - await cp(join(sourcePath, "README.md"), join(targetPath, "README.md")); - await cp(join(sourcePath, "skills"), join(targetPath, "skills"), { - recursive: true, - }); - await cp( - join(sourcePath, ".claude-plugin"), - join(targetPath, ".claude-plugin"), - { recursive: true }, - ); + for (const entry of PROMOTED_ENTRIES) { + await cp(join(sourcePath, entry), join(targetPath, entry), { + recursive: true, + }); + } await rewritePluginRepository(targetPath); promotions.push({ @@ -438,14 +434,6 @@ async function upsertPromotionPullRequest( }); } -async function readJson(path: string) { - return JSON.parse(await readFile(path, "utf-8")) as T; -} - -async function writeJson(path: string, value: unknown) { - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); -} - function requireEnv(name: string) { const value = process.env[name]; diff --git a/scripts/source/ci/release/utils.ts b/scripts/source/ci/release/utils.ts index 360590b45..c01a747ed 100644 --- a/scripts/source/ci/release/utils.ts +++ b/scripts/source/ci/release/utils.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import { readFile, writeFile } from "node:fs/promises"; + import type { AsyncFunctionArguments, ReleaseChannel } from "./types.ts"; /** Runs the given action in a safe way and returns the result. */ @@ -25,6 +27,16 @@ export function runGitHubScript( } } +/** Reads and parses a JSON file. */ +export async function readJson(path: string) { + return JSON.parse(await readFile(path, "utf-8")) as T; +} + +/** Serializes and writes a value as a JSON file, ending with a trailing newline. */ +export async function writeJson(path: string, value: unknown) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + /** Parses the release channel from the given value. */ export function parseReleaseChannel(value: string | undefined): ReleaseChannel { if (value === "internal" || value === "public") { diff --git a/scripts/source/sync-plugin-version.ts b/scripts/source/sync-plugin-version.ts new file mode 100755 index 000000000..ba303380e --- /dev/null +++ b/scripts/source/sync-plugin-version.ts @@ -0,0 +1,49 @@ +#! /usr/bin/env node + +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { readJson, writeJson } from "./ci/release/utils.ts"; + +type PackageJson = { version: string }; +type VersionedManifest = { version: string; [key: string]: unknown }; + +/** Syncs a Commerce plugin's tile.json and plugin.json versions to its package.json version. */ +export default async function main() { + const packageJsonPath = join(process.cwd(), "package.json"); + if (!existsSync(packageJsonPath)) { + return; + } + + const packageJson = await readJson(packageJsonPath); + + for (const manifestPath of [ + join(process.cwd(), "tile.json"), + join(process.cwd(), ".claude-plugin/plugin.json"), + ]) { + if (!existsSync(manifestPath)) { + continue; + } + + const manifest = await readJson(manifestPath); + manifest.version = packageJson.version; + await writeJson(manifestPath, manifest); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/test/unit/ci/release/utils.test.ts b/scripts/test/unit/ci/release/utils.test.ts index 6bd18716f..1739a9e89 100644 --- a/scripts/test/unit/ci/release/utils.test.ts +++ b/scripts/test/unit/ci/release/utils.test.ts @@ -10,9 +10,18 @@ * governing permissions and limitations under the License. */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { withTempFiles } from "@aio-commerce-sdk/scripting-utils/filesystem"; import { describe, expect, test } from "vitest"; -import { parseReleaseChannel, runGitHubScript } from "#ci/release/utils"; +import { + parseReleaseChannel, + readJson, + runGitHubScript, + writeJson, +} from "#ci/release/utils"; import { asCore, createCoreMock } from "#test/fixtures/release"; describe("release/utils.ts", () => { @@ -76,4 +85,32 @@ describe("release/utils.ts", () => { expect(() => parseReleaseChannel(undefined)).toThrow(); }); }); + + describe("readJson", () => { + test("reads and parses a JSON file", async () => { + await withTempFiles( + { "data.json": JSON.stringify({ name: "test", version: "1.0.0" }) }, + async (tempDir) => { + await expect( + readJson<{ name: string; version: string }>( + join(tempDir, "data.json"), + ), + ).resolves.toEqual({ name: "test", version: "1.0.0" }); + }, + ); + }); + }); + + describe("writeJson", () => { + test("writes a value as formatted JSON with a trailing newline", async () => { + await withTempFiles({}, async (tempDir) => { + const path = join(tempDir, "data.json"); + await writeJson(path, { name: "test", version: "1.0.0" }); + + await expect(readFile(path, "utf-8")).resolves.toBe( + '{\n "name": "test",\n "version": "1.0.0"\n}\n', + ); + }); + }); + }); }); diff --git a/scripts/test/unit/sync-plugin-version.test.ts b/scripts/test/unit/sync-plugin-version.test.ts new file mode 100644 index 000000000..73d2b67d1 --- /dev/null +++ b/scripts/test/unit/sync-plugin-version.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { join } from "node:path"; + +import { + withChdir, + withTempFiles, +} from "@aio-commerce-sdk/scripting-utils/filesystem"; +import { describe, expect, test } from "vitest"; + +import { readJson } from "#ci/release/utils"; +import syncPluginVersion from "#sync-plugin-version"; + +type PluginManifest = { version: string; repository?: string }; + +describe("sync-plugin-version.ts", () => { + test("syncs tile.json and plugin.json versions to package.json", async () => { + await withTempFiles( + { + "package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.2.0", + }), + "tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.1.0", + }), + ".claude-plugin/plugin.json": JSON.stringify({ + name: "commerce-app-management", + version: "1.1.0", + repository: "https://github.com/adobe/aio-commerce-sdk", + }), + }, + async (tempDir) => { + await withChdir(tempDir, async () => { + await syncPluginVersion(); + + const [tileJson, pluginJson] = await Promise.all([ + readJson(join(tempDir, "tile.json")), + readJson( + join(tempDir, ".claude-plugin/plugin.json"), + ), + ]); + + expect(tileJson.version).toBe("1.2.0"); + expect(pluginJson.version).toBe("1.2.0"); + expect(pluginJson.repository).toBe( + "https://github.com/adobe/aio-commerce-sdk", + ); + }); + }, + ); + }); + + test("does nothing when package.json is missing", async () => { + await withTempFiles({}, async (tempDir) => { + await withChdir(tempDir, async () => { + await expect(syncPluginVersion()).resolves.toBeUndefined(); + }); + }); + }); + + test("skips manifests that don't exist", async () => { + await withTempFiles( + { + "package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", + version: "1.2.0", + }), + }, + async (tempDir) => { + await withChdir(tempDir, async () => { + await expect(syncPluginVersion()).resolves.toBeUndefined(); + }); + }, + ); + }); +}); From 49b552b61f676cb32009ff2e651fbb454f18a401 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Mon, 6 Jul 2026 18:02:53 +0200 Subject: [PATCH 5/8] fix biome-incompatible JSON formatting from sync-plugin-version writeJson wrote plain JSON.stringify output, which always expands arrays onto multiple lines. Biome collapses short arrays onto one line, so any plugin version bump touching a manifest with an array field (e.g. tile.json/plugin.json keywords) failed the pre-commit hook. writeJson now runs biome check --write on the file after writing it. Also fixes plugins/commerce/app-management's package.json version, which was left at 1.1.2 after merging main: main directly bumped tile.json and .claude-plugin/plugin.json to 1.1.3 for #553 before this branch introduced package.json as the source of truth. --- plugins/commerce/app-management/package.json | 2 +- scripts/source/ci/release/utils.ts | 17 ++++++++++++++++- scripts/test/unit/ci/release/utils.test.ts | 11 +++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/plugins/commerce/app-management/package.json b/plugins/commerce/app-management/package.json index 29aeccd43..71bb114c8 100644 --- a/plugins/commerce/app-management/package.json +++ b/plugins/commerce/app-management/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aio-commerce-plugin-app-management", - "version": "1.1.2", + "version": "1.1.3", "private": true, "scripts": { "version": "sdk-sync-plugin-version" diff --git a/scripts/source/ci/release/utils.ts b/scripts/source/ci/release/utils.ts index c01a747ed..037b07c02 100644 --- a/scripts/source/ci/release/utils.ts +++ b/scripts/source/ci/release/utils.ts @@ -10,10 +10,14 @@ * governing permissions and limitations under the License. */ +import { execFile } from "node:child_process"; import { readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; import type { AsyncFunctionArguments, ReleaseChannel } from "./types.ts"; +const execFileAsync = promisify(execFile); + /** Runs the given action in a safe way and returns the result. */ export function runGitHubScript( core: AsyncFunctionArguments["core"], @@ -32,9 +36,20 @@ export async function readJson(path: string) { return JSON.parse(await readFile(path, "utf-8")) as T; } -/** Serializes and writes a value as a JSON file, ending with a trailing newline. */ +/** + * Serializes and writes a value as a JSON file, then formats it with Biome so the + * result always matches this repo's formatting rules (e.g. array wrapping), which + * plain `JSON.stringify` output does not. + */ export async function writeJson(path: string, value: unknown) { await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); + await execFileAsync("biome", [ + "check", + "--write", + "--no-errors-on-unmatched", + "--files-ignore-unknown=true", + path, + ]); } /** Parses the release channel from the given value. */ diff --git a/scripts/test/unit/ci/release/utils.test.ts b/scripts/test/unit/ci/release/utils.test.ts index 1739a9e89..5da3f8613 100644 --- a/scripts/test/unit/ci/release/utils.test.ts +++ b/scripts/test/unit/ci/release/utils.test.ts @@ -112,5 +112,16 @@ describe("release/utils.ts", () => { ); }); }); + + test("formats the output with Biome, collapsing short arrays onto one line", async () => { + await withTempFiles({}, async (tempDir) => { + const path = join(tempDir, "data.json"); + await writeJson(path, { keywords: ["commerce", "adobe"] }); + + await expect(readFile(path, "utf-8")).resolves.toBe( + '{\n "keywords": ["commerce", "adobe"]\n}\n', + ); + }); + }); }); }); From 44add3a95feec9f2e1b68252344164ac30009e5d Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 9 Jul 2026 11:20:08 +0200 Subject: [PATCH 6/8] note @changesets/release-utils swap candidate in readChangelogEntries --- scripts/source/ci/release/promote-skills.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/source/ci/release/promote-skills.ts b/scripts/source/ci/release/promote-skills.ts index b4ef26f8f..be0dfd11a 100644 --- a/scripts/source/ci/release/promote-skills.ts +++ b/scripts/source/ci/release/promote-skills.ts @@ -292,6 +292,9 @@ async function rewritePluginRepository(targetPath: string) { await writeJson(pluginJsonPath, pluginJson); } +// @changesets/release-utils ships an equivalent getChangelogEntry, but it's +// only published as a 1.0.0-next prerelease (part of the unreleased v3 line) +// so we hand-roll this until it reaches a stable release. async function readChangelogEntries(changelogPath: string, version: string) { try { const changelog = await readFile(changelogPath, "utf-8"); From 454de153eee07e82bf003cce76d051f1fb6d3154 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 9 Jul 2026 11:30:33 +0200 Subject: [PATCH 7/8] fix scripts lint violations from ultracite 7.9.2 rule additions promote-skills.ts and sync-plugin-version.ts only exist on this branch, so the aa985d52 lint fixup missed them. Parallelize the independent per-item loops with Promise.all to satisfy noAwaitInLoops, and let biome sort the object keys it flagged. --- scripts/source/ci/release/promote-skills.ts | 103 ++++++----- scripts/source/sync-plugin-version.ts | 20 +-- .../unit/ci/release/promote-skills.test.ts | 166 +++++++++--------- scripts/test/unit/sync-plugin-version.test.ts | 10 +- 4 files changed, 149 insertions(+), 150 deletions(-) diff --git a/scripts/source/ci/release/promote-skills.ts b/scripts/source/ci/release/promote-skills.ts index be0dfd11a..c2b4e0006 100644 --- a/scripts/source/ci/release/promote-skills.ts +++ b/scripts/source/ci/release/promote-skills.ts @@ -64,8 +64,8 @@ export default async function main( ) { return await runGitHubScript(core, async () => { await promoteSkills(core, exec, github, context, { - sourceRepositoryPath: requireEnv("SOURCE_REPOSITORY_PATH"), skillsRepositoryPath: requireEnv("SKILLS_REPOSITORY_PATH"), + sourceRepositoryPath: requireEnv("SOURCE_REPOSITORY_PATH"), }); }); } @@ -144,15 +144,13 @@ export async function getChangedPluginPackagePaths( .map((line) => line.trim()) .filter((line) => line.length > 0); - const changedPackagePaths: string[] = []; - - for (const packagePath of packagePaths) { - if (await packageVersionChanged(exec, sourceRepositoryPath, packagePath)) { - changedPackagePaths.push(packagePath); - } - } + const versionChanges = await Promise.all( + packagePaths.map((packagePath) => + packageVersionChanged(exec, sourceRepositoryPath, packagePath), + ), + ); - return changedPackagePaths; + return packagePaths.filter((_, index) => versionChanges[index]); } export function hasChangedCommercePluginVersions(packagePaths: string[]) { @@ -185,43 +183,44 @@ export async function preparePromotionArtifacts( sourceRepositoryPath: string, skillsRepositoryPath: string, ) { - const promotions: PluginPromotion[] = []; - - for (const packagePath of packagePaths) { - const pluginRelativePath = dirname(packagePath); - const sourcePath = join(sourceRepositoryPath, pluginRelativePath); - const targetPath = join(skillsRepositoryPath, pluginRelativePath); - const packageJson = await readJson( - join(sourcePath, "package.json"), - ); - const tileJson = await readJson(join(sourcePath, "tile.json")); - const displayName = tileJson.name.split("/").at(-1) ?? basename(sourcePath); - - await rm(targetPath, { recursive: true, force: true }); - await mkdir(targetPath, { recursive: true }); - - for (const entry of PROMOTED_ENTRIES) { - await cp(join(sourcePath, entry), join(targetPath, entry), { - recursive: true, - }); - } - await rewritePluginRepository(targetPath); - - promotions.push({ - packagePath, - sourcePath, - targetPath, - packageName: packageJson.name, - displayName, - version: packageJson.version, - changelogEntries: await readChangelogEntries( - join(sourcePath, "CHANGELOG.md"), - packageJson.version, - ), - }); - } - - return promotions; + return await Promise.all( + packagePaths.map(async (packagePath) => { + const pluginRelativePath = dirname(packagePath); + const sourcePath = join(sourceRepositoryPath, pluginRelativePath); + const targetPath = join(skillsRepositoryPath, pluginRelativePath); + const packageJson = await readJson( + join(sourcePath, "package.json"), + ); + const tileJson = await readJson(join(sourcePath, "tile.json")); + const displayName = + tileJson.name.split("/").at(-1) ?? basename(sourcePath); + + await rm(targetPath, { force: true, recursive: true }); + await mkdir(targetPath, { recursive: true }); + + await Promise.all( + PROMOTED_ENTRIES.map((entry) => + cp(join(sourcePath, entry), join(targetPath, entry), { + recursive: true, + }), + ), + ); + await rewritePluginRepository(targetPath); + + return { + changelogEntries: await readChangelogEntries( + join(sourcePath, "CHANGELOG.md"), + packageJson.version, + ), + displayName, + packageName: packageJson.name, + packagePath, + sourcePath, + targetPath, + version: packageJson.version, + } satisfies PluginPromotion; + }), + ); } export function buildPromotionPullRequestBody( @@ -409,31 +408,31 @@ async function upsertPromotionPullRequest( )}`; const body = buildPromotionPullRequestBody(promotions); const openPullRequests = await github.rest.pulls.list({ + head: `${TARGET_OWNER}:${PROMOTION_BRANCH}`, owner: TARGET_OWNER, repo: TARGET_REPO, - head: `${TARGET_OWNER}:${PROMOTION_BRANCH}`, state: "open", }); const [pullRequest] = openPullRequests.data; if (pullRequest) { await github.rest.pulls.update({ + body, owner: TARGET_OWNER, - repo: TARGET_REPO, pull_number: pullRequest.number, + repo: TARGET_REPO, title, - body, }); return; } await github.rest.pulls.create({ - owner: TARGET_OWNER, - repo: TARGET_REPO, base: "main", + body, head: PROMOTION_BRANCH, + owner: TARGET_OWNER, + repo: TARGET_REPO, title, - body, }); } diff --git a/scripts/source/sync-plugin-version.ts b/scripts/source/sync-plugin-version.ts index ba303380e..e35e88c18 100755 --- a/scripts/source/sync-plugin-version.ts +++ b/scripts/source/sync-plugin-version.ts @@ -29,18 +29,18 @@ export default async function main() { const packageJson = await readJson(packageJsonPath); - for (const manifestPath of [ + const manifestPaths = [ join(process.cwd(), "tile.json"), join(process.cwd(), ".claude-plugin/plugin.json"), - ]) { - if (!existsSync(manifestPath)) { - continue; - } - - const manifest = await readJson(manifestPath); - manifest.version = packageJson.version; - await writeJson(manifestPath, manifest); - } + ].filter((manifestPath) => existsSync(manifestPath)); + + await Promise.all( + manifestPaths.map(async (manifestPath) => { + const manifest = await readJson(manifestPath); + manifest.version = packageJson.version; + await writeJson(manifestPath, manifest); + }), + ); } main().catch((error) => { diff --git a/scripts/test/unit/ci/release/promote-skills.test.ts b/scripts/test/unit/ci/release/promote-skills.test.ts index eeb87d76a..7a3380418 100644 --- a/scripts/test/unit/ci/release/promote-skills.test.ts +++ b/scripts/test/unit/ci/release/promote-skills.test.ts @@ -46,9 +46,9 @@ describe("release/promote-skills.ts", () => { const core = createCoreMock(); const exec = createExecMock(); exec.getExecOutput.mockResolvedValueOnce({ - stdout: "", - stderr: "", exitCode: 0, + stderr: "", + stdout: "", }); await expect( @@ -86,9 +86,9 @@ describe("release/promote-skills.ts", () => { }; const context = { sha: "1234567890abcdef" }; exec.getExecOutput.mockResolvedValueOnce({ - stdout: "", - stderr: "", exitCode: 0, + stderr: "", + stdout: "", }); await promoteSkills( @@ -97,8 +97,8 @@ describe("release/promote-skills.ts", () => { github as never, context as never, { - sourceRepositoryPath: tempDir, skillsRepositoryPath: join(tempDir, "skills"), + sourceRepositoryPath: tempDir, }, ); @@ -111,9 +111,9 @@ describe("release/promote-skills.ts", () => { await withTempFiles({}, async (tempDir) => { const exec = createExecMock(); exec.getExecOutput.mockResolvedValueOnce({ - stdout: "", - stderr: "", exitCode: 0, + stderr: "", + stdout: "", }); await expect( @@ -134,17 +134,17 @@ describe("release/promote-skills.ts", () => { const exec = createExecMock(); exec.getExecOutput .mockResolvedValueOnce({ - stdout: "plugins/commerce/app-management/package.json\n", - stderr: "", exitCode: 0, + stderr: "", + stdout: "plugins/commerce/app-management/package.json\n", }) .mockResolvedValueOnce({ + exitCode: 0, + stderr: "", stdout: JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "0.0.0", }), - stderr: "", - exitCode: 0, }); await expect( @@ -166,17 +166,17 @@ describe("release/promote-skills.ts", () => { const exec = createExecMock(); exec.getExecOutput .mockResolvedValueOnce({ - stdout: "plugins/commerce/app-management/package.json\n", - stderr: "", exitCode: 0, + stderr: "", + stdout: "plugins/commerce/app-management/package.json\n", }) .mockResolvedValueOnce({ + exitCode: 0, + stderr: "", stdout: JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.0.0", }), - stderr: "", - exitCode: 0, }); await expect( @@ -198,14 +198,14 @@ describe("release/promote-skills.ts", () => { const exec = createExecMock(); exec.getExecOutput .mockResolvedValueOnce({ - stdout: "plugins/commerce/app-management/package.json\n", - stderr: "", exitCode: 0, + stderr: "", + stdout: "plugins/commerce/app-management/package.json\n", }) .mockResolvedValueOnce({ - stdout: "", - stderr: "fatal: path does not exist", exitCode: 128, + stderr: "fatal: path does not exist", + stdout: "", }); await expect( @@ -218,10 +218,13 @@ describe("release/promote-skills.ts", () => { test("copies promotion artifacts and excludes SDK-only files", async () => { await withTempFiles( { - "source/plugins/commerce/app-management/package.json": JSON.stringify({ - name: "@adobe/aio-commerce-plugin-app-management", - version: "1.0.0", - }), + "skills/plugins/commerce/app-management/package.json": "{}", + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + repository: "https://github.com/adobe/aio-commerce-sdk", + version: "1.0.0", + }), "source/plugins/commerce/app-management/CHANGELOG.md": [ "# @adobe/aio-commerce-plugin-app-management", "", @@ -238,21 +241,18 @@ describe("release/promote-skills.ts", () => { "- Keep previous entries out.", "", ].join("\n"), - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", + "source/plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", version: "1.0.0", }), "source/plugins/commerce/app-management/README.md": "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/.claude-plugin/plugin.json": - JSON.stringify({ - name: "commerce-app-management", - version: "1.0.0", - repository: "https://github.com/adobe/aio-commerce-sdk", - }), - "skills/plugins/commerce/app-management/package.json": "{}", + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.0.0", + }), }, async (tempDir) => { const sourceRoot = join(tempDir, "source"); @@ -274,10 +274,10 @@ describe("release/promote-skills.ts", () => { expect(promotions).toEqual([ expect.objectContaining({ + changelogEntries: ["- Promote App Management skills."], displayName: "commerce-app-management", packageName: "@adobe/aio-commerce-plugin-app-management", version: "1.0.0", - changelogEntries: ["- Promote App Management skills."], }), ]); expect(pluginJson.repository).toBe("https://github.com/adobe/skills"); @@ -300,24 +300,24 @@ describe("release/promote-skills.ts", () => { test("rejects plugin manifests that do not point to the SDK repository", async () => { await withTempFiles( { + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + repository: "https://github.com/adobe/skills", + version: "1.0.0", + }), "source/plugins/commerce/app-management/package.json": JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.0.0", }), - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", - version: "1.0.0", - }), "source/plugins/commerce/app-management/README.md": "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/.claude-plugin/plugin.json": - JSON.stringify({ - name: "commerce-app-management", - version: "1.0.0", - repository: "https://github.com/adobe/skills", - }), + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.0.0", + }), }, async (tempDir) => { await expect( @@ -334,10 +334,12 @@ describe("release/promote-skills.ts", () => { test("promotes changed plugins through the full git path", async () => { await withTempFiles( { - "source/plugins/commerce/app-management/package.json": JSON.stringify({ - name: "@adobe/aio-commerce-plugin-app-management", - version: "1.2.0", - }), + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + repository: "https://github.com/adobe/aio-commerce-sdk", + version: "1.2.0", + }), "source/plugins/commerce/app-management/CHANGELOG.md": [ "# @adobe/aio-commerce-plugin-app-management", "", @@ -346,20 +348,18 @@ describe("release/promote-skills.ts", () => { "- Add new skill.", "", ].join("\n"), - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", + "source/plugins/commerce/app-management/package.json": JSON.stringify({ + name: "@adobe/aio-commerce-plugin-app-management", version: "1.2.0", }), "source/plugins/commerce/app-management/README.md": "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/.claude-plugin/plugin.json": - JSON.stringify({ - name: "commerce-app-management", - version: "1.2.0", - repository: "https://github.com/adobe/aio-commerce-sdk", - }), + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.2.0", + }), }, async (tempDir) => { const core = createCoreMock(); @@ -378,24 +378,24 @@ describe("release/promote-skills.ts", () => { exec.getExecOutput // git diff --name-only (detect changed plugins) .mockResolvedValueOnce({ - stdout: "plugins/commerce/app-management/package.json\n", - stderr: "", exitCode: 0, + stderr: "", + stdout: "plugins/commerce/app-management/package.json\n", }) // git show HEAD^1:package.json (previous version) .mockResolvedValueOnce({ + exitCode: 0, + stderr: "", stdout: JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.1.1", }), - stderr: "", - exitCode: 0, }) // git status --porcelain (has changes to commit) .mockResolvedValueOnce({ - stdout: " M plugins/commerce/app-management/tile.json\n", - stderr: "", exitCode: 0, + stderr: "", + stdout: " M plugins/commerce/app-management/tile.json\n", }); await promoteSkills( @@ -404,8 +404,8 @@ describe("release/promote-skills.ts", () => { github as never, context as never, { - sourceRepositoryPath: join(tempDir, "source"), skillsRepositoryPath: join(tempDir, "skills"), + sourceRepositoryPath: join(tempDir, "source"), }, ); @@ -413,10 +413,10 @@ describe("release/promote-skills.ts", () => { expect(github.rest.pulls.create).toHaveBeenCalledOnce(); expect(github.rest.pulls.create).toHaveBeenCalledWith( expect.objectContaining({ + base: "main", + head: "promote/adobe-aio-commerce-sdk", owner: "adobe", repo: "skills", - head: "promote/adobe-aio-commerce-sdk", - base: "main", }), ); }, @@ -426,25 +426,25 @@ describe("release/promote-skills.ts", () => { test("is a no-op when adobe/skills already reflects the promoted content", async () => { await withTempFiles( { + "source/plugins/commerce/app-management/.claude-plugin/plugin.json": + JSON.stringify({ + name: "commerce-app-management", + repository: "https://github.com/adobe/aio-commerce-sdk", + version: "1.2.0", + }), + "source/plugins/commerce/app-management/CHANGELOG.md": "", "source/plugins/commerce/app-management/package.json": JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.2.0", }), - "source/plugins/commerce/app-management/CHANGELOG.md": "", - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", - version: "1.2.0", - }), "source/plugins/commerce/app-management/README.md": "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/.claude-plugin/plugin.json": - JSON.stringify({ - name: "commerce-app-management", - version: "1.2.0", - repository: "https://github.com/adobe/aio-commerce-sdk", - }), + "source/plugins/commerce/app-management/tile.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.2.0", + }), }, async (tempDir) => { const core = createCoreMock(); @@ -462,20 +462,20 @@ describe("release/promote-skills.ts", () => { exec.getExecOutput .mockResolvedValueOnce({ - stdout: "plugins/commerce/app-management/package.json\n", - stderr: "", exitCode: 0, + stderr: "", + stdout: "plugins/commerce/app-management/package.json\n", }) .mockResolvedValueOnce({ + exitCode: 0, + stderr: "", stdout: JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.1.1", }), - stderr: "", - exitCode: 0, }) // git status --porcelain returns empty: skills already up to date - .mockResolvedValueOnce({ stdout: "", stderr: "", exitCode: 0 }); + .mockResolvedValueOnce({ exitCode: 0, stderr: "", stdout: "" }); await promoteSkills( asCore(core), @@ -483,8 +483,8 @@ describe("release/promote-skills.ts", () => { github as never, context as never, { - sourceRepositoryPath: join(tempDir, "source"), skillsRepositoryPath: join(tempDir, "skills"), + sourceRepositoryPath: join(tempDir, "source"), }, ); @@ -498,14 +498,14 @@ describe("release/promote-skills.ts", () => { expect( buildPromotionPullRequestBody([ { + changelogEntries: ["- Add App Management skills."], displayName: "commerce-app-management", version: "1.0.0", - changelogEntries: ["- Add App Management skills."], }, { + changelogEntries: [], displayName: "commerce-app-migration", version: "1.0.0", - changelogEntries: [], }, ]), ).toBe( diff --git a/scripts/test/unit/sync-plugin-version.test.ts b/scripts/test/unit/sync-plugin-version.test.ts index 73d2b67d1..70028af60 100644 --- a/scripts/test/unit/sync-plugin-version.test.ts +++ b/scripts/test/unit/sync-plugin-version.test.ts @@ -27,6 +27,11 @@ describe("sync-plugin-version.ts", () => { test("syncs tile.json and plugin.json versions to package.json", async () => { await withTempFiles( { + ".claude-plugin/plugin.json": JSON.stringify({ + name: "commerce-app-management", + repository: "https://github.com/adobe/aio-commerce-sdk", + version: "1.1.0", + }), "package.json": JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.2.0", @@ -35,11 +40,6 @@ describe("sync-plugin-version.ts", () => { name: "adobe/commerce-app-management", version: "1.1.0", }), - ".claude-plugin/plugin.json": JSON.stringify({ - name: "commerce-app-management", - version: "1.1.0", - repository: "https://github.com/adobe/aio-commerce-sdk", - }), }, async (tempDir) => { await withChdir(tempDir, async () => { From 0b9e91ebc110dcdff996bb5bcea0fec483af3991 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 9 Jul 2026 11:46:23 +0200 Subject: [PATCH 8/8] fix scripts and docs to reference .tessl-plugin/plugin.json instead of tile.json The tessl migration (353577d1) replaced tile.json with .tessl-plugin/plugin.json for app-management and app-migration but never updated the code and docs that still pointed at the old path, breaking version-consistency.test.ts. That migration also manually bumped the manifest versions (1.1.3->1.1.4 for app-management, 1.0.0->1.1.1 for app-migration) without bumping package.json, which is supposed to be the authoritative source. Bump package.json to match since the manifest versions are the ones already reflected downstream. --- plugins/commerce/AGENTS.md | 17 ++++---- plugins/commerce/app-management/package.json | 2 +- plugins/commerce/app-migration/package.json | 2 +- scripts/source/ci/release/promote-skills.ts | 15 +++++-- scripts/source/sync-plugin-version.ts | 4 +- .../unit/ci/release/promote-skills.test.ts | 39 +++++++++++-------- .../unit/plugins/version-consistency.test.ts | 11 ++++-- scripts/test/unit/sync-plugin-version.test.ts | 18 +++++---- 8 files changed, 61 insertions(+), 47 deletions(-) diff --git a/plugins/commerce/AGENTS.md b/plugins/commerce/AGENTS.md index d527c96e5..b6463d85a 100644 --- a/plugins/commerce/AGENTS.md +++ b/plugins/commerce/AGENTS.md @@ -11,9 +11,9 @@ Each plugin is a private pnpm workspace package. Use changesets to express relea 3. Choose the semver bump for the plugin change. 4. Write a concise, user-facing changeset message. -Do not manually bump versions in `tile.json` or `.claude-plugin/plugin.json`. The plugin -`package.json` is the authoritative version source; release automation syncs that version into -the plugin manifests. +Do not manually bump versions in `.tessl-plugin/plugin.json` or `.claude-plugin/plugin.json`. The +plugin `package.json` is the authoritative version source; release automation syncs that version +into the plugin manifests. Plugin semver rules: @@ -21,14 +21,11 @@ Plugin semver rules: - `minor` — new skills, new references or assets, or additive behavioral changes. - `major` — removed skills, renamed skills that break installs, or behavioral changes that break existing usage patterns. -When adding a new skill to a plugin, also add a matching entry to `tile.json`: +When adding a new skill to a plugin, also add its directory to `.tessl-plugin/plugin.json`'s +`skills` array: ```json -"skills": { - "commerce-app-": { - "path": "skills/commerce-app-/SKILL.md" - } -} +"skills": ["skills/commerce-app-"] ``` ## Plugin README files @@ -43,7 +40,7 @@ Plugin `README.md` files are copied verbatim to `adobe/skills` and must be stabl Each `SKILL.md` must open with a YAML frontmatter block. Required fields: -- `name` — kebab-case identifier; must match the key in `tile.json` +- `name` — kebab-case identifier; must match the skill's directory name as referenced in `.tessl-plugin/plugin.json`'s `skills` array - `description` — multi-line routing description (use `>` block scalar); written to tell the agent _when_ to invoke the skill, not to describe its contents - `license` — always `Apache-2.0` - `compatibility` — runtime and package requirements (use `>` block scalar) diff --git a/plugins/commerce/app-management/package.json b/plugins/commerce/app-management/package.json index 71bb114c8..a858c1882 100644 --- a/plugins/commerce/app-management/package.json +++ b/plugins/commerce/app-management/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aio-commerce-plugin-app-management", - "version": "1.1.3", + "version": "1.1.4", "private": true, "scripts": { "version": "sdk-sync-plugin-version" diff --git a/plugins/commerce/app-migration/package.json b/plugins/commerce/app-migration/package.json index 65c87b781..3f0930f87 100644 --- a/plugins/commerce/app-migration/package.json +++ b/plugins/commerce/app-migration/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aio-commerce-plugin-app-migration", - "version": "1.0.0", + "version": "1.1.1", "private": true, "scripts": { "version": "sdk-sync-plugin-version" diff --git a/scripts/source/ci/release/promote-skills.ts b/scripts/source/ci/release/promote-skills.ts index c2b4e0006..e2ef9d1e7 100644 --- a/scripts/source/ci/release/promote-skills.ts +++ b/scripts/source/ci/release/promote-skills.ts @@ -23,14 +23,19 @@ const TARGET_REPO = "skills"; const TARGET_REPOSITORY_URL = `https://github.com/${TARGET_OWNER}/${TARGET_REPO}`; const PROMOTION_BRANCH = "promote/adobe-aio-commerce-sdk"; const PROMOTED_REPOSITORY_FIELD = "https://github.com/adobe/aio-commerce-sdk"; -const PROMOTED_ENTRIES = ["tile.json", "README.md", "skills", ".claude-plugin"]; +const PROMOTED_ENTRIES = [ + ".tessl-plugin", + "README.md", + "skills", + ".claude-plugin", +]; type PluginPackageJson = { name: string; version: string; }; -type TileJson = { +type TesslPluginManifest = { name: string; version: string; }; @@ -191,9 +196,11 @@ export async function preparePromotionArtifacts( const packageJson = await readJson( join(sourcePath, "package.json"), ); - const tileJson = await readJson(join(sourcePath, "tile.json")); + const tesslPluginJson = await readJson( + join(sourcePath, ".tessl-plugin/plugin.json"), + ); const displayName = - tileJson.name.split("/").at(-1) ?? basename(sourcePath); + tesslPluginJson.name.split("/").at(-1) ?? basename(sourcePath); await rm(targetPath, { force: true, recursive: true }); await mkdir(targetPath, { recursive: true }); diff --git a/scripts/source/sync-plugin-version.ts b/scripts/source/sync-plugin-version.ts index e35e88c18..1796687bc 100755 --- a/scripts/source/sync-plugin-version.ts +++ b/scripts/source/sync-plugin-version.ts @@ -20,7 +20,7 @@ import { readJson, writeJson } from "./ci/release/utils.ts"; type PackageJson = { version: string }; type VersionedManifest = { version: string; [key: string]: unknown }; -/** Syncs a Commerce plugin's tile.json and plugin.json versions to its package.json version. */ +/** Syncs a Commerce plugin's .tessl-plugin/plugin.json and .claude-plugin/plugin.json versions to its package.json version. */ export default async function main() { const packageJsonPath = join(process.cwd(), "package.json"); if (!existsSync(packageJsonPath)) { @@ -30,7 +30,7 @@ export default async function main() { const packageJson = await readJson(packageJsonPath); const manifestPaths = [ - join(process.cwd(), "tile.json"), + join(process.cwd(), ".tessl-plugin/plugin.json"), join(process.cwd(), ".claude-plugin/plugin.json"), ].filter((manifestPath) => existsSync(manifestPath)); diff --git a/scripts/test/unit/ci/release/promote-skills.test.ts b/scripts/test/unit/ci/release/promote-skills.test.ts index 7a3380418..06269bcbf 100644 --- a/scripts/test/unit/ci/release/promote-skills.test.ts +++ b/scripts/test/unit/ci/release/promote-skills.test.ts @@ -225,6 +225,11 @@ describe("release/promote-skills.ts", () => { repository: "https://github.com/adobe/aio-commerce-sdk", version: "1.0.0", }), + "source/plugins/commerce/app-management/.tessl-plugin/plugin.json": + JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.0.0", + }), "source/plugins/commerce/app-management/CHANGELOG.md": [ "# @adobe/aio-commerce-plugin-app-management", "", @@ -249,10 +254,6 @@ describe("release/promote-skills.ts", () => { "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", - version: "1.0.0", - }), }, async (tempDir) => { const sourceRoot = join(tempDir, "source"); @@ -306,6 +307,11 @@ describe("release/promote-skills.ts", () => { repository: "https://github.com/adobe/skills", version: "1.0.0", }), + "source/plugins/commerce/app-management/.tessl-plugin/plugin.json": + JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.0.0", + }), "source/plugins/commerce/app-management/package.json": JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.0.0", @@ -314,10 +320,6 @@ describe("release/promote-skills.ts", () => { "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", - version: "1.0.0", - }), }, async (tempDir) => { await expect( @@ -340,6 +342,11 @@ describe("release/promote-skills.ts", () => { repository: "https://github.com/adobe/aio-commerce-sdk", version: "1.2.0", }), + "source/plugins/commerce/app-management/.tessl-plugin/plugin.json": + JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.2.0", + }), "source/plugins/commerce/app-management/CHANGELOG.md": [ "# @adobe/aio-commerce-plugin-app-management", "", @@ -356,10 +363,6 @@ describe("release/promote-skills.ts", () => { "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", - version: "1.2.0", - }), }, async (tempDir) => { const core = createCoreMock(); @@ -395,7 +398,8 @@ describe("release/promote-skills.ts", () => { .mockResolvedValueOnce({ exitCode: 0, stderr: "", - stdout: " M plugins/commerce/app-management/tile.json\n", + stdout: + " M plugins/commerce/app-management/.tessl-plugin/plugin.json\n", }); await promoteSkills( @@ -432,6 +436,11 @@ describe("release/promote-skills.ts", () => { repository: "https://github.com/adobe/aio-commerce-sdk", version: "1.2.0", }), + "source/plugins/commerce/app-management/.tessl-plugin/plugin.json": + JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.2.0", + }), "source/plugins/commerce/app-management/CHANGELOG.md": "", "source/plugins/commerce/app-management/package.json": JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", @@ -441,10 +450,6 @@ describe("release/promote-skills.ts", () => { "# App Management\n", "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": "# Skill\n", - "source/plugins/commerce/app-management/tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", - version: "1.2.0", - }), }, async (tempDir) => { const core = createCoreMock(); diff --git a/scripts/test/unit/plugins/version-consistency.test.ts b/scripts/test/unit/plugins/version-consistency.test.ts index be7697df4..15f987493 100644 --- a/scripts/test/unit/plugins/version-consistency.test.ts +++ b/scripts/test/unit/plugins/version-consistency.test.ts @@ -25,19 +25,22 @@ const plugins = [ describe("plugin version consistency", () => { for (const plugin of plugins) { - test(`${plugin}: package.json version matches tile.json and .claude-plugin/plugin.json`, async () => { - const [packageJson, tileJson, pluginJson] = await Promise.all([ + test(`${plugin}: package.json version matches .tessl-plugin/plugin.json and .claude-plugin/plugin.json`, async () => { + const [packageJson, tesslPluginJson, pluginJson] = await Promise.all([ readFile(join(repoRoot, plugin, "package.json"), "utf-8").then( JSON.parse, ), - readFile(join(repoRoot, plugin, "tile.json"), "utf-8").then(JSON.parse), + readFile( + join(repoRoot, plugin, ".tessl-plugin/plugin.json"), + "utf-8", + ).then(JSON.parse), readFile( join(repoRoot, plugin, ".claude-plugin/plugin.json"), "utf-8", ).then(JSON.parse), ]); - expect(tileJson.version).toBe(packageJson.version); + expect(tesslPluginJson.version).toBe(packageJson.version); expect(pluginJson.version).toBe(packageJson.version); }); } diff --git a/scripts/test/unit/sync-plugin-version.test.ts b/scripts/test/unit/sync-plugin-version.test.ts index 70028af60..078dd0426 100644 --- a/scripts/test/unit/sync-plugin-version.test.ts +++ b/scripts/test/unit/sync-plugin-version.test.ts @@ -24,7 +24,7 @@ import syncPluginVersion from "#sync-plugin-version"; type PluginManifest = { version: string; repository?: string }; describe("sync-plugin-version.ts", () => { - test("syncs tile.json and plugin.json versions to package.json", async () => { + test("syncs .tessl-plugin/plugin.json and .claude-plugin/plugin.json versions to package.json", async () => { await withTempFiles( { ".claude-plugin/plugin.json": JSON.stringify({ @@ -32,27 +32,29 @@ describe("sync-plugin-version.ts", () => { repository: "https://github.com/adobe/aio-commerce-sdk", version: "1.1.0", }), + ".tessl-plugin/plugin.json": JSON.stringify({ + name: "adobe/commerce-app-management", + version: "1.1.0", + }), "package.json": JSON.stringify({ name: "@adobe/aio-commerce-plugin-app-management", version: "1.2.0", }), - "tile.json": JSON.stringify({ - name: "adobe/commerce-app-management", - version: "1.1.0", - }), }, async (tempDir) => { await withChdir(tempDir, async () => { await syncPluginVersion(); - const [tileJson, pluginJson] = await Promise.all([ - readJson(join(tempDir, "tile.json")), + const [tesslPluginJson, pluginJson] = await Promise.all([ + readJson( + join(tempDir, ".tessl-plugin/plugin.json"), + ), readJson( join(tempDir, ".claude-plugin/plugin.json"), ), ]); - expect(tileJson.version).toBe("1.2.0"); + expect(tesslPluginJson.version).toBe("1.2.0"); expect(pluginJson.version).toBe("1.2.0"); expect(pluginJson.repository).toBe( "https://github.com/adobe/aio-commerce-sdk",