Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ab722cb
chore(skills): add release-widget skill
yordan-st Aug 3, 2026
84f101f
refactor(automation-utils): extract release-widget skill scripts into…
yordan-st Aug 21, 2026
d220a4c
fix(skills): extract changelog read into rui-changelog CLI helper
yordan-st Sep 7, 2026
f779d5e
fix(release-widget): address all review feedback
yordan-st Sep 7, 2026
ea5a9a5
fix(release-widget): address remaining review feedback
yordan-st Sep 8, 2026
3405c50
fix(release-widget): derive release title inside OSS scripts
yordan-st Sep 8, 2026
f4d13c1
feat(automation-utils): add package path resolution and releasability…
yordan-st Sep 8, 2026
7005101
fix(automation-utils): resolve releases by tag for drafts too
yordan-st Sep 8, 2026
f6031cd
fix(automation-utils): count module subcomponent entries as unreleased
yordan-st Sep 8, 2026
4546eb9
fix(automation-utils): validate version bumps and bump wrapped widget…
yordan-st Sep 8, 2026
163ea13
fix(automation-utils): name SBOM zip after the real MPK hash
yordan-st Sep 8, 2026
907336f
fix(automation-utils): report existing READMEOSS asset instead of fai…
yordan-st Sep 8, 2026
8e388a7
docs(automation-utils): correct rui-create-jira-version exit code com…
yordan-st Sep 8, 2026
bc2e579
docs(release-widget): align skill with helper behaviour and trim prose
yordan-st Sep 8, 2026
43384ef
fix(release-widget): address PR review feedback on release skill and …
yordan-st Sep 9, 2026
aa15dbe
fix(release-widget): trim SKILL.md and drop dead xmlBumped field
yordan-st Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions .agents/skills/release-widget/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
---
name: release-widget
description: Use when releasing a standalone Mendix widget or module from the web-widgets monorepo — version bump through Marketplace publish. Guides module-vs-standalone detection, prereqs, changelog-driven version selection, and drives the release pipeline directly (git/gh/pnpm) instead of a manual wizard.
---

# Release Widget

## Overview

Releases a widget (or the module wrapping it): version bump → GitHub draft release → OSS clearance → Marketplace publish.

**Autonomy carve-out (this skill only):** pre-authorized to run `git push`, `gh workflow run`, and `gh release edit --draft=false` (publish) directly without per-step confirmation. Does **not** extend to rollback (deleting releases/tags/branches) or merging PRs (branch protection needs team approvals — user's job).

**No persisted release-state file** — each invocation re-checks git/GitHub/Jira/Marketplace from scratch. Safe to stop and resume across sessions.

## Prerequisites

Ask only if not already known:

1. **Package name** — widget or module to release, e.g. `combobox-web` or `data-widgets`. If not given, ask: "Which widget or module are you releasing?"

Everything else — check automatically in Phase 0, don't ask.

## Workflow

### Phase 0 — Detect release target

```bash
cd packages/pluggableWidgets/<widget>
pnpm exec rui-package-info
```

Prints `{"name", "version", "appNumber", "appName"}`. Reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument.

`appName` is the Marketplace display name (e.g. `Maps`). Draft release is titled `<appName> v<version>`.

- `appNumber` positive → **standalone release**. Keep this `info` — Phase 2/3 reuse `<npm-package-name>` from it, Phase 7 reuses `appNumber`.
- `appNumber` is `null`/absent/`-1` → widget is wrapped by another package. Find the owner (usually a module, but a widget like `charts-web` also wraps sub-widgets e.g. `area-chart-web`):
```bash
grep -l "\"@mendix/<widget>\"" packages/modules/*/package.json packages/pluggableWidgets/*/package.json
```
No owner found → stop, package is misconfigured. Otherwise re-run from the owner's directory:
```bash
cd packages/modules/<owner> # or packages/pluggableWidgets/<owner>
pnpm exec rui-package-info
```
Owner's `info` is the release target from here on. Tell the user which module or widget wraps it.

Placeholders used below, derived from the release target's `info`:

- `<npm-package-name>` — `info.name` (e.g. `@mendix/data-widgets`). Pass to `rui-changelog`, `rui-bump-version`, `CreateGitHubRelease.yml`'s `package` input.
- `<widget-or-module>` — `<npm-package-name>` minus `@mendix/` prefix, not a folder name. Used in commit messages, branch names, tags.
- `<release-tag>` — `<widget-or-module>-v<version>`, assembled once Phase 2 confirms `<version>`. Used as GitHub release tag, `tmp/<release-tag>` branch, and Jira version.

### Phase 1 — Prerequisite check

Run once, report all results together (don't ask one at a time):

```bash
echo "== SBOM jar =="; ls "${SBOM_GENERATOR_JAR:-$HOME/SBOM_Generator.jar}" 2>&1
echo "== gh auth =="; gh auth status 2>&1
echo "== git branch/status =="; git branch --show-current; git status --short
echo "== main sync =="; git fetch origin main --quiet
echo "behind: $(git rev-list HEAD..origin/main --count)"; echo "ahead: $(git rev-list origin/main..HEAD --count)"
```

If not on `main` or not in sync — fix it yourself (`git checkout main`, `git merge --ff-only origin/main`), unless `main` has diverged from `origin/main` (both `behind` and `ahead` non-zero) — stop and ask.

If the SBOM jar is missing, say what's missing and how to fix it (where to get `SBOM_Generator.jar`, or point `SBOM_GENERATOR_JAR` at it) — don't proceed.

### Phase 2 — Version selection

Current version already known from Phase 0 — don't re-run `rui-package-info`.

```bash
pnpm exec rui-changelog <npm-package-name>
```

Prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`.

- **Widget**: content in `sections`, `subcomponents` empty.
- **Module**: unreleased work usually sits in each wrapped widget's CHANGELOG.md, surfaced as `subcomponents[].sections`. Read those too — a module with `sections: []` is not "nothing to release". `hasUnreleasedLogs` accounts for both.

Summarize unreleased entries by type (Fixed/Added/Changed/Breaking changes), across subcomponents for a module (name the widget each entry came from), and propose a semver bump:

- Any "Breaking changes" section present → propose **major**, but flag it as a recommendation, not a mandate.
- Only "Added" → propose **minor**.
- Only "Fixed" → propose **patch**.

Show the concrete `<version>`, not just the bump-type word — e.g. "propose **minor**: 2.9.0 → 2.10.0". Always ask the user to confirm or override it. If their choice contradicts the changelog (patch despite breaking changes), flag it once, then respect it.

### Phase 3 — Version bump + release branch (autonomous)

Bump to the `<version>` confirmed in Phase 2:

```bash
pnpm exec rui-bump-version <npm-package-name> <version>
```

Prints `{"previousVersion", "version", "bumpedPackages", "changedPaths"}`.

Refuses to run, exits non-zero when:

- `<npm-package-name>` isn't independently releasable (no positive `marketplace.appNumber`) — go back to Phase 0.
- argument isn't a valid `x.y.z` version.
- resulting version isn't greater than `previousVersion`.

**If target wraps other packages (module, or widget like `charts-web` with sub-widgets), this bumps every wrapped dependency to the same version** — all of them, since they ship inside the same MPK. Use `changedPaths` verbatim in the `git add` below.

Then:

```bash
git checkout -b tmp/<release-tag>
git add <changedPaths from the rui-bump-version output above>
git commit -m "chore(<widget-or-module>): bump version to <version>"
git push -u origin tmp/<release-tag>
```

If the branch already exists locally or on remote, stop and ask.

**Jira version** — safe to re-run, always exits 0:

```bash
pnpm exec rui-create-jira-version "<release-tag>"
```

Prints `{"status": "created"|"exists"|"skipped", ...}`. `skipped` covers missing `JIRA_API_TOKEN` or a failed API call — not a blocker either way.

Trigger the GitHub release workflow:

```bash
gh workflow run "CreateGitHubRelease.yml" --ref "tmp/<release-tag>" -f package=<npm-package-name>
```

Poll for completion:

```bash
gh run list --workflow="CreateGitHubRelease.yml" --branch "tmp/<release-tag>" -L 1 --json databaseId,status,conclusion
gh run view <databaseId> --json status,conclusion,url
```

Keep `--branch`: without it, `-L 1` returns the newest run on _any_ branch.

Wait (re-poll, don't ask the user to check) until `status == completed`. Report the conclusion and the draft release URL.

### Phase 4 — OSS clearance SBOM (autonomous prep, manual submission)

```bash
pnpm exec rui-generate-oss-sbom "<release-tag>"
```

Prints `{"path": "<zip path>", "mpk": "<mpk asset name>", "sha256": "<hash>"}`. Generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if elsewhere.

Zip is named `<appName> v<version> [<sha256 of the MPK>].zip` — don't rename it. Works on the **draft** release, no publishing needed first.

**Submission is manual** — goes through the OSS clearance portal (Mendix app, Mendix credentials login), not email. Tell the user:

- Zip is ready at the printed path.
- Ask them to submit it via the OSS clearance portal (they know the URL/login flow).
- Draft the request content (widget/module name, version, draft release URL, one-line summary of changes from the changelog) so they can paste it into the portal.

Then ask: "Submitted? Waiting on OSS team reply (a READMEOSS HTML file)." Wait is unbounded (days) — skill can be safely re-invoked later; Phase 0–4 confirm state unchanged and skip straight back here.

### Phase 5 — Include OSS Readme (autonomous once file is provided)

```bash
pnpm exec rui-upload-readme-oss "<release-tag>"
```

Prints `{"uploaded": "<asset name>", "status": "created"|"exists"}`. `exists` = already attached, safe to re-run (GitHub rejects duplicate asset name with 422 otherwise). If no match found, ask the user where the file was saved and pass as 2nd arg: `rui-upload-readme-oss "<release-tag>" "<explicit path>"`.

### Phase 6 — Asset gate + publish (GATE — do not skip)

**Before ever publishing, verify both assets are present:**

```bash
gh release view <release-tag> --json assets --jq '.assets[].name'
```

Require: exactly one `.mpk` file AND one `*READMEOSS*.html` file. If either is missing, **refuse to publish** and tell the user what's missing. If user explicitly says to publish anyway, comply but state clearly this is unverified (no asset-gate passed).

Once the gate passes, publish:

```bash
gh release edit <release-tag> --draft=false
```

Publishing triggers `PublishMarketplace.yml` automatically (on `release: published`). Don't manually re-run the marketplace-publish workflow for the same tag unless the automatic run failed — see Phase 7.

### Phase 7 — Marketplace publish verification

```bash
gh run list --workflow="Publishes a package to marketplace" -L 5 --json databaseId,status,conclusion,headBranch,createdAt
```

Find the run matching this tag/branch.

- `conclusion: success` → doesn't mean the version is live yet. Confirm: `marketplace-mcp`'s `get_content_versions` with `contentId` = `appNumber` from Phase 0, check `<version>` is listed. If `marketplace-mcp` isn't connected or errors, ask the user to check the widget's Marketplace listing page for `<version>`. Don't declare done until one of the two confirms it.

Then verify changelog PR merged:

```bash
gh pr list --head "tmp/<release-tag>" --json number,state
```

Still open after successful publish → check whether `merge-changelogs-pr` step ran. Don't merge it yourself: branch protection requires team approvals — tell the user it's on them.

- `conclusion: failure` → **check run history before escalating**:
```bash
gh run view <databaseId> --log-failed | grep -A3 "Response status Code"
```
If `409` on `POST .../packages/<appNumber>/versions`:
1. Check whether an **earlier run for this exact tag already succeeded**: `gh run list --workflow="Publishes a package to marketplace" --json databaseId,status,conclusion,createdAt,headBranch` filtered to this tag. If yes, the 409 means **version already published** — report that, don't escalate/retry/teardown.
2. If no prior success: check for two runs created seconds apart for the same tag (double-trigger). Otherwise stuck server-side state.
3. Escalate: report appNumber, tag, endpoint, error, ask whether to (a) dig through logs together and check Marketplace → package page → Manage Versions for a stuck draft, or (b) retry.
4. Never `gh run rerun` speculatively. Rerun once, only after user confirms they acted (deleted a draft, etc.).

### Phase 8 — Rollback (human-gated, always — carve-out does not apply here)

If the user wants to undo a release attempt, list the exact teardown commands and **wait for explicit confirmation before running any of them**.

```bash
gh release view <release-tag> --json tagName,isDraft,isPrerelease # confirm current state first
gh pr list --head "tmp/<release-tag>" --json number,url,state
```

Teardown list (present all, confirm once, then execute):

1. `gh release delete <release-tag> --yes` (only if it exists)
2. `git push origin --delete <release-tag>` (remote tag)
3. `git push origin --delete tmp/<release-tag>` (auto-closes any open PR)
4. Jira version: cannot be deleted via available tooling — tell the user to check `<release-tag>` in Jira manually.
5. Marketplace: if a draft/version was created there, that's manual — tell the user to check.

## Common Mistakes

- **Writing inline `ts-node -e` scripts instead of using the packaged CLI helpers** — use `rui-package-info`, `rui-changelog`, `rui-bump-version`, `rui-create-jira-version`, `rui-generate-oss-sbom`, `rui-upload-readme-oss` (in `automation/utils/bin/`). Never reimplement ad hoc.
- **Working around a helper's refusal instead of fixing the input** — go back to Phase 0/2, don't bump the widget by hand.
- **Publishing before the asset gate passes** — never `gh release edit --draft=false` without confirming both MPK and READMEOSS are attached.
- **Escalating a 409 without checking run history first** — check `gh run list` for the tag first.
- **Running rollback commands without explicit go-ahead** — list, then wait.
70 changes: 70 additions & 0 deletions automation/utils/bin/rui-bump-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env ts-node-script

import { bumpPackageJson, bumpXml, hasPackageXml } from "../src/bump-version";
import { resolvePackagePath } from "../src/monorepo";
import { getPackageInfo, isReleasable } from "../src/package-info";
import { Version, versionRegex } from "../src/version";

async function bumpPackage(path: string, version: string): Promise<void> {
bumpPackageJson(path, version);

if (!hasPackageXml(path)) {
return; // modules have no package.xml
}

await bumpXml(path, version);
}

function checkVersion(version: string, previousVersion: string): void {
if (!versionRegex.test(version)) {
throw new Error(`'${version}' is not a valid version number (expected x.y.z)`);
}

if (!Version.fromString(version).isGreaterThan(Version.fromString(previousVersion))) {
throw new Error(`Version '${version}' is not greater than the current version '${previousVersion}'`);
}
}

async function main(): Promise<void> {
const npmPackageName = process.argv[2];
const version = process.argv[3];

if (!npmPackageName || !version) {
throw new Error(
"Usage: rui-bump-version <npm-package-name> <x.y.z>\nExample: rui-bump-version @mendix/combobox-web 1.2.3"
);
}

const path = await resolvePackagePath(npmPackageName);
const info = await getPackageInfo(path);

if (!isReleasable(info)) {
throw new Error(
`'${npmPackageName}' has no positive marketplace.appNumber, so it is not published on its own. If it is a widget, bump the module wrapping it instead.`
);
}

const previousVersion = info.version.format();
checkVersion(version, previousVersion);

await bumpPackage(path, version);
const bumpedPackages = [info.name];
const changedPaths = [path];

// Wrapped widgets are released as part of the target and share its version,
// so all of them are bumped, not only the ones with changelog entries.
for (const dependencyName of info.mxpackage.dependencies) {
const dependencyPath = await resolvePackagePath(dependencyName);

await bumpPackage(dependencyPath, version);
bumpedPackages.push(dependencyName);
changedPaths.push(dependencyPath);
}

console.log(JSON.stringify({ previousVersion, version, bumpedPackages, changedPaths }));
}

main().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
61 changes: 61 additions & 0 deletions automation/utils/bin/rui-changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env ts-node-script

import { getPackageChangelog, getWidgetChangelog } from "../src/changelog-parser";
import { SubComponentEntry } from "../src/changelog-parser/types";
import { listPackages, resolvePackagePath } from "../src/monorepo";
import { getPackageInfo, isReleasable } from "../src/package-info";

/**
* A module's own CHANGELOG.md never carries unreleased subcomponent entries:
* they're only added there (and immediately moved into a release) by
* rui-update-changelog-module, at release time. Before that, unreleased work
* for a wrapped widget lives solely in that widget's own CHANGELOG.md.
*/
async function getUnreleasedSubcomponents(dependencyNames: string[]): Promise<SubComponentEntry[]> {
const dependencies = await listPackages(dependencyNames);
const entries = await Promise.all(
dependencies.map(async ({ path }) => {
const depInfo = await getPackageInfo(path);
const [unreleased] = (await getWidgetChangelog(path)).changelog.content;
return { name: depInfo.mxpackage.name, sections: unreleased.sections };
})
);

return entries.filter(entry => entry.sections.length !== 0);
}

async function main(): Promise<void> {
const npmPackageName = process.argv[2];

if (!npmPackageName) {
throw new Error("Usage: rui-changelog <npm-package-name>\nExample: rui-changelog @mendix/combobox-web");
}

const path = await resolvePackagePath(npmPackageName);
const info = await getPackageInfo(path);

if (!isReleasable(info)) {
throw new Error(
`'${npmPackageName}' has no positive marketplace.appNumber, so it is not published on its own. If it is a widget, read the changelog of the module wrapping it instead.`
);
}

const changelog = await getPackageChangelog(path);
// The parsers keep the Unreleased entry first, released versions follow.
const unreleased = changelog.changelog.content[0];
const subcomponents =
"subcomponents" in unreleased ? await getUnreleasedSubcomponents(info.mxpackage.dependencies) : [];

console.log(
JSON.stringify({
hasUnreleasedLogs: unreleased.sections.length !== 0 || subcomponents.length !== 0,
sections: unreleased.sections,
subcomponents
})
);
}

main().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
Loading
Loading