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..32e2e30fd 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,48 @@ 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' + 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..b6463d85a 100644 --- a/plugins/commerce/AGENTS.md +++ b/plugins/commerce/AGENTS.md @@ -2,33 +2,62 @@ 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 `.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. -`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: + +- `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 its directory to `.tessl-plugin/plugin.json`'s +`skills` array: ```json -{ "version": "1.1.1" } +"skills": ["skills/commerce-app-"] ``` -When adding a new skill to a plugin, also add a matching entry to `tile.json`: +## Plugin README files -```json -"skills": { - "commerce-app-": { - "path": "skills/commerce-app-/SKILL.md" - } -} +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 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) +- `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 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..a858c1882 --- /dev/null +++ b/plugins/commerce/app-management/package.json @@ -0,0 +1,11 @@ +{ + "name": "@adobe/aio-commerce-plugin-app-management", + "version": "1.1.4", + "private": true, + "scripts": { + "version": "sdk-sync-plugin-version" + }, + "devDependencies": { + "@aio-commerce-sdk/scripts": "workspace:*" + } +} 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 9db425c1e..57354dfc0 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 @@ -17,8 +17,6 @@ compatibility: > Requires a base app initialized with commerce-app-init. metadata: author: adobe - sdk-package: "@adobe/aio-commerce-sdk" - version: "0.0.5" --- # 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-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 54fe46837..fb2a773de 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..3f0930f87 --- /dev/null +++ b/plugins/commerce/app-migration/package.json @@ -0,0 +1,11 @@ +{ + "name": "@adobe/aio-commerce-plugin-app-migration", + "version": "1.1.1", + "private": true, + "scripts": { + "version": "sdk-sync-plugin-version" + }, + "devDependencies": { + "@aio-commerce-sdk/scripts": "workspace:*" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41cdcdddb..f9910f959 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -751,6 +751,18 @@ importers: specifier: 'catalog:' version: 6.0.3 + plugins/commerce/app-management: + devDependencies: + '@aio-commerce-sdk/scripts': + specifier: workspace:* + version: link:../../../scripts + + plugins/commerce/app-migration: + devDependencies: + '@aio-commerce-sdk/scripts': + specifier: workspace:* + version: link:../../../scripts + scripts: devDependencies: '@actions/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4d25f49c9..062a7ae24 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/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 new file mode 100644 index 000000000..e2ef9d1e7 --- /dev/null +++ b/scripts/source/ci/release/promote-skills.ts @@ -0,0 +1,458 @@ +/* + * 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 } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +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/${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 = [ + ".tessl-plugin", + "README.md", + "skills", + ".claude-plugin", +]; + +type PluginPackageJson = { + name: string; + version: string; +}; + +type TesslPluginManifest = { + 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, { + skillsRepositoryPath: requireEnv("SKILLS_REPOSITORY_PATH"), + sourceRepositoryPath: requireEnv("SOURCE_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 versionChanges = await Promise.all( + packagePaths.map((packagePath) => + packageVersionChanged(exec, sourceRepositoryPath, packagePath), + ), + ); + + return packagePaths.filter((_, index) => versionChanges[index]); +} + +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, +) { + 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 tesslPluginJson = await readJson( + join(sourcePath, ".tessl-plugin/plugin.json"), + ); + const displayName = + tesslPluginJson.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( + 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); +} + +// @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"); + 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({ + head: `${TARGET_OWNER}:${PROMOTION_BRANCH}`, + owner: TARGET_OWNER, + repo: TARGET_REPO, + state: "open", + }); + const [pullRequest] = openPullRequests.data; + + if (pullRequest) { + await github.rest.pulls.update({ + body, + owner: TARGET_OWNER, + pull_number: pullRequest.number, + repo: TARGET_REPO, + title, + }); + return; + } + + await github.rest.pulls.create({ + base: "main", + body, + head: PROMOTION_BRANCH, + owner: TARGET_OWNER, + repo: TARGET_REPO, + title, + }); +} + +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/source/ci/release/utils.ts b/scripts/source/ci/release/utils.ts index 360590b45..037b07c02 100644 --- a/scripts/source/ci/release/utils.ts +++ b/scripts/source/ci/release/utils.ts @@ -10,8 +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"], @@ -25,6 +31,27 @@ 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, 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. */ 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..1796687bc --- /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 .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)) { + return; + } + + const packageJson = await readJson(packageJsonPath); + + const manifestPaths = [ + join(process.cwd(), ".tessl-plugin/plugin.json"), + join(process.cwd(), ".claude-plugin/plugin.json"), + ].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) => { + console.error(error); + process.exit(1); +}); 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..06269bcbf --- /dev/null +++ b/scripts/test/unit/ci/release/promote-skills.test.ts @@ -0,0 +1,528 @@ +/* + * 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({ + exitCode: 0, + stderr: "", + stdout: "", + }); + + 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({ + exitCode: 0, + stderr: "", + stdout: "", + }); + + await promoteSkills( + asCore(core), + asExec(exec), + github as never, + context as never, + { + skillsRepositoryPath: join(tempDir, "skills"), + sourceRepositoryPath: tempDir, + }, + ); + + 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({ + exitCode: 0, + stderr: "", + stdout: "", + }); + + 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({ + 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", + }), + }); + + 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({ + 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", + }), + }); + + 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({ + exitCode: 0, + stderr: "", + stdout: "plugins/commerce/app-management/package.json\n", + }) + .mockResolvedValueOnce({ + exitCode: 128, + stderr: "fatal: path does not exist", + stdout: "", + }); + + 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( + { + "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/.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", + "", + "## 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/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", + }, + 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({ + changelogEntries: ["- Promote App Management skills."], + displayName: "commerce-app-management", + packageName: "@adobe/aio-commerce-plugin-app-management", + version: "1.0.0", + }), + ]); + 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/.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/.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", + }), + "source/plugins/commerce/app-management/README.md": + "# App Management\n", + "source/plugins/commerce/app-management/skills/commerce-app-init/SKILL.md": + "# Skill\n", + }, + 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/.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/.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", + "", + "## 1.2.0", + "", + "- Add new skill.", + "", + ].join("\n"), + "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", + }, + 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({ + 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", + }), + }) + // git status --porcelain (has changes to commit) + .mockResolvedValueOnce({ + exitCode: 0, + stderr: "", + stdout: + " M plugins/commerce/app-management/.tessl-plugin/plugin.json\n", + }); + + await promoteSkills( + asCore(core), + asExec(exec), + github as never, + context as never, + { + skillsRepositoryPath: join(tempDir, "skills"), + sourceRepositoryPath: join(tempDir, "source"), + }, + ); + + expect(core.setOutput).toHaveBeenCalledWith("promotedSkills", "true"); + 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", + }), + ); + }, + ); + }); + + 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/.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", + 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", + }, + 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({ + 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", + }), + }) + // git status --porcelain returns empty: skills already up to date + .mockResolvedValueOnce({ exitCode: 0, stderr: "", stdout: "" }); + + await promoteSkills( + asCore(core), + asExec(exec), + github as never, + context as never, + { + skillsRepositoryPath: join(tempDir, "skills"), + sourceRepositoryPath: join(tempDir, "source"), + }, + ); + + 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([ + { + changelogEntries: ["- Add App Management skills."], + displayName: "commerce-app-management", + version: "1.0.0", + }, + { + changelogEntries: [], + displayName: "commerce-app-migration", + version: "1.0.0", + }, + ]), + ).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/ci/release/utils.test.ts b/scripts/test/unit/ci/release/utils.test.ts index 6bd18716f..5da3f8613 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,43 @@ 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', + ); + }); + }); + + 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', + ); + }); + }); + }); }); 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..15f987493 --- /dev/null +++ b/scripts/test/unit/plugins/version-consistency.test.ts @@ -0,0 +1,47 @@ +/* + * 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 .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, ".tessl-plugin/plugin.json"), + "utf-8", + ).then(JSON.parse), + readFile( + join(repoRoot, plugin, ".claude-plugin/plugin.json"), + "utf-8", + ).then(JSON.parse), + ]); + + 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 new file mode 100644 index 000000000..078dd0426 --- /dev/null +++ b/scripts/test/unit/sync-plugin-version.test.ts @@ -0,0 +1,90 @@ +/* + * 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 .tessl-plugin/plugin.json and .claude-plugin/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", + }), + ".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", + }), + }, + async (tempDir) => { + await withChdir(tempDir, async () => { + await syncPluginVersion(); + + const [tesslPluginJson, pluginJson] = await Promise.all([ + readJson( + join(tempDir, ".tessl-plugin/plugin.json"), + ), + readJson( + join(tempDir, ".claude-plugin/plugin.json"), + ), + ]); + + 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", + ); + }); + }, + ); + }); + + 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(); + }); + }, + ); + }); +}); 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