From ab722cb65f57bd7f555bb3e3a0f09187231a61d3 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 3 Aug 2026 14:27:02 +0200 Subject: [PATCH 01/16] chore(skills): add release-widget skill Automates widget/module release pipeline: version bump, GitHub draft release, OSS clearance SBOM, Marketplace publish. Sharing for team feedback before promoting out of private trial. --- .agents/skills/release-widget/SKILL.md | 268 +++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 .agents/skills/release-widget/SKILL.md diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md new file mode 100644 index 0000000000..29254a458e --- /dev/null +++ b/.agents/skills/release-widget/SKILL.md @@ -0,0 +1,268 @@ +--- +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) from this monorepo: version bump → GitHub draft release → OSS clearance → Marketplace publish. + +**Autonomy carve-out (this skill only):** unlike the repo's default stance of never pushing/publishing without asking, this skill is pre-authorized to run `git push`, `gh workflow run`, `gh pr merge`, and `gh release edit --draft=false` (publish) directly, without pausing for confirmation on each one — because a human already invoked this skill specifically to run a release. This does **not** extend to destructive rollback (deleting releases/tags/branches) or anything outside this skill's scope. + +**State is re-derived every run.** There is no persisted release-state file. Each invocation re-checks git/GitHub/Jira/Marketplace reality from scratch — safe to stop and resume this skill across sessions (e.g. while waiting days for OSS clearance). + +## Prerequisites + +Ask only if not already known: + +1. **Widget name** — e.g. `combobox-web`. If not given, ask: "Which widget are you releasing?" + +Everything else (module detection, environment prereqs, version state) — check automatically in Phase 0, don't ask. + +## Workflow + +### Phase 0 — Detect release target + +Read the widget's marketplace info directly via the repo's own helper (don't grep — the schema is the source of truth): + +```bash +cd automation/utils +pnpm exec ts-node -e " +import { getPackageInfo } from './src/package-info'; +getPackageInfo('$(pwd)/../../packages/pluggableWidgets/').then(info => { + console.log(JSON.stringify({ appNumber: info.marketplace.appNumber ?? null, appName: info.marketplace.appName, version: info.version.format(), name: info.name })); +}).catch(e => console.error('ERR', e.message)); +" +``` + +Use an **absolute path** to the widget dir — the script resolves `import()` relative to its own module location, not cwd. + +- `appNumber` is a positive number → **standalone widget release**. `$RELEASE_PATH = packages/pluggableWidgets/`. +- `appNumber` is `null`/absent → widget is module-wrapped, not published on its own. Find the owning module: + ```bash + grep -l "\"@mendix/\"" packages/modules/*/package.json + ``` + That module's directory is `$RELEASE_PATH`. Tell the user which module wraps it. If no module found, stop — this is a misconfigured package, not something to guess through. + +### 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 2>&1 +echo "== gh auth =="; gh auth status 2>&1 +echo "== JIRA token =="; [[ -n "$JIRA_API_TOKEN" ]] && echo set || echo missing +echo "== commitlint =="; ls node_modules/.bin/commitlint 2>/dev/null || echo missing +echo "== git branch/status =="; git branch --show-current; git status --short +echo "== main sync =="; git fetch origin main --quiet; git rev-list HEAD..origin/main --count; 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`) rather than asking, unless `main` has diverged from `origin/main` (both ahead and behind) — that needs a human decision, stop and ask. + +If commitlint or the SBOM jar is missing, tell the user exactly what's missing and how to fix it (`pnpm install`, or where to get `SBOM_Generator.jar`) — don't proceed past a missing prereq. + +### Phase 2 — Version selection + +Read the unreleased changelog and current version: + +```bash +sed -n '/## \[Unreleased\]/,/## \[/p' $RELEASE_PATH/CHANGELOG.md | head -40 +grep '"version"' $RELEASE_PATH/package.json | head -1 +``` + +Summarize the unreleased entries by type (Fixed/Added/Changed/Breaking changes) 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**. + +Ask the user to confirm or override — this is the one decision in the pipeline that's inherently a judgment call, always ask. If the user picks something inconsistent with changelog content (e.g. patch despite a breaking-changes note), flag the mismatch once, then respect their choice. + +### Phase 3 — Version bump + release branch (autonomous) + +Compute the next version and bump both files using the repo's real version-math helper (not a reimplementation): + +```bash +cd automation/utils +pnpm exec ts-node -e " +import { getNewVersion, bumpPackageJson, bumpXml } from './src/bump-version'; +const next = getNewVersion('', ''); +console.log('next:', next); +bumpPackageJson('$(pwd)/../../$RELEASE_PATH', next); +bumpXml('$(pwd)/../../$RELEASE_PATH', next).catch(e => console.error('no package.xml (module?):', e.message)); +" +``` + +Then, directly (no wizard): + +```bash +git checkout -b tmp/-v +git add $RELEASE_PATH +git commit -m "chore(): bump version to " +git push -u origin tmp/-v +``` + +If the branch already exists locally or on remote, stop and ask — don't guess a random suffix, that was a wizard fallback for unattended use, not something to do silently on someone's behalf. + +**Jira version** (skip cleanly if `JIRA_API_TOKEN` missing or the API call fails — this has historically 404'd transiently and is not a blocker): + +```bash +cd automation/utils +pnpm exec ts-node -e " +import { Jira } from './src/jira'; +const jira = new Jira(process.env.JIRA_PROJECT_KEY ?? 'WC', process.env.JIRA_BASE_URL ?? 'https://mendix.atlassian.net', process.env.JIRA_API_TOKEN!); +jira.initializeProjectData().then(() => jira.createVersion('-v')).then(v => console.log('created:', v.name)).catch(e => console.error('skip:', e.message)); +" +``` + +Trigger the GitHub release workflow directly: + +```bash +gh workflow run "CreateGitHubRelease.yml" --ref "tmp/-v" -f package= +``` + +Poll for completion: + +```bash +gh run list --workflow="CreateGitHubRelease.yml" -L 1 --json databaseId,status,conclusion +gh run view --json status,conclusion,url +``` + +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) + +Download the MPK from the draft release and generate the SBOM zip directly — don't use the interactive `oss-clearance` wizard, call the same underlying helpers: + +```bash +cd automation/utils +pnpm exec ts-node -e " +import { gh } from './src/github'; +import { createSBomGeneratorFolderStructure, generateSBomArtifactsInFolder } from './src/oss-clearance'; +import { join } from 'path'; +import { homedir } from 'os'; + +async function main() { + await gh.ensureAuth(); + const releaseId = await gh.getReleaseIdByReleaseTag('-v'); + const assets = await gh.listReleaseAssets(releaseId!); + const mpk = assets.find(a => a.name.endsWith('.mpk')); + if (!mpk) throw new Error('no MPK asset found'); + const releaseName = ' v'; // e.g. 'Combo box v2.9.0' + const [tmpFolder, downloadPath] = await createSBomGeneratorFolderStructure(releaseName); + await gh.downloadReleaseAsset(mpk.id, downloadPath); + const finalPath = join(homedir(), 'Downloads', \`\${releaseName} [pending-hash].zip\`); + await generateSBomArtifactsInFolder(tmpFolder, join(homedir(), 'SBOM_Generator.jar'), releaseName, finalPath); + console.log('SBOM zip:', finalPath); +} +main().catch(e => { console.error(e); process.exit(1); }); +" +``` + +**Submission is manual** — the OSS clearance request now goes through the OSS clearance portal (a Mendix app, log in with Mendix credentials), not email. Tell the user: + +- The zip is ready at the printed path. +- Ask them to submit it via the OSS clearance portal (they know the URL/login flow; don't guess or fetch a URL for this). +- 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)." This wait is inherently unbounded (days) — the skill can be safely re-invoked later; Phase 0–4 will just confirm state is unchanged and skip straight back here. + +### Phase 5 — Include OSS Readme (autonomous once file is provided) + +Once the user has the READMEOSS HTML file (ask where it was saved — default search locations are `~/Downloads` and `~/Documents`): + +```bash +cd automation/utils +pnpm exec ts-node -e " +import { gh } from './src/github'; +import { findAllReadmeOssLocally, getRecommendedReadmeOss } from './src/oss-clearance'; +import { basename } from 'path'; + +async function main() { + await gh.ensureAuth(); + const releaseId = await gh.getReleaseIdByReleaseTag('-v'); + const readmes = findAllReadmeOssLocally(); + const recommended = getRecommendedReadmeOss(' v', readmes); + if (!recommended) throw new Error('no matching READMEOSS found in Downloads/Documents — ask the user for the path'); + const asset = await gh.uploadReleaseAsset(releaseId!, recommended, basename(recommended)); + console.log('uploaded:', asset.name); +} +main().catch(e => { console.error(e); process.exit(1); }); +" +``` + +### Phase 6 — Asset gate + publish (GATE — do not skip) + +**Before ever publishing, verify both assets are present:** + +```bash +gh release view -v --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 the user explicitly says to publish anyway, comply but state clearly that this is an unverified publish (no asset-gate passed). + +Once the gate passes, publish directly (carve-out applies — this is a forward release action): + +```bash +gh release edit -v --draft=false +``` + +Publishing triggers `PublishMarketplace.yml` automatically (on `release: published`). Do not also manually re-run the marketplace-publish workflow for the same tag unless the automatic run actually failed — see Phase 7 for how to tell the difference. + +### 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` → done. Merge the changelog PR (this repo's automation should trigger this, but verify): + + ```bash + gh pr list --head "tmp/-v" --json number,state + ``` + + If still open and unmerged after a successful publish, that's unexpected — check whether the workflow's own `merge-changelogs-pr` step ran, don't just merge it yourself without checking why it didn't auto-merge. + +- `conclusion: failure` → **before assuming stuck-draft or escalating, check history first**: + ```bash + gh run view --log-failed | grep -A3 "Response status Code" + ``` + If it's a `409` on `POST .../packages//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 a prior run for the same tag succeeded, the 409 on this run means **the version is already published** — not a real failure. Report that, don't escalate, don't retry, don't teardown. + 2. If no prior success exists for this tag: this is the same failure mode from the last incident (real backend conflict, not caused by our script — `createDraft()` has no idempotency check, so a 409 here is either a genuine stuck server-side state or a double-trigger — check `gh run list` for more than one run created within seconds of each other for the same tag, which would indicate a double-trigger). + 3. Only after ruling out (1) and confirming a real conflict: report the exact escalation details (appNumber, tag, endpoint, error) and ask the user to check the Marketplace UI for stuck drafts. Marketplace UI actions are manual — give exact navigation steps (Marketplace → package page → Manage Versions → search version → delete draft), the user executes and reports back. + 4. Do not blindly `gh run rerun` more than once without new information — 3 identical reruns with no state change, as happened previously, wastes time. Rerun once after the user confirms they've taken an action (deleted a draft, etc.), not speculatively. + +### 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**, regardless of how far the carve-out extends elsewhere in this skill: + +```bash +gh release view --json tagName,isDraft,isPrerelease # confirm current state first +gh pr list --head "tmp/-v" --json number,url,state +``` + +Teardown list (present all, confirm once, then execute): + +1. `gh release delete --yes` (only if it exists) +2. `git push origin --delete ` (remote tag) +3. `git push origin --delete tmp/-v` (auto-closes any open PR) +4. Jira version: cannot be deleted via available tooling — tell the user to check `-v` in Jira manually. +5. Marketplace: if a draft/version was created there, that's manual — tell the user to check. + +## Common Mistakes + +- **Using relative paths in the `ts-node -e` snippets** — `import()` inside `automation/utils/src/*` resolves relative to that module's own location, not your cwd. Always pass absolute paths to widget/module directories. +- **Treating `appNumber` presence via grep instead of reading the schema** — a module-wrapped widget's package.json simply omits the `marketplace.appNumber` key; check for `null`/undefined via `getPackageInfo`, don't grep for the string `"appNumber"` (unreliable — the field can exist with value `-1` too, which also means "not independently published"). +- **Publishing before the asset gate passes** — this is the exact mistake pattern that caused the 409 double-trigger risk. Never call `gh release edit --draft=false` without first confirming both MPK and READMEOSS assets are attached. +- **Escalating a 409 without checking run history first** — many past "failures" are actually the second of two triggers for an already-successful publish. Always check `gh run list` history for the tag before treating a 409 as a real incident. +- **Retrying `gh run rerun` speculatively** — reruns without new information (e.g., a deleted draft) just reproduce the same failure. Only rerun after the user confirms they changed something. +- **Running rollback commands without the explicit go-ahead** — this is the one phase where the autonomy carve-out does not apply. Always list and wait for confirmation. + +## Reference Files + +None yet — this skill is new (rebuilt from lost prior version + 2026-07 incident history) and running in a private trial (`.agents/skills/`, untracked) before being proposed for the shared skill set. If patterns emerge from real runs (new failure modes, widget-specific quirks), add them here rather than growing the phases above indefinitely. From 84f101f793ec2839104c9b665003474f6640da69 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 21 Aug 2026 18:06:37 +0200 Subject: [PATCH 02/16] refactor(automation-utils): extract release-widget skill scripts into CLI helpers --- .agents/skills/release-widget/SKILL.md | 94 +++++-------------- automation/utils/bin/rui-bump-version.ts | 35 +++++++ .../utils/bin/rui-create-jira-version.ts | 49 ++++++++++ automation/utils/bin/rui-generate-oss-sbom.ts | 45 +++++++++ automation/utils/bin/rui-package-info.ts | 22 +++++ automation/utils/bin/rui-upload-readme-oss.ts | 39 ++++++++ automation/utils/package.json | 5 + 7 files changed, 220 insertions(+), 69 deletions(-) create mode 100755 automation/utils/bin/rui-bump-version.ts create mode 100755 automation/utils/bin/rui-create-jira-version.ts create mode 100755 automation/utils/bin/rui-generate-oss-sbom.ts create mode 100755 automation/utils/bin/rui-package-info.ts create mode 100755 automation/utils/bin/rui-upload-readme-oss.ts diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index 29254a458e..b16e72efd2 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -25,22 +25,17 @@ Everything else (module detection, environment prereqs, version state) — check ### Phase 0 — Detect release target -Read the widget's marketplace info directly via the repo's own helper (don't grep — the schema is the source of truth): +Read the widget's marketplace info via the packaged CLI helper (don't grep, don't write an inline script — the schema is the source of truth): ```bash -cd automation/utils -pnpm exec ts-node -e " -import { getPackageInfo } from './src/package-info'; -getPackageInfo('$(pwd)/../../packages/pluggableWidgets/').then(info => { - console.log(JSON.stringify({ appNumber: info.marketplace.appNumber ?? null, appName: info.marketplace.appName, version: info.version.format(), name: info.name })); -}).catch(e => console.error('ERR', e.message)); -" +cd packages/pluggableWidgets/ +pnpm exec rui-package-info ``` -Use an **absolute path** to the widget dir — the script resolves `import()` relative to its own module location, not cwd. +Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. - `appNumber` is a positive number → **standalone widget release**. `$RELEASE_PATH = packages/pluggableWidgets/`. -- `appNumber` is `null`/absent → widget is module-wrapped, not published on its own. Find the owning module: +- `appNumber` is `null`/absent/`-1` → widget is module-wrapped, not published on its own. Find the owning module: ```bash grep -l "\"@mendix/\"" packages/modules/*/package.json ``` @@ -82,19 +77,15 @@ Ask the user to confirm or override — this is the one decision in the pipeline ### Phase 3 — Version bump + release branch (autonomous) -Compute the next version and bump both files using the repo's real version-math helper (not a reimplementation): +Compute the next version and bump both files using the packaged CLI helper (not an inline script — it wraps the repo's real version-math code): ```bash -cd automation/utils -pnpm exec ts-node -e " -import { getNewVersion, bumpPackageJson, bumpXml } from './src/bump-version'; -const next = getNewVersion('', ''); -console.log('next:', next); -bumpPackageJson('$(pwd)/../../$RELEASE_PATH', next); -bumpXml('$(pwd)/../../$RELEASE_PATH', next).catch(e => console.error('no package.xml (module?):', e.message)); -" +cd $RELEASE_PATH +pnpm exec rui-bump-version ``` +Prints `{"previousVersion", "version", "xmlBumped"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. + Then, directly (no wizard): ```bash @@ -106,17 +97,14 @@ git push -u origin tmp/-v If the branch already exists locally or on remote, stop and ask — don't guess a random suffix, that was a wizard fallback for unattended use, not something to do silently on someone's behalf. -**Jira version** (skip cleanly if `JIRA_API_TOKEN` missing or the API call fails — this has historically 404'd transiently and is not a blocker): +**Jira version** — the CLI checks for an existing version before creating one (safe to re-run) and always exits 0, reporting status via JSON rather than blocking the release: ```bash -cd automation/utils -pnpm exec ts-node -e " -import { Jira } from './src/jira'; -const jira = new Jira(process.env.JIRA_PROJECT_KEY ?? 'WC', process.env.JIRA_BASE_URL ?? 'https://mendix.atlassian.net', process.env.JIRA_API_TOKEN!); -jira.initializeProjectData().then(() => jira.createVersion('-v')).then(v => console.log('created:', v.name)).catch(e => console.error('skip:', e.message)); -" +pnpm exec rui-create-jira-version "-v" ``` +Prints `{"status": "created"|"exists"|"skipped", ...}`. `skipped` covers both a missing `JIRA_API_TOKEN` and a failed API call (this has historically 404'd transiently) — not a blocker either way. + Trigger the GitHub release workflow directly: ```bash @@ -134,33 +122,14 @@ Wait (re-poll, don't ask the user to check) until `status == completed`. Report ### Phase 4 — OSS clearance SBOM (autonomous prep, manual submission) -Download the MPK from the draft release and generate the SBOM zip directly — don't use the interactive `oss-clearance` wizard, call the same underlying helpers: +Download the MPK from the draft release and generate the SBOM zip via the packaged CLI helper — don't use the interactive `oss-clearance` wizard, and don't write an inline script: ```bash -cd automation/utils -pnpm exec ts-node -e " -import { gh } from './src/github'; -import { createSBomGeneratorFolderStructure, generateSBomArtifactsInFolder } from './src/oss-clearance'; -import { join } from 'path'; -import { homedir } from 'os'; - -async function main() { - await gh.ensureAuth(); - const releaseId = await gh.getReleaseIdByReleaseTag('-v'); - const assets = await gh.listReleaseAssets(releaseId!); - const mpk = assets.find(a => a.name.endsWith('.mpk')); - if (!mpk) throw new Error('no MPK asset found'); - const releaseName = ' v'; // e.g. 'Combo box v2.9.0' - const [tmpFolder, downloadPath] = await createSBomGeneratorFolderStructure(releaseName); - await gh.downloadReleaseAsset(mpk.id, downloadPath); - const finalPath = join(homedir(), 'Downloads', \`\${releaseName} [pending-hash].zip\`); - await generateSBomArtifactsInFolder(tmpFolder, join(homedir(), 'SBOM_Generator.jar'), releaseName, finalPath); - console.log('SBOM zip:', finalPath); -} -main().catch(e => { console.error(e); process.exit(1); }); -" +pnpm exec rui-generate-oss-sbom "-v" " v" ``` +(` v` e.g. `"Combo box v2.9.0"`.) Prints `{"path": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. + **Submission is manual** — the OSS clearance request now goes through the OSS clearance portal (a Mendix app, log in with Mendix credentials), not email. Tell the user: - The zip is ready at the printed path. @@ -171,28 +140,14 @@ Then ask: "Submitted? Waiting on OSS team reply (a READMEOSS HTML file)." This w ### Phase 5 — Include OSS Readme (autonomous once file is provided) -Once the user has the READMEOSS HTML file (ask where it was saved — default search locations are `~/Downloads` and `~/Documents`): +Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`): ```bash -cd automation/utils -pnpm exec ts-node -e " -import { gh } from './src/github'; -import { findAllReadmeOssLocally, getRecommendedReadmeOss } from './src/oss-clearance'; -import { basename } from 'path'; - -async function main() { - await gh.ensureAuth(); - const releaseId = await gh.getReleaseIdByReleaseTag('-v'); - const readmes = findAllReadmeOssLocally(); - const recommended = getRecommendedReadmeOss(' v', readmes); - if (!recommended) throw new Error('no matching READMEOSS found in Downloads/Documents — ask the user for the path'); - const asset = await gh.uploadReleaseAsset(releaseId!, recommended, basename(recommended)); - console.log('uploaded:', asset.name); -} -main().catch(e => { console.error(e); process.exit(1); }); -" +pnpm exec rui-upload-readme-oss "-v" " v" ``` +Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 3rd argument: `rui-upload-readme-oss "" " v" ""`. + ### Phase 6 — Asset gate + publish (GATE — do not skip) **Before ever publishing, verify both assets are present:** @@ -256,8 +211,9 @@ Teardown list (present all, confirm once, then execute): ## Common Mistakes -- **Using relative paths in the `ts-node -e` snippets** — `import()` inside `automation/utils/src/*` resolves relative to that module's own location, not your cwd. Always pass absolute paths to widget/module directories. -- **Treating `appNumber` presence via grep instead of reading the schema** — a module-wrapped widget's package.json simply omits the `marketplace.appNumber` key; check for `null`/undefined via `getPackageInfo`, don't grep for the string `"appNumber"` (unreliable — the field can exist with value `-1` too, which also means "not independently published"). +- **Writing inline `ts-node -e` scripts instead of using the packaged CLI helpers** — `rui-package-info`, `rui-bump-version`, `rui-create-jira-version`, `rui-generate-oss-sbom`, and `rui-upload-readme-oss` (in `automation/utils/bin/`) already wrap all the release-pipeline logic this skill needs. Never reimplement that logic in an ad-hoc script. +- **Running `rui-package-info` / `rui-bump-version` without `cd`-ing into the widget/module dir first** — they read `process.cwd()`, not a path argument. +- **Treating `appNumber` presence via grep instead of reading the schema** — a module-wrapped widget's package.json simply omits the `marketplace.appNumber` key; check for `null`/undefined/`-1` via `rui-package-info`, don't grep for the string `"appNumber"` (unreliable — the field can exist with value `-1` too, which also means "not independently published"). - **Publishing before the asset gate passes** — this is the exact mistake pattern that caused the 409 double-trigger risk. Never call `gh release edit --draft=false` without first confirming both MPK and READMEOSS assets are attached. - **Escalating a 409 without checking run history first** — many past "failures" are actually the second of two triggers for an already-successful publish. Always check `gh run list` history for the tag before treating a 409 as a real incident. - **Retrying `gh run rerun` speculatively** — reruns without new information (e.g., a deleted draft) just reproduce the same failure. Only rerun after the user confirms they changed something. diff --git a/automation/utils/bin/rui-bump-version.ts b/automation/utils/bin/rui-bump-version.ts new file mode 100755 index 0000000000..1788688f27 --- /dev/null +++ b/automation/utils/bin/rui-bump-version.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env ts-node-script + +import { bumpPackageJson, bumpXml, getNewVersion } from "../src/bump-version"; +import { getPackageInfo } from "../src/package-info"; + +async function main(): Promise { + const bumpType = process.argv[2]; + + if (!bumpType) { + throw new Error( + "Usage: rui-bump-version \nRun from inside the widget/module directory." + ); + } + + const path = process.cwd(); + const info = await getPackageInfo(path); + const previousVersion = info.version.format(); + const version = getNewVersion(bumpType, previousVersion); + + bumpPackageJson(path, version); + + let xmlBumped = true; + try { + await bumpXml(path, version); + } catch { + xmlBumped = false; // modules have no package.xml + } + + console.log(JSON.stringify({ previousVersion, version, xmlBumped })); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/automation/utils/bin/rui-create-jira-version.ts b/automation/utils/bin/rui-create-jira-version.ts new file mode 100755 index 0000000000..337af06f19 --- /dev/null +++ b/automation/utils/bin/rui-create-jira-version.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env ts-node-script + +import { Jira } from "../src/jira"; + +/** + * Jira version creation has historically 404'd transiently and must never + * block a release, so this always exits 0 and reports status via stdout JSON. + */ +async function main(): Promise { + const versionName = process.argv[2]; + + if (!versionName) { + throw new Error( + "Usage: rui-create-jira-version \nExample: rui-create-jira-version combobox-web-v2.9.0" + ); + } + + const apiToken = process.env.JIRA_API_TOKEN; + if (!apiToken) { + console.log(JSON.stringify({ status: "skipped", reason: "JIRA_API_TOKEN not set" })); + return; + } + + const projectKey = process.env.JIRA_PROJECT_KEY ?? "WC"; + const baseUrl = process.env.JIRA_BASE_URL ?? "https://mendix.atlassian.net"; + + try { + const jira = new Jira(projectKey, baseUrl, apiToken); + await jira.initializeProjectData(); + + const existing = jira.findVersion(versionName); + if (existing) { + console.log(JSON.stringify({ status: "exists", name: existing.name, id: existing.id })); + return; + } + + const created = await jira.createVersion(versionName); + console.log(JSON.stringify({ status: "created", name: created.name, id: created.id })); + } catch (error) { + console.log( + JSON.stringify({ status: "skipped", reason: error instanceof Error ? error.message : String(error) }) + ); + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/automation/utils/bin/rui-generate-oss-sbom.ts b/automation/utils/bin/rui-generate-oss-sbom.ts new file mode 100755 index 0000000000..a37d8802f1 --- /dev/null +++ b/automation/utils/bin/rui-generate-oss-sbom.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env ts-node-script + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { gh } from "../src/github"; +import { createSBomGeneratorFolderStructure, generateSBomArtifactsInFolder } from "../src/oss-clearance"; + +async function main(): Promise { + const releaseTag = process.argv[2]; + const releaseName = process.argv[3]; + + if (!releaseTag || !releaseName) { + throw new Error( + 'Usage: rui-generate-oss-sbom ""\nExample: rui-generate-oss-sbom combobox-web-v2.9.0 "Combo box v2.9.0"' + ); + } + + await gh.ensureAuth(); + + const releaseId = await gh.getReleaseIdByReleaseTag(releaseTag); + if (!releaseId) { + throw new Error(`No GitHub release found for tag '${releaseTag}'`); + } + + const assets = await gh.listReleaseAssets(releaseId); + const mpk = assets.find(a => a.name.endsWith(".mpk")); + if (!mpk) { + throw new Error(`No .mpk asset found on release '${releaseTag}'`); + } + + const [tmpFolder, downloadPath] = await createSBomGeneratorFolderStructure(releaseName); + await gh.downloadReleaseAsset(mpk.id, downloadPath); + + const generatorJar = process.env.SBOM_GENERATOR_JAR ?? join(homedir(), "SBOM_Generator.jar"); + const finalPath = join(homedir(), "Downloads", `${releaseName} [pending-hash].zip`); + + await generateSBomArtifactsInFolder(tmpFolder, generatorJar, releaseName, finalPath); + + console.log(JSON.stringify({ path: finalPath })); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/automation/utils/bin/rui-package-info.ts b/automation/utils/bin/rui-package-info.ts new file mode 100755 index 0000000000..3cd8f2ee78 --- /dev/null +++ b/automation/utils/bin/rui-package-info.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env ts-node-script + +import { getPackageInfo } from "../src/package-info"; + +async function main(): Promise { + const path = process.cwd(); + const info = await getPackageInfo(path); + + console.log( + JSON.stringify({ + name: info.name, + version: info.version.format(), + appNumber: info.marketplace.appNumber ?? null, + appName: info.marketplace.appName ?? null + }) + ); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/automation/utils/bin/rui-upload-readme-oss.ts b/automation/utils/bin/rui-upload-readme-oss.ts new file mode 100755 index 0000000000..ba559a3bfc --- /dev/null +++ b/automation/utils/bin/rui-upload-readme-oss.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env ts-node-script + +import { basename } from "node:path"; +import { gh } from "../src/github"; +import { findAllReadmeOssLocally, getRecommendedReadmeOss } from "../src/oss-clearance"; + +async function main(): Promise { + const releaseTag = process.argv[2]; + const releaseName = process.argv[3]; + const explicitPath = process.argv[4]; + + if (!releaseTag || !releaseName) { + throw new Error( + 'Usage: rui-upload-readme-oss "" [explicit-path]\nExample: rui-upload-readme-oss combobox-web-v2.9.0 "Combo box v2.9.0"' + ); + } + + await gh.ensureAuth(); + + const releaseId = await gh.getReleaseIdByReleaseTag(releaseTag); + if (!releaseId) { + throw new Error(`No GitHub release found for tag '${releaseTag}'`); + } + + const readmePath = explicitPath ?? getRecommendedReadmeOss(releaseName, findAllReadmeOssLocally()); + if (!readmePath) { + throw new Error( + `No matching READMEOSS found in ~/Downloads or ~/Documents for '${releaseName}'. Pass the path explicitly as a 3rd argument.` + ); + } + + const asset = await gh.uploadReleaseAsset(releaseId, readmePath, basename(readmePath)); + console.log(JSON.stringify({ uploaded: asset.name })); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/automation/utils/package.json b/automation/utils/package.json index f97cfb193b..09aaf78849 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -5,15 +5,20 @@ "copyright": "© Mendix Technology BV 2025. All rights reserved.", "private": true, "bin": { + "rui-bump-version": "bin/rui-bump-version.ts", "rui-create-gh-release": "bin/rui-create-gh-release.ts", + "rui-create-jira-version": "bin/rui-create-jira-version.ts", "rui-create-translation": "bin/rui-create-translation.ts", + "rui-generate-oss-sbom": "bin/rui-generate-oss-sbom.ts", "rui-generate-package-xml": "bin/rui-generate-package-xml.ts", "rui-include-oss-in-artifact": "bin/rui-include-oss-in-artifact.ts", "rui-merge-changelogs-pr": "bin/rui-merge-changelogs-pr.ts", + "rui-package-info": "bin/rui-package-info.ts", "rui-prepare-release": "bin/rui-prepare-release.ts", "rui-publish-marketplace": "bin/rui-publish-marketplace.ts", "rui-update-changelog-module": "bin/rui-update-changelog-module.ts", "rui-update-changelog-widget": "bin/rui-update-changelog-widget.ts", + "rui-upload-readme-oss": "bin/rui-upload-readme-oss.ts", "rui-verify-package-format": "bin/rui-verify-package-format.ts" }, "types": "index.ts", From d220a4c0b9deec7121a8414a3f6f72717c9cf2c0 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 7 Sep 2026 14:46:27 +0200 Subject: [PATCH 03/16] fix(skills): extract changelog read into rui-changelog CLI helper --- .agents/skills/release-widget/SKILL.md | 9 +++++--- automation/utils/bin/rui-changelog.ts | 29 ++++++++++++++++++++++++++ automation/utils/package.json | 1 + 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 automation/utils/bin/rui-changelog.ts diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index b16e72efd2..859d419c66 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -60,13 +60,16 @@ If commitlint or the SBOM jar is missing, tell the user exactly what's missing a ### Phase 2 — Version selection -Read the unreleased changelog and current version: +Read the unreleased changelog and current version using the packaged CLI helpers (not raw `sed`/`grep` — they wrap the repo's real changelog parser): ```bash -sed -n '/## \[Unreleased\]/,/## \[/p' $RELEASE_PATH/CHANGELOG.md | head -40 -grep '"version"' $RELEASE_PATH/package.json | head -1 +cd $RELEASE_PATH +pnpm exec rui-changelog +pnpm exec rui-package-info ``` +`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}` — parsed directly from `CHANGELOG.md` via the changelog-parser module, so it correctly stops at the unreleased section boundary and reflects module subcomponents. + Summarize the unreleased entries by type (Fixed/Added/Changed/Breaking changes) and propose a semver bump: - Any "Breaking changes" section present → propose **major**, but flag it as a recommendation, not a mandate. diff --git a/automation/utils/bin/rui-changelog.ts b/automation/utils/bin/rui-changelog.ts new file mode 100644 index 0000000000..da81f015e9 --- /dev/null +++ b/automation/utils/bin/rui-changelog.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env ts-node-script + +import { getModuleChangelog, getWidgetChangelog } from "../src/changelog-parser"; +import { getPackageInfo } from "../src/package-info"; + +async function main(): Promise { + const path = process.cwd(); + const info = await getPackageInfo(path); + + const changelog = + info.mxpackage.type === "widget" && info.mxpackage.changelogType === "widget" + ? await getWidgetChangelog(path) + : await getModuleChangelog(path, info.mxpackage.name); + + const unreleased = changelog.changelog.content[0]; + + console.log( + JSON.stringify({ + hasUnreleasedLogs: changelog.hasUnreleasedLogs(), + sections: unreleased.sections, + subcomponents: "subcomponents" in unreleased ? unreleased.subcomponents : undefined + }) + ); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/automation/utils/package.json b/automation/utils/package.json index 09aaf78849..c74c4e1ca0 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -6,6 +6,7 @@ "private": true, "bin": { "rui-bump-version": "bin/rui-bump-version.ts", + "rui-changelog": "bin/rui-changelog.ts", "rui-create-gh-release": "bin/rui-create-gh-release.ts", "rui-create-jira-version": "bin/rui-create-jira-version.ts", "rui-create-translation": "bin/rui-create-translation.ts", From f779d5e6cb094ef456ac3f3aa5e8edadddd77bc8 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 7 Sep 2026 16:27:10 +0200 Subject: [PATCH 04/16] fix(release-widget): address all review feedback --- .agents/skills/release-widget/SKILL.md | 79 ++++++++++++++++---------- automation/utils/bin/rui-changelog.ts | 17 ++++-- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index 859d419c66..e2826ece03 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -32,15 +32,22 @@ cd packages/pluggableWidgets/ pnpm exec rui-package-info ``` -Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. +Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. Keep this `info` around — Phase 2 and 3 reuse it, no need to re-fetch. -- `appNumber` is a positive number → **standalone widget release**. `$RELEASE_PATH = packages/pluggableWidgets/`. +- `appNumber` is a positive number → **standalone release** (a widget, or a module that is itself published directly). `$RELEASE_PATH` = the directory you just `cd`-ed into — `packages/pluggableWidgets/` or `packages/modules/`. - `appNumber` is `null`/absent/`-1` → widget is module-wrapped, not published on its own. Find the owning module: ```bash grep -l "\"@mendix/\"" packages/modules/*/package.json ``` That module's directory is `$RELEASE_PATH`. Tell the user which module wraps it. If no module found, stop — this is a misconfigured package, not something to guess through. +Four placeholders recur through the rest of this skill, all derived once here from `$RELEASE_PATH`/`info` — never guessed, never reconstructed later: + +- `` — the folder name of `$RELEASE_PATH` (e.g. `combobox-web`, `data-widgets`). Used in commit messages. +- `` — `info.name` (e.g. `@mendix/combobox-web`). Used only for the `CreateGitHubRelease.yml` workflow's `package` input, which specifically wants the literal `package.json` `name` field, not the folder name. +- `` — `info.appName` from `rui-package-info` (e.g. `Combo box`). Used for the SBOM/READMEOSS asset naming (`" v"`) — never re-typed or guessed in Phase 4/5. +- `` — `` + `-v` + ``, assembled once `` is confirmed in Phase 2. This is the single identifier for the release: the GitHub release tag, the `tmp/` branch name, and the Jira version string all reuse it verbatim. + ### Phase 1 — Prerequisite check Run once, report all results together (don't ask one at a time): @@ -48,7 +55,6 @@ Run once, report all results together (don't ask one at a time): ```bash echo "== SBOM jar =="; ls ~/SBOM_Generator.jar 2>&1 echo "== gh auth =="; gh auth status 2>&1 -echo "== JIRA token =="; [[ -n "$JIRA_API_TOKEN" ]] && echo set || echo missing echo "== commitlint =="; ls node_modules/.bin/commitlint 2>/dev/null || echo missing echo "== git branch/status =="; git branch --show-current; git status --short echo "== main sync =="; git fetch origin main --quiet; git rev-list HEAD..origin/main --count; git rev-list origin/main..HEAD --count @@ -60,15 +66,14 @@ If commitlint or the SBOM jar is missing, tell the user exactly what's missing a ### Phase 2 — Version selection -Read the unreleased changelog and current version using the packaged CLI helpers (not raw `sed`/`grep` — they wrap the repo's real changelog parser): +Read the unreleased changelog using the packaged CLI helper (not raw `sed`/`grep` — it wraps the repo's real changelog parser). Current version is already known from Phase 0's `rui-package-info` output — don't re-run it: ```bash cd $RELEASE_PATH pnpm exec rui-changelog -pnpm exec rui-package-info ``` -`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}` — parsed directly from `CHANGELOG.md` via the changelog-parser module, so it correctly stops at the unreleased section boundary and reflects module subcomponents. +`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}` — parsed directly from `CHANGELOG.md` via the changelog-parser module, so it correctly stops at the unreleased section boundary and reflects module subcomponents (for a module, `subcomponents` is which wrapped widgets have unreleased entries — needed again in Phase 3). Summarize the unreleased entries by type (Fixed/Added/Changed/Breaking changes) and propose a semver bump: @@ -76,42 +81,54 @@ Summarize the unreleased entries by type (Fixed/Added/Changed/Breaking changes) - Only "Added" → propose **minor**. - Only "Fixed" → propose **patch**. -Ask the user to confirm or override — this is the one decision in the pipeline that's inherently a judgment call, always ask. If the user picks something inconsistent with changelog content (e.g. patch despite a breaking-changes note), flag the mismatch once, then respect their choice. +Compute the concrete `` this bump produces (current version from Phase 0 + bump type) and show it, not just the bump-type word — e.g. "propose **minor**: 2.9.0 → 2.10.0". Ask the user to confirm or override that `` — this is the one decision in the pipeline that's inherently a judgment call, always ask. If the user picks something inconsistent with changelog content (e.g. patch despite a breaking-changes note), flag the mismatch once, then respect their choice. The `` confirmed here is final — Phase 3 bumps to it directly, it is not recomputed later. ### Phase 3 — Version bump + release branch (autonomous) -Compute the next version and bump both files using the packaged CLI helper (not an inline script — it wraps the repo's real version-math code): +Bump both files to the `` confirmed in Phase 2, using the packaged CLI helper (not an inline script — it wraps the repo's real version-math code). Pass the explicit version, not the bump-type word — the word was only needed to _propose_ `` in Phase 2, it's a resolved value by now: ```bash cd $RELEASE_PATH -pnpm exec rui-bump-version +pnpm exec rui-bump-version ``` Prints `{"previousVersion", "version", "xmlBumped"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. +**Module release: bump every wrapped widget too.** A module's CHANGELOG.md tags each wrapped widget's own entries with the module's version (e.g. `### [3.11.4] Gallery` under `## [3.11.4] DataWidgets`) — so every widget in Phase 2's `subcomponents` list that has unreleased entries must be bumped to the same ``, not just the module itself: + +```bash +for widget in ; do + ( cd packages/pluggableWidgets/$widget && pnpm exec rui-bump-version ) +done +``` + +Skip this loop entirely for a standalone widget release. + Then, directly (no wizard): ```bash -git checkout -b tmp/-v -git add $RELEASE_PATH +git checkout -b tmp/ +git add $RELEASE_PATH packages/pluggableWidgets/ git commit -m "chore(): bump version to " -git push -u origin tmp/-v +git push -u origin tmp/ ``` +`git add` needs every path actually touched above — for a module release that's the module directory _and_ each wrapped widget directory bumped in the loop, not just `$RELEASE_PATH`. + If the branch already exists locally or on remote, stop and ask — don't guess a random suffix, that was a wizard fallback for unattended use, not something to do silently on someone's behalf. **Jira version** — the CLI checks for an existing version before creating one (safe to re-run) and always exits 0, reporting status via JSON rather than blocking the release: ```bash -pnpm exec rui-create-jira-version "-v" +pnpm exec rui-create-jira-version "" ``` Prints `{"status": "created"|"exists"|"skipped", ...}`. `skipped` covers both a missing `JIRA_API_TOKEN` and a failed API call (this has historically 404'd transiently) — not a blocker either way. -Trigger the GitHub release workflow directly: +Trigger the GitHub release workflow directly, passing `` as defined in Phase 0 (`info.name`, e.g. `@mendix/combobox-web` — the workflow's `package` input wants the literal `package.json` name, not ``): ```bash -gh workflow run "CreateGitHubRelease.yml" --ref "tmp/-v" -f package= +gh workflow run "CreateGitHubRelease.yml" --ref "tmp/" -f package= ``` Poll for completion: @@ -128,7 +145,7 @@ Wait (re-poll, don't ask the user to check) until `status == completed`. Report Download the MPK from the draft release and generate the SBOM zip via the packaged CLI helper — don't use the interactive `oss-clearance` wizard, and don't write an inline script: ```bash -pnpm exec rui-generate-oss-sbom "-v" " v" +pnpm exec rui-generate-oss-sbom "" " v" ``` (` v` e.g. `"Combo box v2.9.0"`.) Prints `{"path": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. @@ -146,17 +163,17 @@ Then ask: "Submitted? Waiting on OSS team reply (a READMEOSS HTML file)." This w Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`): ```bash -pnpm exec rui-upload-readme-oss "-v" " v" +pnpm exec rui-upload-readme-oss "" " v" ``` -Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 3rd argument: `rui-upload-readme-oss "" " v" ""`. +Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 3rd argument: `rui-upload-readme-oss "" " v" ""`. ### Phase 6 — Asset gate + publish (GATE — do not skip) **Before ever publishing, verify both assets are present:** ```bash -gh release view -v --json assets --jq '.assets[].name' +gh release view --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 the user explicitly says to publish anyway, comply but state clearly that this is an unverified publish (no asset-gate passed). @@ -164,7 +181,7 @@ Require: exactly one `.mpk` file AND one `*READMEOSS*.html` file. If either is m Once the gate passes, publish directly (carve-out applies — this is a forward release action): ```bash -gh release edit -v --draft=false +gh release edit --draft=false ``` Publishing triggers `PublishMarketplace.yml` automatically (on `release: published`). Do not also manually re-run the marketplace-publish workflow for the same tag unless the automatic run actually failed — see Phase 7 for how to tell the difference. @@ -177,10 +194,12 @@ gh run list --workflow="Publishes a package to marketplace" -L 5 --json database Find the run matching this tag/branch. -- `conclusion: success` → done. Merge the changelog PR (this repo's automation should trigger this, but verify): +- `conclusion: success` → the workflow succeeded, but that only means the API call didn't error — it's not proof the version is live. There's no packaged helper to query the Marketplace programmatically (`createDraft`/`publishDraft` are write-only, no idempotency or read-back check), so confirm manually: ask the user to open Marketplace → package page → Manage Versions, and check `` is listed. Don't declare the release done until they confirm. + + Once confirmed, merge the changelog PR (this repo's automation should trigger this, but verify): ```bash - gh pr list --head "tmp/-v" --json number,state + gh pr list --head "tmp/" --json number,state ``` If still open and unmerged after a successful publish, that's unexpected — check whether the workflow's own `merge-changelogs-pr` step ran, don't just merge it yourself without checking why it didn't auto-merge. @@ -192,7 +211,7 @@ Find the run matching this tag/branch. If it's a `409` on `POST .../packages//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 a prior run for the same tag succeeded, the 409 on this run means **the version is already published** — not a real failure. Report that, don't escalate, don't retry, don't teardown. 2. If no prior success exists for this tag: this is the same failure mode from the last incident (real backend conflict, not caused by our script — `createDraft()` has no idempotency check, so a 409 here is either a genuine stuck server-side state or a double-trigger — check `gh run list` for more than one run created within seconds of each other for the same tag, which would indicate a double-trigger). - 3. Only after ruling out (1) and confirming a real conflict: report the exact escalation details (appNumber, tag, endpoint, error) and ask the user to check the Marketplace UI for stuck drafts. Marketplace UI actions are manual — give exact navigation steps (Marketplace → package page → Manage Versions → search version → delete draft), the user executes and reports back. + 3. Only after ruling out (1) and confirming a real conflict: this is an exceptional situation, don't act unilaterally — report the exact escalation details (appNumber, tag, endpoint, error) and ask the user which way to go: (a) dig further into the failed run's logs together (e.g. check the Marketplace UI for stuck drafts — navigation: Marketplace → package page → Manage Versions → search version), or (b) if they know something was just fixed/changed on the Marketplace side, retry now. Don't pick a direction yourself. 4. Do not blindly `gh run rerun` more than once without new information — 3 identical reruns with no state change, as happened previously, wastes time. Rerun once after the user confirms they've taken an action (deleted a draft, etc.), not speculatively. ### Phase 8 — Rollback (human-gated, always — carve-out does not apply here) @@ -200,16 +219,16 @@ Find the run matching this tag/branch. If the user wants to undo a release attempt, list the exact teardown commands and **wait for explicit confirmation before running any of them**, regardless of how far the carve-out extends elsewhere in this skill: ```bash -gh release view --json tagName,isDraft,isPrerelease # confirm current state first -gh pr list --head "tmp/-v" --json number,url,state +gh release view --json tagName,isDraft,isPrerelease # confirm current state first +gh pr list --head "tmp/" --json number,url,state ``` Teardown list (present all, confirm once, then execute): -1. `gh release delete --yes` (only if it exists) -2. `git push origin --delete ` (remote tag) -3. `git push origin --delete tmp/-v` (auto-closes any open PR) -4. Jira version: cannot be deleted via available tooling — tell the user to check `-v` in Jira manually. +1. `gh release delete --yes` (only if it exists) +2. `git push origin --delete ` (remote tag) +3. `git push origin --delete tmp/` (auto-closes any open PR) +4. Jira version: cannot be deleted via available tooling — tell the user to check `` in Jira manually. 5. Marketplace: if a draft/version was created there, that's manual — tell the user to check. ## Common Mistakes @@ -218,7 +237,7 @@ Teardown list (present all, confirm once, then execute): - **Running `rui-package-info` / `rui-bump-version` without `cd`-ing into the widget/module dir first** — they read `process.cwd()`, not a path argument. - **Treating `appNumber` presence via grep instead of reading the schema** — a module-wrapped widget's package.json simply omits the `marketplace.appNumber` key; check for `null`/undefined/`-1` via `rui-package-info`, don't grep for the string `"appNumber"` (unreliable — the field can exist with value `-1` too, which also means "not independently published"). - **Publishing before the asset gate passes** — this is the exact mistake pattern that caused the 409 double-trigger risk. Never call `gh release edit --draft=false` without first confirming both MPK and READMEOSS assets are attached. -- **Escalating a 409 without checking run history first** — many past "failures" are actually the second of two triggers for an already-successful publish. Always check `gh run list` history for the tag before treating a 409 as a real incident. +- **Escalating a 409 without checking run history first** — `PublishMarketplace.yml` fires automatically on `release: published` (Phase 6), but nothing stops a human from also manually re-running it for the same tag (e.g. impatience, or thinking it silently failed) while the automatic run is still in flight or already succeeded. The second run then hits a package that's already published and 409s — a real HTTP error, but not a real incident. Always check `gh run list` history for the tag before treating a 409 as a real incident. - **Retrying `gh run rerun` speculatively** — reruns without new information (e.g., a deleted draft) just reproduce the same failure. Only rerun after the user confirms they changed something. - **Running rollback commands without the explicit go-ahead** — this is the one phase where the autonomy carve-out does not apply. Always list and wait for confirmation. diff --git a/automation/utils/bin/rui-changelog.ts b/automation/utils/bin/rui-changelog.ts index da81f015e9..bb5e147885 100644 --- a/automation/utils/bin/rui-changelog.ts +++ b/automation/utils/bin/rui-changelog.ts @@ -1,16 +1,23 @@ #!/usr/bin/env ts-node-script -import { getModuleChangelog, getWidgetChangelog } from "../src/changelog-parser"; +import { + getModuleChangelog, + getWidgetChangelog, + ModuleChangelogFileWrapper, + WidgetChangelogFileWrapper +} from "../src/changelog-parser"; import { getPackageInfo } from "../src/package-info"; async function main(): Promise { const path = process.cwd(); const info = await getPackageInfo(path); - const changelog = - info.mxpackage.type === "widget" && info.mxpackage.changelogType === "widget" - ? await getWidgetChangelog(path) - : await getModuleChangelog(path, info.mxpackage.name); + let changelog: WidgetChangelogFileWrapper | ModuleChangelogFileWrapper; + try { + changelog = await getWidgetChangelog(path); + } catch { + changelog = await getModuleChangelog(path, info.mxpackage.name); + } const unreleased = changelog.changelog.content[0]; From ea5a9a5eb9c9b768b8de1d9996d71114317a1359 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 15:02:59 +0200 Subject: [PATCH 05/16] fix(release-widget): address remaining review feedback --- .agents/skills/release-widget/SKILL.md | 52 +++++++++++------------- automation/utils/bin/rui-bump-version.ts | 43 ++++++++++++++++---- 2 files changed, 59 insertions(+), 36 deletions(-) diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index e2826ece03..6c5c31747a 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -17,7 +17,7 @@ Releases a widget (or the module wrapping it) from this monorepo: version bump Ask only if not already known: -1. **Widget name** — e.g. `combobox-web`. If not given, ask: "Which widget are you releasing?" +1. **Package name** — the 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 (module detection, environment prereqs, version state) — check automatically in Phase 0, don't ask. @@ -25,7 +25,7 @@ Everything else (module detection, environment prereqs, version state) — check ### Phase 0 — Detect release target -Read the widget's marketplace info via the packaged CLI helper (don't grep, don't write an inline script — the schema is the source of truth): +Read the widget's marketplace info via the packaged CLI helper (don't grep — the schema is the source of truth): ```bash cd packages/pluggableWidgets/ @@ -41,13 +41,14 @@ Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` ``` That module's directory is `$RELEASE_PATH`. Tell the user which module wraps it. If no module found, stop — this is a misconfigured package, not something to guess through. -Four placeholders recur through the rest of this skill, all derived once here from `$RELEASE_PATH`/`info` — never guessed, never reconstructed later: +Three placeholders recur through the rest of this skill, all derived once here from `$RELEASE_PATH`/`info` — never guessed, never reconstructed later: - `` — the folder name of `$RELEASE_PATH` (e.g. `combobox-web`, `data-widgets`). Used in commit messages. - `` — `info.name` (e.g. `@mendix/combobox-web`). Used only for the `CreateGitHubRelease.yml` workflow's `package` input, which specifically wants the literal `package.json` `name` field, not the folder name. -- `` — `info.appName` from `rui-package-info` (e.g. `Combo box`). Used for the SBOM/READMEOSS asset naming (`" v"`) — never re-typed or guessed in Phase 4/5. - `` — `` + `-v` + ``, assembled once `` is confirmed in Phase 2. This is the single identifier for the release: the GitHub release tag, the `tmp/` branch name, and the Jira version string all reuse it verbatim. +There's no `` placeholder to track — the draft release's title is always `" v"` (set by `rui-create-gh-release`'s workflow), so Phase 4/5 read it straight off the release itself instead of carrying it from here. + ### Phase 1 — Prerequisite check Run once, report all results together (don't ask one at a time): @@ -55,14 +56,13 @@ Run once, report all results together (don't ask one at a time): ```bash echo "== SBOM jar =="; ls ~/SBOM_Generator.jar 2>&1 echo "== gh auth =="; gh auth status 2>&1 -echo "== commitlint =="; ls node_modules/.bin/commitlint 2>/dev/null || echo missing echo "== git branch/status =="; git branch --show-current; git status --short echo "== main sync =="; git fetch origin main --quiet; git rev-list HEAD..origin/main --count; 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`) rather than asking, unless `main` has diverged from `origin/main` (both ahead and behind) — that needs a human decision, stop and ask. -If commitlint or the SBOM jar is missing, tell the user exactly what's missing and how to fix it (`pnpm install`, or where to get `SBOM_Generator.jar`) — don't proceed past a missing prereq. +If the SBOM jar is missing, tell the user exactly what's missing and how to fix it (where to get `SBOM_Generator.jar`) — don't proceed past a missing prereq. A missing `commitlint` binary is a local `pnpm install` issue, not something this skill gates on — if `git commit` in Phase 3 fails because of it, surface that error when it happens rather than pre-checking for it. ### Phase 2 — Version selection @@ -73,7 +73,7 @@ cd $RELEASE_PATH pnpm exec rui-changelog ``` -`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}` — parsed directly from `CHANGELOG.md` via the changelog-parser module, so it correctly stops at the unreleased section boundary and reflects module subcomponents (for a module, `subcomponents` is which wrapped widgets have unreleased entries — needed again in Phase 3). +`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}` — parsed directly from `CHANGELOG.md` via the changelog-parser module, so it correctly stops at the unreleased section boundary and reflects module subcomponents. For a module, `subcomponents` is which wrapped widgets have unreleased entries — informational here; Phase 3's `rui-bump-version` re-derives the same thing itself, no need to pass it along. Summarize the unreleased entries by type (Fixed/Added/Changed/Breaking changes) and propose a semver bump: @@ -85,36 +85,24 @@ Compute the concrete `` this bump produces (current version from Phase ### Phase 3 — Version bump + release branch (autonomous) -Bump both files to the `` confirmed in Phase 2, using the packaged CLI helper (not an inline script — it wraps the repo's real version-math code). Pass the explicit version, not the bump-type word — the word was only needed to _propose_ `` in Phase 2, it's a resolved value by now: +Bump to the `` confirmed in Phase 2, using the packaged CLI helper — it wraps the repo's real version-math code. Pass the explicit version, not the bump-type word — the word was only needed to _propose_ `` in Phase 2, it's a resolved value by now: ```bash cd $RELEASE_PATH pnpm exec rui-bump-version ``` -Prints `{"previousVersion", "version", "xmlBumped"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. - -**Module release: bump every wrapped widget too.** A module's CHANGELOG.md tags each wrapped widget's own entries with the module's version (e.g. `### [3.11.4] Gallery` under `## [3.11.4] DataWidgets`) — so every widget in Phase 2's `subcomponents` list that has unreleased entries must be bumped to the same ``, not just the module itself: - -```bash -for widget in ; do - ( cd packages/pluggableWidgets/$widget && pnpm exec rui-bump-version ) -done -``` - -Skip this loop entirely for a standalone widget release. +Prints `{"previousVersion", "version", "xmlBumped", "bumpedPackages", "changedPaths"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. **For a module release, this already bumps every wrapped widget that has unreleased changelog entries** — the script walks the module's own `mxpackage.dependencies` and checks each wrapped widget's changelog itself; there's no separate loop to run here. `bumpedPackages`/`changedPaths` list everything actually touched (the module/widget itself, plus any wrapped widgets bumped alongside it for a module release) — use `changedPaths` directly in the `git add` below rather than reconstructing the list. Then, directly (no wizard): ```bash git checkout -b tmp/ -git add $RELEASE_PATH packages/pluggableWidgets/ +git add git commit -m "chore(): bump version to " git push -u origin tmp/ ``` -`git add` needs every path actually touched above — for a module release that's the module directory _and_ each wrapped widget directory bumped in the loop, not just `$RELEASE_PATH`. - If the branch already exists locally or on remote, stop and ask — don't guess a random suffix, that was a wizard fallback for unattended use, not something to do silently on someone's behalf. **Jira version** — the CLI checks for an existing version before creating one (safe to re-run) and always exits 0, reporting status via JSON rather than blocking the release: @@ -142,13 +130,19 @@ Wait (re-poll, don't ask the user to check) until `status == completed`. Report ### Phase 4 — OSS clearance SBOM (autonomous prep, manual submission) -Download the MPK from the draft release and generate the SBOM zip via the packaged CLI helper — don't use the interactive `oss-clearance` wizard, and don't write an inline script: +Read the draft release's title — it's always `" v"` (e.g. `"Combo box v2.9.0"`), so this is the one place that string comes from, never re-typed or guessed: + +```bash +gh release view "" --json name --jq .name +``` + +Download the MPK from the draft release and generate the SBOM zip via the packaged CLI helper — don't use the interactive `oss-clearance` wizard: ```bash -pnpm exec rui-generate-oss-sbom "" " v" +pnpm exec rui-generate-oss-sbom "" "" ``` -(` v` e.g. `"Combo box v2.9.0"`.) Prints `{"path": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. +Prints `{"path": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. **Submission is manual** — the OSS clearance request now goes through the OSS clearance portal (a Mendix app, log in with Mendix credentials), not email. Tell the user: @@ -160,13 +154,13 @@ Then ask: "Submitted? Waiting on OSS team reply (a READMEOSS HTML file)." This w ### Phase 5 — Include OSS Readme (autonomous once file is provided) -Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`): +Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`), reusing the same release-title lookup from Phase 4: ```bash -pnpm exec rui-upload-readme-oss "" " v" +pnpm exec rui-upload-readme-oss "" "" ``` -Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 3rd argument: `rui-upload-readme-oss "" " v" ""`. +Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 3rd argument: `rui-upload-readme-oss "" "" ""`. ### Phase 6 — Asset gate + publish (GATE — do not skip) @@ -194,7 +188,7 @@ gh run list --workflow="Publishes a package to marketplace" -L 5 --json database Find the run matching this tag/branch. -- `conclusion: success` → the workflow succeeded, but that only means the API call didn't error — it's not proof the version is live. There's no packaged helper to query the Marketplace programmatically (`createDraft`/`publishDraft` are write-only, no idempotency or read-back check), so confirm manually: ask the user to open Marketplace → package page → Manage Versions, and check `` is listed. Don't declare the release done until they confirm. +- `conclusion: success` → the workflow succeeded, but that only means the API call didn't error — it's not proof the version is live. `createDraft`/`publishDraft` are write-only (no idempotency or read-back check), so confirm with a read: call the `marketplace-mcp` MCP server's `get_content_versions` tool with `contentId` = `appNumber` (from Phase 0's `rui-package-info` output), and check `` appears among the returned versions. If `marketplace-mcp` isn't connected (e.g. missing `MARKETPLACE_API_TOKEN`) or the call errors, fall back to asking the user to open Marketplace → package page → Manage Versions and check manually. Don't declare the release done until one of these two confirms it. Once confirmed, merge the changelog PR (this repo's automation should trigger this, but verify): diff --git a/automation/utils/bin/rui-bump-version.ts b/automation/utils/bin/rui-bump-version.ts index 1788688f27..cc587c7169 100755 --- a/automation/utils/bin/rui-bump-version.ts +++ b/automation/utils/bin/rui-bump-version.ts @@ -1,8 +1,29 @@ #!/usr/bin/env ts-node-script +import { resolve } from "path"; import { bumpPackageJson, bumpXml, getNewVersion } from "../src/bump-version"; +import { getModuleChangelog, getWidgetChangelog } from "../src/changelog-parser"; import { getPackageInfo } from "../src/package-info"; +async function bumpPackage(path: string, version: string): Promise { + bumpPackageJson(path, version); + try { + await bumpXml(path, version); + return true; + } catch { + return false; // modules have no package.xml + } +} + +async function hasUnreleasedLogs(path: string): Promise { + const info = await getPackageInfo(path); + const changelog = + info.mxpackage.type === "widget" && info.mxpackage.changelogType === "widget" + ? await getWidgetChangelog(path) + : await getModuleChangelog(path, info.mxpackage.name); + return changelog.hasUnreleasedLogs(); +} + async function main(): Promise { const bumpType = process.argv[2]; @@ -17,16 +38,24 @@ async function main(): Promise { const previousVersion = info.version.format(); const version = getNewVersion(bumpType, previousVersion); - bumpPackageJson(path, version); + const xmlBumped = await bumpPackage(path, version); + const bumpedPackages = [info.mxpackage.name]; + const changedPaths = [path]; - let xmlBumped = true; - try { - await bumpXml(path, version); - } catch { - xmlBumped = false; // modules have no package.xml + if (info.mxpackage.type === "module") { + for (const dependencyName of info.mxpackage.dependencies) { + const widgetFolder = dependencyName.replace(/^@mendix\//, ""); + const widgetPath = resolve(path, "..", "..", "pluggableWidgets", widgetFolder); + + if (await hasUnreleasedLogs(widgetPath)) { + await bumpPackage(widgetPath, version); + bumpedPackages.push(widgetFolder); + changedPaths.push(widgetPath); + } + } } - console.log(JSON.stringify({ previousVersion, version, xmlBumped })); + console.log(JSON.stringify({ previousVersion, version, xmlBumped, bumpedPackages, changedPaths })); } main().catch(error => { From 3405c508da5ed00c921512f95bce95898d01aff1 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 15:31:38 +0200 Subject: [PATCH 06/16] fix(release-widget): derive release title inside OSS scripts --- .agents/skills/release-widget/SKILL.md | 28 +++++++------------ automation/utils/bin/rui-generate-oss-sbom.ts | 12 ++++---- automation/utils/bin/rui-upload-readme-oss.ts | 17 ++++++----- automation/utils/src/github.ts | 18 ++++++++++++ 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index 6c5c31747a..08b4faa7c2 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -32,7 +32,7 @@ cd packages/pluggableWidgets/ pnpm exec rui-package-info ``` -Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. Keep this `info` around — Phase 2 and 3 reuse it, no need to re-fetch. +Prints `{"name", "version", "appNumber"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. Keep this `info` around — Phase 2 and 3 reuse it, no need to re-fetch. - `appNumber` is a positive number → **standalone release** (a widget, or a module that is itself published directly). `$RELEASE_PATH` = the directory you just `cd`-ed into — `packages/pluggableWidgets/` or `packages/modules/`. - `appNumber` is `null`/absent/`-1` → widget is module-wrapped, not published on its own. Find the owning module: @@ -47,8 +47,6 @@ Three placeholders recur through the rest of this skill, all derived once here f - `` — `info.name` (e.g. `@mendix/combobox-web`). Used only for the `CreateGitHubRelease.yml` workflow's `package` input, which specifically wants the literal `package.json` `name` field, not the folder name. - `` — `` + `-v` + ``, assembled once `` is confirmed in Phase 2. This is the single identifier for the release: the GitHub release tag, the `tmp/` branch name, and the Jira version string all reuse it verbatim. -There's no `` placeholder to track — the draft release's title is always `" v"` (set by `rui-create-gh-release`'s workflow), so Phase 4/5 read it straight off the release itself instead of carrying it from here. - ### Phase 1 — Prerequisite check Run once, report all results together (don't ask one at a time): @@ -62,18 +60,18 @@ echo "== main sync =="; git fetch origin main --quiet; git rev-list HEAD..origin If not on `main` or not in sync — fix it yourself (`git checkout main`, `git merge --ff-only origin/main`) rather than asking, unless `main` has diverged from `origin/main` (both ahead and behind) — that needs a human decision, stop and ask. -If the SBOM jar is missing, tell the user exactly what's missing and how to fix it (where to get `SBOM_Generator.jar`) — don't proceed past a missing prereq. A missing `commitlint` binary is a local `pnpm install` issue, not something this skill gates on — if `git commit` in Phase 3 fails because of it, surface that error when it happens rather than pre-checking for it. +If the SBOM jar is missing, tell the user exactly what's missing and how to fix it (where to get `SBOM_Generator.jar`) — don't proceed past a missing prereq. ### Phase 2 — Version selection -Read the unreleased changelog using the packaged CLI helper (not raw `sed`/`grep` — it wraps the repo's real changelog parser). Current version is already known from Phase 0's `rui-package-info` output — don't re-run it: +Read the unreleased changelog using the packaged CLI helper. Current version is already known from Phase 0's `rui-package-info` output — don't re-run it: ```bash cd $RELEASE_PATH pnpm exec rui-changelog ``` -`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}` — parsed directly from `CHANGELOG.md` via the changelog-parser module, so it correctly stops at the unreleased section boundary and reflects module subcomponents. For a module, `subcomponents` is which wrapped widgets have unreleased entries — informational here; Phase 3's `rui-bump-version` re-derives the same thing itself, no need to pass it along. +`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. For a module, `subcomponents` is which wrapped widgets have unreleased entries — informational here; Phase 3's `rui-bump-version` re-derives the same thing itself, no need to pass it along. Summarize the unreleased entries by type (Fixed/Added/Changed/Breaking changes) and propose a semver bump: @@ -85,14 +83,14 @@ Compute the concrete `` this bump produces (current version from Phase ### Phase 3 — Version bump + release branch (autonomous) -Bump to the `` confirmed in Phase 2, using the packaged CLI helper — it wraps the repo's real version-math code. Pass the explicit version, not the bump-type word — the word was only needed to _propose_ `` in Phase 2, it's a resolved value by now: +Bump to the `` confirmed in Phase 2, using the packaged CLI helper. Pass the explicit version, not the bump-type word — the word was only needed to _propose_ `` in Phase 2, it's a resolved value by now: ```bash cd $RELEASE_PATH pnpm exec rui-bump-version ``` -Prints `{"previousVersion", "version", "xmlBumped", "bumpedPackages", "changedPaths"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. **For a module release, this already bumps every wrapped widget that has unreleased changelog entries** — the script walks the module's own `mxpackage.dependencies` and checks each wrapped widget's changelog itself; there's no separate loop to run here. `bumpedPackages`/`changedPaths` list everything actually touched (the module/widget itself, plus any wrapped widgets bumped alongside it for a module release) — use `changedPaths` directly in the `git add` below rather than reconstructing the list. +Prints `{"previousVersion", "version", "xmlBumped", "bumpedPackages", "changedPaths"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. **For a module release, this already bumps every wrapped widget that has unreleased changelog entries** — there's no separate loop to run here. `bumpedPackages`/`changedPaths` list everything actually touched (the module/widget itself, plus any wrapped widgets bumped alongside it for a module release) — use `changedPaths` directly in the `git add` below rather than reconstructing the list. Then, directly (no wizard): @@ -130,16 +128,10 @@ Wait (re-poll, don't ask the user to check) until `status == completed`. Report ### Phase 4 — OSS clearance SBOM (autonomous prep, manual submission) -Read the draft release's title — it's always `" v"` (e.g. `"Combo box v2.9.0"`), so this is the one place that string comes from, never re-typed or guessed: - -```bash -gh release view "" --json name --jq .name -``` - Download the MPK from the draft release and generate the SBOM zip via the packaged CLI helper — don't use the interactive `oss-clearance` wizard: ```bash -pnpm exec rui-generate-oss-sbom "" "" +pnpm exec rui-generate-oss-sbom "" ``` Prints `{"path": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. @@ -154,13 +146,13 @@ Then ask: "Submitted? Waiting on OSS team reply (a READMEOSS HTML file)." This w ### Phase 5 — Include OSS Readme (autonomous once file is provided) -Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`), reusing the same release-title lookup from Phase 4: +Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`): ```bash -pnpm exec rui-upload-readme-oss "" "" +pnpm exec rui-upload-readme-oss "" ``` -Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 3rd argument: `rui-upload-readme-oss "" "" ""`. +Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 2nd argument: `rui-upload-readme-oss "" ""`. ### Phase 6 — Asset gate + publish (GATE — do not skip) diff --git a/automation/utils/bin/rui-generate-oss-sbom.ts b/automation/utils/bin/rui-generate-oss-sbom.ts index a37d8802f1..714fe1154b 100755 --- a/automation/utils/bin/rui-generate-oss-sbom.ts +++ b/automation/utils/bin/rui-generate-oss-sbom.ts @@ -7,22 +7,22 @@ import { createSBomGeneratorFolderStructure, generateSBomArtifactsInFolder } fro async function main(): Promise { const releaseTag = process.argv[2]; - const releaseName = process.argv[3]; - if (!releaseTag || !releaseName) { + if (!releaseTag) { throw new Error( - 'Usage: rui-generate-oss-sbom ""\nExample: rui-generate-oss-sbom combobox-web-v2.9.0 "Combo box v2.9.0"' + "Usage: rui-generate-oss-sbom \nExample: rui-generate-oss-sbom combobox-web-v2.9.0" ); } await gh.ensureAuth(); - const releaseId = await gh.getReleaseIdByReleaseTag(releaseTag); - if (!releaseId) { + const release = await gh.getReleaseByTag(releaseTag); + if (!release) { throw new Error(`No GitHub release found for tag '${releaseTag}'`); } + const releaseName = release.name; - const assets = await gh.listReleaseAssets(releaseId); + const assets = await gh.listReleaseAssets(release.id); const mpk = assets.find(a => a.name.endsWith(".mpk")); if (!mpk) { throw new Error(`No .mpk asset found on release '${releaseTag}'`); diff --git a/automation/utils/bin/rui-upload-readme-oss.ts b/automation/utils/bin/rui-upload-readme-oss.ts index ba559a3bfc..ba92e9f5bf 100755 --- a/automation/utils/bin/rui-upload-readme-oss.ts +++ b/automation/utils/bin/rui-upload-readme-oss.ts @@ -6,30 +6,29 @@ import { findAllReadmeOssLocally, getRecommendedReadmeOss } from "../src/oss-cle async function main(): Promise { const releaseTag = process.argv[2]; - const releaseName = process.argv[3]; - const explicitPath = process.argv[4]; + const explicitPath = process.argv[3]; - if (!releaseTag || !releaseName) { + if (!releaseTag) { throw new Error( - 'Usage: rui-upload-readme-oss "" [explicit-path]\nExample: rui-upload-readme-oss combobox-web-v2.9.0 "Combo box v2.9.0"' + "Usage: rui-upload-readme-oss [explicit-path]\nExample: rui-upload-readme-oss combobox-web-v2.9.0" ); } await gh.ensureAuth(); - const releaseId = await gh.getReleaseIdByReleaseTag(releaseTag); - if (!releaseId) { + const release = await gh.getReleaseByTag(releaseTag); + if (!release) { throw new Error(`No GitHub release found for tag '${releaseTag}'`); } - const readmePath = explicitPath ?? getRecommendedReadmeOss(releaseName, findAllReadmeOssLocally()); + const readmePath = explicitPath ?? getRecommendedReadmeOss(release.name, findAllReadmeOssLocally()); if (!readmePath) { throw new Error( - `No matching READMEOSS found in ~/Downloads or ~/Documents for '${releaseName}'. Pass the path explicitly as a 3rd argument.` + `No matching READMEOSS found in ~/Downloads or ~/Documents for '${release.name}'. Pass the path explicitly as a 2nd argument.` ); } - const asset = await gh.uploadReleaseAsset(releaseId, readmePath, basename(readmePath)); + const asset = await gh.uploadReleaseAsset(release.id, readmePath, basename(readmePath)); console.log(JSON.stringify({ uploaded: asset.name })); } diff --git a/automation/utils/src/github.ts b/automation/utils/src/github.ts index 884716d0fc..c9f1013e3e 100644 --- a/automation/utils/src/github.ts +++ b/automation/utils/src/github.ts @@ -187,6 +187,24 @@ export class GitHub { } } + async getReleaseByTag(releaseTag: string): Promise<{ id: string; name: string } | undefined> { + console.log(`Searching for release from Github tag '${releaseTag}'`); + try { + return await fetch<{ id: string; name: string }>( + "GET", + `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${releaseTag}`, + undefined, + { ...this.ghAPIHeaders } + ); + } catch (e) { + if (e instanceof Error && e.message.includes("404")) { + return undefined; + } + + throw e; + } + } + async getMPKReleaseAssetUrl(releaseTag: string): Promise { const releaseId = await this.getReleaseIdByReleaseTag(releaseTag); if (!releaseId) { From f4d13c1207092f07d196f1045e9d437922d9fb51 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:10 +0200 Subject: [PATCH 07/16] feat(automation-utils): add package path resolution and releasability helpers --- automation/utils/src/monorepo.ts | 8 ++++++++ automation/utils/src/package-info.ts | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/automation/utils/src/monorepo.ts b/automation/utils/src/monorepo.ts index 65295e1c96..b0203219d9 100644 --- a/automation/utils/src/monorepo.ts +++ b/automation/utils/src/monorepo.ts @@ -28,6 +28,14 @@ export async function listPackages(packageNames: string[]): Promise { + const [pkg] = await listPackages([npmPackageName]); + if (!pkg) { + throw new Error(`No package found in the workspace named '${npmPackageName}'`); + } + return pkg.path; +} + export async function getMpkPaths(packageNames: string[]): Promise { const packages = await listPackages(packageNames); const paths = [...find(packages.map(p => `${p.path}/dist/${p.version}/*.mpk`))]; diff --git a/automation/utils/src/package-info.ts b/automation/utils/src/package-info.ts index 85c933ef6b..ce90d1ff04 100644 --- a/automation/utils/src/package-info.ts +++ b/automation/utils/src/package-info.ts @@ -153,6 +153,15 @@ export async function getPackageInfo(path: string): Promise { return PackageSchema.parse(packageJson); } +/** + * A package can be released on its own only if it has a Marketplace app number. + * A missing number (module-wrapped widget) and `-1` (never published, e.g. the + * google-tag module) both mean "not independently releasable". + */ +export function isReleasable(info: PackageInfo): boolean { + return (info.marketplace.appNumber ?? -1) > 0; +} + export async function getPublishedInfo(path: string): Promise { const packageJson = await getPackageFileContent(path); return PublishedPackageSchema.parse(packageJson); From 7005101b6eae57693374a7192a177408190f109a Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:16 +0200 Subject: [PATCH 08/16] fix(automation-utils): resolve releases by tag for drafts too --- automation/utils/src/github.ts | 66 +++++++++++++++------------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/automation/utils/src/github.ts b/automation/utils/src/github.ts index c9f1013e3e..9a723286ea 100644 --- a/automation/utils/src/github.ts +++ b/automation/utils/src/github.ts @@ -33,7 +33,7 @@ export interface GitHubReleaseAsset { digest: string; } -export interface GitHubDraftRelease { +export interface GitHubRelease { id: string; tag_name: string; name: string; @@ -43,6 +43,8 @@ export interface GitHubDraftRelease { assets: GitHubReleaseAsset[]; } +export type GitHubDraftRelease = GitHubRelease; + interface GitHubReleaseInfo { title: string; tag: string; @@ -163,46 +165,43 @@ export class GitHub { } async getReleaseIdByReleaseTag(releaseTag: string): Promise { - console.log(`Searching for release from Github tag '${releaseTag}'`); - try { - const release = - (await fetch<{ id: string }>( - "GET", - `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${releaseTag}`, - undefined, - { ...this.ghAPIHeaders } - )) ?? []; - - if (!release) { - return undefined; - } - - return release.id; - } catch (e) { - if (e instanceof Error && e.message.includes("404")) { - return undefined; - } - - throw e; - } + return (await this.getReleaseByTag(releaseTag))?.id; } - async getReleaseByTag(releaseTag: string): Promise<{ id: string; name: string } | undefined> { + /** + * Finds a release by tag, draft or published. + * + * The `releases/tags/{tag}` endpoint only knows published releases — a draft + * has no git tag yet, so it answers 404 for one. Drafts are only reachable + * through the release list, which is the fallback used here. + */ + async getReleaseByTag(releaseTag: string): Promise { console.log(`Searching for release from Github tag '${releaseTag}'`); + try { - return await fetch<{ id: string; name: string }>( + return await fetch( "GET", `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${releaseTag}`, undefined, { ...this.ghAPIHeaders } ); } catch (e) { - if (e instanceof Error && e.message.includes("404")) { - return undefined; + if (!(e instanceof Error && e.message.includes("404"))) { + throw e; } - - throw e; } + + const releases = await this.listReleases(); + return releases.find(release => release.tag_name === releaseTag); + } + + async listReleases(): Promise { + return fetch( + "GET", + `https://api.github.com/repos/${this.owner}/${this.repo}/releases?per_page=100`, + undefined, + { ...this.ghAPIHeaders } + ); } async getMPKReleaseAssetUrl(releaseTag: string): Promise { @@ -222,14 +221,7 @@ export class GitHub { } async getDraftReleases(): Promise { - const releases = await fetch( - "GET", - `https://api.github.com/repos/${this.owner}/${this.repo}/releases`, - undefined, - { - ...this.ghAPIHeaders - } - ); + const releases = await this.listReleases(); // Filter only draft releases return releases.filter(release => release.draft); From f6031cd18c3ee36144dfef7dfbdb31b8a544bbe1 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:18 +0200 Subject: [PATCH 09/16] fix(automation-utils): count module subcomponent entries as unreleased --- automation/utils/bin/rui-changelog.ts | 32 +++++++++++-------- .../utils/src/changelog-parser/index.ts | 20 +++++++++++- .../utils/src/prepare-release-helpers.ts | 9 ++---- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/automation/utils/bin/rui-changelog.ts b/automation/utils/bin/rui-changelog.ts index bb5e147885..93fb3e5ec5 100644 --- a/automation/utils/bin/rui-changelog.ts +++ b/automation/utils/bin/rui-changelog.ts @@ -1,31 +1,35 @@ #!/usr/bin/env ts-node-script -import { - getModuleChangelog, - getWidgetChangelog, - ModuleChangelogFileWrapper, - WidgetChangelogFileWrapper -} from "../src/changelog-parser"; -import { getPackageInfo } from "../src/package-info"; +import { getPackageChangelog } from "../src/changelog-parser"; +import { resolvePackagePath } from "../src/monorepo"; +import { getPackageInfo, isReleasable } from "../src/package-info"; async function main(): Promise { - const path = process.cwd(); + const npmPackageName = process.argv[2]; + + if (!npmPackageName) { + throw new Error("Usage: rui-changelog \nExample: rui-changelog @mendix/combobox-web"); + } + + const path = await resolvePackagePath(npmPackageName); const info = await getPackageInfo(path); - let changelog: WidgetChangelogFileWrapper | ModuleChangelogFileWrapper; - try { - changelog = await getWidgetChangelog(path); - } catch { - changelog = await getModuleChangelog(path, info.mxpackage.name); + 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 ? unreleased.subcomponents : []; console.log( JSON.stringify({ hasUnreleasedLogs: changelog.hasUnreleasedLogs(), sections: unreleased.sections, - subcomponents: "subcomponents" in unreleased ? unreleased.subcomponents : undefined + subcomponents }) ); } diff --git a/automation/utils/src/changelog-parser/index.ts b/automation/utils/src/changelog-parser/index.ts index 549a7dceac..1dc87d35b4 100644 --- a/automation/utils/src/changelog-parser/index.ts +++ b/automation/utils/src/changelog-parser/index.ts @@ -1,5 +1,6 @@ import { readFileSync, writeFileSync } from "fs"; import { join } from "path"; +import { getPackageInfo } from "../package-info"; import { Version } from "../version"; import { parse as parseModuleChangelogFile } from "./parser/module/module"; import { parse as parseWidgetChangelogFile } from "./parser/widget/widget"; @@ -220,7 +221,10 @@ export class ModuleChangelogFileWrapper { } hasUnreleasedLogs(): boolean { - return this.changelog.content[0].sections.length !== 0; + const [unreleased] = this.changelog.content; + // Module changelogs usually carry their entries under subcomponents + // (per wrapped widget), with no module level sections at all. + return unreleased.sections.length !== 0 || unreleased.subcomponents.length !== 0; } moveUnreleasedToVersion(newVersion: Version): ModuleChangelogFileWrapper { @@ -302,3 +306,17 @@ export async function getWidgetChangelog(path: string): Promise { return ModuleChangelogFileWrapper.fromFile(join(path, "CHANGELOG.md"), moduleName); } + +/** + * Reads a package's CHANGELOG.md with the parser matching its format. Packages + * declare the format with `mxpackage.changelogType` and fall back to their + * `mxpackage.type` when they don't (which is all but one widget). + */ +export async function getPackageChangelog( + path: string +): Promise { + const info = await getPackageInfo(path); + return (info.mxpackage.changelogType ?? info.mxpackage.type) === "widget" + ? getWidgetChangelog(path) + : getModuleChangelog(path, info.mxpackage.name); +} diff --git a/automation/utils/src/prepare-release-helpers.ts b/automation/utils/src/prepare-release-helpers.ts index 368a852aa9..91bbc45741 100644 --- a/automation/utils/src/prepare-release-helpers.ts +++ b/automation/utils/src/prepare-release-helpers.ts @@ -2,7 +2,7 @@ import chalk from "chalk"; import { prompt } from "enquirer"; import { getModuleChangelog, - getWidgetChangelog, + getPackageChangelog, ModuleChangelogFileWrapper, WidgetChangelogFileWrapper } from "./changelog-parser"; @@ -42,12 +42,7 @@ async function loadPackagesFullInfo(packages: PackageListing[]): Promise Date: Tue, 8 Sep 2026 16:41:19 +0200 Subject: [PATCH 10/16] fix(automation-utils): validate version bumps and bump wrapped widgets in lockstep --- automation/utils/bin/rui-bump-version.ts | 79 ++++++++++++++---------- automation/utils/src/bump-version.ts | 54 +++++++++++----- automation/utils/src/version.ts | 18 +++++- 3 files changed, 102 insertions(+), 49 deletions(-) diff --git a/automation/utils/bin/rui-bump-version.ts b/automation/utils/bin/rui-bump-version.ts index cc587c7169..605140bb86 100755 --- a/automation/utils/bin/rui-bump-version.ts +++ b/automation/utils/bin/rui-bump-version.ts @@ -1,58 +1,73 @@ #!/usr/bin/env ts-node-script -import { resolve } from "path"; -import { bumpPackageJson, bumpXml, getNewVersion } from "../src/bump-version"; -import { getModuleChangelog, getWidgetChangelog } from "../src/changelog-parser"; -import { getPackageInfo } from "../src/package-info"; +import { bumpPackageJson, bumpXml, getNewVersion, 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 { bumpPackageJson(path, version); - try { - await bumpXml(path, version); - return true; - } catch { + + if (!hasPackageXml(path)) { return false; // modules have no package.xml } + + await bumpXml(path, version); + return true; } -async function hasUnreleasedLogs(path: string): Promise { - const info = await getPackageInfo(path); - const changelog = - info.mxpackage.type === "widget" && info.mxpackage.changelogType === "widget" - ? await getWidgetChangelog(path) - : await getModuleChangelog(path, info.mxpackage.name); - return changelog.hasUnreleasedLogs(); +function shortName(npmPackageName: string): string { + return npmPackageName.replace(/^@mendix\//, ""); +} + +function resolveVersion(bumpType: string, previousVersion: string): string { + const version = getNewVersion(bumpType, previousVersion); + + if (!versionRegex.test(version)) { + throw new Error(`'${bumpType}' is not a bump type (patch|minor|major) nor a valid version number`); + } + + if (!Version.fromString(version).isGreaterThan(Version.fromString(previousVersion))) { + throw new Error(`Version '${version}' is not greater than the current version '${previousVersion}'`); + } + + return version; } async function main(): Promise { - const bumpType = process.argv[2]; + const npmPackageName = process.argv[2]; + const bumpType = process.argv[3]; - if (!bumpType) { + if (!npmPackageName || !bumpType) { throw new Error( - "Usage: rui-bump-version \nRun from inside the widget/module directory." + "Usage: rui-bump-version \nExample: rui-bump-version @mendix/combobox-web patch" ); } - const path = process.cwd(); + 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(); - const version = getNewVersion(bumpType, previousVersion); + const version = resolveVersion(bumpType, previousVersion); const xmlBumped = await bumpPackage(path, version); - const bumpedPackages = [info.mxpackage.name]; + const bumpedPackages = [shortName(info.name)]; const changedPaths = [path]; - if (info.mxpackage.type === "module") { - for (const dependencyName of info.mxpackage.dependencies) { - const widgetFolder = dependencyName.replace(/^@mendix\//, ""); - const widgetPath = resolve(path, "..", "..", "pluggableWidgets", widgetFolder); - - if (await hasUnreleasedLogs(widgetPath)) { - await bumpPackage(widgetPath, version); - bumpedPackages.push(widgetFolder); - changedPaths.push(widgetPath); - } - } + // 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(shortName(dependencyName)); + changedPaths.push(dependencyPath); } console.log(JSON.stringify({ previousVersion, version, xmlBumped, bumpedPackages, changedPaths })); diff --git a/automation/utils/src/bump-version.ts b/automation/utils/src/bump-version.ts index 3b80f2c4af..71d0e11788 100644 --- a/automation/utils/src/bump-version.ts +++ b/automation/utils/src/bump-version.ts @@ -1,5 +1,5 @@ import { spawnSync } from "child_process"; -import { promises as fs } from "fs"; +import { existsSync, promises as fs, readFileSync } from "fs"; import { join } from "path"; import { nextTick } from "process"; import chalk from "chalk"; @@ -22,35 +22,57 @@ export function getNewVersion(bumpVersionType: BumpVersionType, currentVersion: } } +export function packageXmlPath(path: string): string { + return join(path, "src", "package.xml"); +} + +export function hasPackageXml(path: string): boolean { + return existsSync(packageXmlPath(path)); +} + +/** + * `pnpm version` reports failures (invalid or unchanged version) on stderr and + * leaves the file alone, so the result is read back rather than trusted. + */ export function bumpPackageJson(path: string, version: string): void { - spawnSync("pnpm", ["version", version], { cwd: path }); + const packageJsonFile = join(path, "package.json"); + const result = spawnSync("pnpm", ["version", version], { cwd: path, encoding: "utf8" }); + const written = JSON.parse(readFileSync(packageJsonFile, "utf8")).version; + + if (written !== version) { + throw new Error( + `Failed to set version '${version}' in ${packageJsonFile}, it is still '${written}'. ${( + result.stderr ?? "" + ).trim()}` + ); + } } export async function bumpXml(path: string, version: string): Promise { - const packageXmlFile = join(path, "src", "package.xml"); - try { - const content = await fs.readFile(packageXmlFile); - if (content) { - const newContent = content.toString().replace(/version=.+xmlns/, `version="${version}" xmlns`); - await fs.writeFile(packageXmlFile, newContent); - return true; - } - return false; - } catch (e) { - throw new Error("package.xml not found"); + const packageXmlFile = packageXmlPath(path); + + if (!hasPackageXml(path)) { + throw new Error(`package.xml not found at ${packageXmlFile}`); } + + const content = await fs.readFile(packageXmlFile); + const newContent = content.toString().replace(/version=.+xmlns/, `version="${version}" xmlns`); + await fs.writeFile(packageXmlFile, newContent); + return true; } export async function writeVersion(pkg: PackageListing, version: string): Promise { bumpPackageJson(pkg.path, version); - try { - await bumpXml(pkg.path, version); - } catch { + + if (!hasPackageXml(pkg.path)) { nextTick(() => { const msg = `[WARN] Update version: package ${pkg.name} is missing package.xml, skip`; console.warn(chalk.yellow(msg)); }); + return; } + + await bumpXml(pkg.path, version); } export async function selectBumpVersionType(currentVersion: string): Promise { diff --git a/automation/utils/src/version.ts b/automation/utils/src/version.ts index ed0edcc0b6..e0d1dbe7ff 100644 --- a/automation/utils/src/version.ts +++ b/automation/utils/src/version.ts @@ -32,7 +32,7 @@ export class Version { } bumpMajor(): Version { - return new Version(this.major, this.minor + 1, 0, undefined); + return new Version(this.major + 1, 0, 0, undefined); } format(withBuild = false): string { @@ -45,6 +45,22 @@ export class Version { return withBuild ? `${v}.${this.build}` : v; } + isGreaterThan(anotherVersion: Version): boolean { + const parts = [ + [this.major, anotherVersion.major], + [this.minor, anotherVersion.minor], + [this.patch, anotherVersion.patch] + ]; + + for (const [own, other] of parts) { + if (own !== other) { + return own > other; + } + } + + return false; + } + equals(anotherVersion: Version): boolean { return ( this.major === anotherVersion.major && From 163ea13eee2dd18d5e003b84131991ac6931cb15 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:21 +0200 Subject: [PATCH 11/16] fix(automation-utils): name SBOM zip after the real MPK hash --- automation/utils/bin/rui-generate-oss-sbom.ts | 14 ++++++---- automation/utils/bin/rui-oss-clearance.ts | 19 ++++--------- automation/utils/src/oss-clearance.ts | 27 +++++++++++++++++++ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/automation/utils/bin/rui-generate-oss-sbom.ts b/automation/utils/bin/rui-generate-oss-sbom.ts index 714fe1154b..017612f0d5 100755 --- a/automation/utils/bin/rui-generate-oss-sbom.ts +++ b/automation/utils/bin/rui-generate-oss-sbom.ts @@ -3,7 +3,11 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { gh } from "../src/github"; -import { createSBomGeneratorFolderStructure, generateSBomArtifactsInFolder } from "../src/oss-clearance"; +import { + createSBomGeneratorFolderStructure, + generateSBomArtifactsInFolder, + verifyAssetDigest +} from "../src/oss-clearance"; async function main(): Promise { const releaseTag = process.argv[2]; @@ -22,21 +26,21 @@ async function main(): Promise { } const releaseName = release.name; - const assets = await gh.listReleaseAssets(release.id); - const mpk = assets.find(a => a.name.endsWith(".mpk")); + const mpk = release.assets.find(asset => asset.name.endsWith(".mpk")); if (!mpk) { throw new Error(`No .mpk asset found on release '${releaseTag}'`); } const [tmpFolder, downloadPath] = await createSBomGeneratorFolderStructure(releaseName); await gh.downloadReleaseAsset(mpk.id, downloadPath); + const fileHash = await verifyAssetDigest(mpk, downloadPath); const generatorJar = process.env.SBOM_GENERATOR_JAR ?? join(homedir(), "SBOM_Generator.jar"); - const finalPath = join(homedir(), "Downloads", `${releaseName} [pending-hash].zip`); + const finalPath = join(homedir(), "Downloads", `${releaseName} [${fileHash}].zip`); await generateSBomArtifactsInFolder(tmpFolder, generatorJar, releaseName, finalPath); - console.log(JSON.stringify({ path: finalPath })); + console.log(JSON.stringify({ path: finalPath, mpk: mpk.name, sha256: fileHash })); } main().catch(error => { diff --git a/automation/utils/bin/rui-oss-clearance.ts b/automation/utils/bin/rui-oss-clearance.ts index 5058053c59..111638cb94 100755 --- a/automation/utils/bin/rui-oss-clearance.ts +++ b/automation/utils/bin/rui-oss-clearance.ts @@ -3,15 +3,13 @@ // Enable quiet mode for fetch calls to reduce logging noise process.env.FETCH_QUIET = "true"; -import { gh, GitHubDraftRelease, GitHubReleaseAsset } from "../src/github"; +import { homedir } from "node:os"; import { basename, join } from "path"; -import { prompt } from "enquirer"; import chalk from "chalk"; -import { createReadStream } from "node:fs"; -import * as crypto from "crypto"; -import { pipeline } from "stream/promises"; -import { homedir } from "node:os"; +import { prompt } from "enquirer"; +import { gh, GitHubDraftRelease, GitHubReleaseAsset } from "../src/github"; import { + computeSha256, createSBomGeneratorFolderStructure, findAllReadmeOssLocally, generateSBomArtifactsInFolder, @@ -185,7 +183,7 @@ async function downloadAndVerifyAsset(mpkAsset: GitHubReleaseAsset, downloadPath printProgressCheck("Download completed"); printProgress("Computing SHA-256 hash..."); - const fileHash = await computeHash(downloadPath); + const fileHash = await computeSha256(downloadPath); printProgressCheck(`Computed hash: ${fileHash}`); const expectedDigest = mpkAsset.digest.replace("sha256:", ""); @@ -214,13 +212,6 @@ async function runSbomGenerator(tmpFolder: string, releaseName: string, fileHash return finalPath; } -async function computeHash(filepath: string): Promise { - const input = createReadStream(filepath); - const hash = crypto.createHash("sha256"); - await pipeline(input, hash); - return hash.digest("hex"); -} - // ============================================================================ // Command Handlers // ============================================================================ diff --git a/automation/utils/src/oss-clearance.ts b/automation/utils/src/oss-clearance.ts index e85480296a..4731289f7c 100644 --- a/automation/utils/src/oss-clearance.ts +++ b/automation/utils/src/oss-clearance.ts @@ -1,9 +1,36 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; import { mkdtemp, stat } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; +import { pipeline } from "node:stream/promises"; import { basename, join, parse } from "path"; import { globSync } from "glob"; +import { GitHubReleaseAsset } from "./github"; import { chmod, cp, exec, mkdir, mv, rm, unzip, zip } from "./shell"; +export async function computeSha256(filePath: string): Promise { + const hash = createHash("sha256"); + await pipeline(createReadStream(filePath), hash); + return hash.digest("hex"); +} + +/** + * The OSS clearance artifacts are named after the hash of the scanned MPK, so + * the downloaded file has to be the exact one GitHub reports. + */ +export async function verifyAssetDigest(asset: GitHubReleaseAsset, downloadedPath: string): Promise { + const fileHash = await computeSha256(downloadedPath); + const expectedDigest = asset.digest?.replace("sha256:", ""); + + if (expectedDigest && fileHash !== expectedDigest) { + throw new Error( + `Asset integrity check failed for '${asset.name}': expected ${expectedDigest}, got ${fileHash}` + ); + } + + return fileHash; +} + export function findAllReadmeOssLocally(): string[] { const readmeossPattern = join("**", `*__*__READMEOSS_*.html`); const path1 = join(homedir(), "Downloads"); From 907336fd1c6320f893da09e659df4f916d169b0c Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:23 +0200 Subject: [PATCH 12/16] fix(automation-utils): report existing READMEOSS asset instead of failing --- automation/utils/bin/rui-upload-readme-oss.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/automation/utils/bin/rui-upload-readme-oss.ts b/automation/utils/bin/rui-upload-readme-oss.ts index ba92e9f5bf..a9cc0a8a51 100755 --- a/automation/utils/bin/rui-upload-readme-oss.ts +++ b/automation/utils/bin/rui-upload-readme-oss.ts @@ -2,7 +2,7 @@ import { basename } from "node:path"; import { gh } from "../src/github"; -import { findAllReadmeOssLocally, getRecommendedReadmeOss } from "../src/oss-clearance"; +import { findAllReadmeOssLocally, getRecommendedReadmeOss, hasReadmeOssInAssets } from "../src/oss-clearance"; async function main(): Promise { const releaseTag = process.argv[2]; @@ -21,6 +21,14 @@ async function main(): Promise { throw new Error(`No GitHub release found for tag '${releaseTag}'`); } + // Uploading a name that is already attached fails with a 422, so a re-run of + // this step reports the existing asset instead of trying again. + const attached = release.assets.filter(asset => hasReadmeOssInAssets([asset.name])); + if (attached.length > 0) { + console.log(JSON.stringify({ uploaded: attached[0].name, status: "exists" })); + return; + } + const readmePath = explicitPath ?? getRecommendedReadmeOss(release.name, findAllReadmeOssLocally()); if (!readmePath) { throw new Error( @@ -29,7 +37,7 @@ async function main(): Promise { } const asset = await gh.uploadReleaseAsset(release.id, readmePath, basename(readmePath)); - console.log(JSON.stringify({ uploaded: asset.name })); + console.log(JSON.stringify({ uploaded: asset.name, status: "created" })); } main().catch(error => { From 8e388a74f63f7f17fb9b7e743a957d1feccc9cf9 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:24 +0200 Subject: [PATCH 13/16] docs(automation-utils): correct rui-create-jira-version exit code comment --- automation/utils/bin/rui-create-jira-version.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/automation/utils/bin/rui-create-jira-version.ts b/automation/utils/bin/rui-create-jira-version.ts index 337af06f19..e46e322189 100755 --- a/automation/utils/bin/rui-create-jira-version.ts +++ b/automation/utils/bin/rui-create-jira-version.ts @@ -3,8 +3,9 @@ import { Jira } from "../src/jira"; /** - * Jira version creation has historically 404'd transiently and must never - * block a release, so this always exits 0 and reports status via stdout JSON. + * Jira version creation has historically 404'd transiently and must never block + * a release, so a missing token or any API failure is reported as `skipped` on + * stdout with exit code 0. Only a usage error (no version name) exits non-zero. */ async function main(): Promise { const versionName = process.argv[2]; From bc2e579dfb630a26c549994fbe13b0842839c776 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:26 +0200 Subject: [PATCH 14/16] docs(release-widget): align skill with helper behaviour and trim prose --- .agents/skills/release-widget/SKILL.md | 110 ++++++++++++++----------- 1 file changed, 64 insertions(+), 46 deletions(-) diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index 08b4faa7c2..49329b50e2 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -11,7 +11,7 @@ Releases a widget (or the module wrapping it) from this monorepo: version bump **Autonomy carve-out (this skill only):** unlike the repo's default stance of never pushing/publishing without asking, this skill is pre-authorized to run `git push`, `gh workflow run`, `gh pr merge`, and `gh release edit --draft=false` (publish) directly, without pausing for confirmation on each one — because a human already invoked this skill specifically to run a release. This does **not** extend to destructive rollback (deleting releases/tags/branches) or anything outside this skill's scope. -**State is re-derived every run.** There is no persisted release-state file. Each invocation re-checks git/GitHub/Jira/Marketplace reality from scratch — safe to stop and resume this skill across sessions (e.g. while waiting days for OSS clearance). +**State is re-derived every run.** No persisted release-state file: each invocation re-checks git/GitHub/Jira/Marketplace from scratch, so this skill is safe to stop and resume across sessions (e.g. while waiting days for OSS clearance). ## Prerequisites @@ -32,65 +32,85 @@ cd packages/pluggableWidgets/ pnpm exec rui-package-info ``` -Prints `{"name", "version", "appNumber"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. Keep this `info` around — Phase 2 and 3 reuse it, no need to re-fetch. +Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. -- `appNumber` is a positive number → **standalone release** (a widget, or a module that is itself published directly). `$RELEASE_PATH` = the directory you just `cd`-ed into — `packages/pluggableWidgets/` or `packages/modules/`. +`appName` is the Marketplace display name (e.g. `Maps`). The draft release is titled ` v`, which is what the OSS helpers match SBOM/READMEOSS filenames against — they derive it from the tag themselves. + +- `appNumber` is a positive number → **standalone release** (a widget, or a module published directly). Keep this `info`, Phase 2 and 3 reuse it — no re-fetching. - `appNumber` is `null`/absent/`-1` → widget is module-wrapped, not published on its own. Find the owning module: ```bash grep -l "\"@mendix/\"" packages/modules/*/package.json ``` - That module's directory is `$RELEASE_PATH`. Tell the user which module wraps it. If no module found, stop — this is a misconfigured package, not something to guess through. + No module found → stop, the package is misconfigured; don't guess through it. Otherwise re-run from the module's directory: + ```bash + cd packages/modules/ + pnpm exec rui-package-info + ``` + The module's `info` is the release target from here on; the widget's was only needed to find it. Tell the user which module wraps it. -Three placeholders recur through the rest of this skill, all derived once here from `$RELEASE_PATH`/`info` — never guessed, never reconstructed later: +Three placeholders recur below, all derived from the release target's `info` — never guessed: -- `` — the folder name of `$RELEASE_PATH` (e.g. `combobox-web`, `data-widgets`). Used in commit messages. -- `` — `info.name` (e.g. `@mendix/combobox-web`). Used only for the `CreateGitHubRelease.yml` workflow's `package` input, which specifically wants the literal `package.json` `name` field, not the folder name. -- `` — `` + `-v` + ``, assembled once `` is confirmed in Phase 2. This is the single identifier for the release: the GitHub release tag, the `tmp/` branch name, and the Jira version string all reuse it verbatim. +- `` — `info.name` (e.g. `@mendix/data-widgets`). Pass to `rui-changelog`, `rui-bump-version`, and `CreateGitHubRelease.yml`'s `package` input. +- `` — `` minus the `@mendix/` prefix, not a folder name. Used in commit messages, branch names, tags. +- `` — `-v`, assembled once Phase 2 confirms ``. Reused verbatim as the GitHub release tag, the `tmp/` branch, and the 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 2>&1 +echo "== SBOM jar =="; ls "${SBOM_GENERATOR_JAR:-$HOME/SBOM_Generator.jar}" 2>&1 +echo "== rui helpers =="; pnpm exec which rui-package-info 2>&1 | tail -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; git rev-list HEAD..origin/main --count; git rev-list origin/main..HEAD --count +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`) rather than asking, unless `main` has diverged from `origin/main` (both ahead and behind) — that needs a human decision, stop and ask. +If not on `main` or not in sync — fix it yourself (`git checkout main`, `git merge --ff-only origin/main`) rather than asking, unless `main` has diverged from `origin/main` (both `behind` and `ahead` non-zero) — that needs a human decision, stop and ask. -If the SBOM jar is missing, tell the user exactly what's missing and how to fix it (where to get `SBOM_Generator.jar`) — don't proceed past a missing prereq. +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 past a missing prereq. + +If a helper doesn't resolve (`Command "rui-package-info" not found`), the bins aren't linked yet — `pnpm install` at the repo root, then re-check. Don't work around it by calling `ts-node bin/.ts` all run. ### Phase 2 — Version selection Read the unreleased changelog using the packaged CLI helper. Current version is already known from Phase 0's `rui-package-info` output — don't re-run it: ```bash -cd $RELEASE_PATH -pnpm exec rui-changelog +pnpm exec rui-changelog ``` -`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. For a module, `subcomponents` is which wrapped widgets have unreleased entries — informational here; Phase 3's `rui-bump-version` re-derives the same thing itself, no need to pass it along. +`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. + +- For a **widget**, the content is in `sections` and `subcomponents` is empty. +- For a **module**, it's usually the other way round: module changelogs record entries per wrapped widget, so `sections` is often empty and everything real lives in `subcomponents[].sections`. Read those too — a module with `sections: []` is not "nothing to release". `hasUnreleasedLogs` accounts for both. -Summarize the unreleased entries by type (Fixed/Added/Changed/Breaking changes) and propose a semver bump: +Summarize the 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**. -Compute the concrete `` this bump produces (current version from Phase 0 + bump type) and show it, not just the bump-type word — e.g. "propose **minor**: 2.9.0 → 2.10.0". Ask the user to confirm or override that `` — this is the one decision in the pipeline that's inherently a judgment call, always ask. If the user picks something inconsistent with changelog content (e.g. patch despite a breaking-changes note), flag the mismatch once, then respect their choice. The `` confirmed here is final — Phase 3 bumps to it directly, it is not recomputed later. +Show the concrete ``, 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; this is the pipeline's one judgment call. 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 `` confirmed in Phase 2, using the packaged CLI helper. Pass the explicit version, not the bump-type word — the word was only needed to _propose_ `` in Phase 2, it's a resolved value by now: +Bump to the `` confirmed in Phase 2. Pass the explicit version, not the bump-type word: ```bash -cd $RELEASE_PATH -pnpm exec rui-bump-version +pnpm exec rui-bump-version ``` -Prints `{"previousVersion", "version", "xmlBumped", "bumpedPackages", "changedPaths"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. **For a module release, this already bumps every wrapped widget that has unreleased changelog entries** — there's no separate loop to run here. `bumpedPackages`/`changedPaths` list everything actually touched (the module/widget itself, plus any wrapped widgets bumped alongside it for a module release) — use `changedPaths` directly in the `git add` below rather than reconstructing the list. +Prints `{"previousVersion", "version", "xmlBumped", "bumpedPackages", "changedPaths"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. + +It refuses to run and exits non-zero when: + +- `` isn't independently releasable (no positive `marketplace.appNumber`) — Phase 0 pointed at the wrong package, go back and recheck. +- the argument is neither a bump type nor an `x.y.z` version. +- the resulting version isn't greater than `previousVersion` (catches a typo'd downgrade or re-bumping an already-bumped package). + +**If the target wraps other packages (a module, or a widget like `charts-web` with sub-widgets), this bumps every wrapped dependency to the same version** — all of them, not only those with unreleased changelog entries, since they ship inside the same MPK. Use `changedPaths` verbatim in the `git add` below rather than reconstructing the list. Then, directly (no wizard): @@ -101,17 +121,17 @@ git commit -m "chore(): bump version to " git push -u origin tmp/ ``` -If the branch already exists locally or on remote, stop and ask — don't guess a random suffix, that was a wizard fallback for unattended use, not something to do silently on someone's behalf. +If the branch already exists locally or on remote, stop and ask — don't guess a suffix. -**Jira version** — the CLI checks for an existing version before creating one (safe to re-run) and always exits 0, reporting status via JSON rather than blocking the release: +**Jira version** — safe to re-run, and always exits 0 so it can't block the release: ```bash pnpm exec rui-create-jira-version "" ``` -Prints `{"status": "created"|"exists"|"skipped", ...}`. `skipped` covers both a missing `JIRA_API_TOKEN` and a failed API call (this has historically 404'd transiently) — not a blocker either way. +Prints `{"status": "created"|"exists"|"skipped", ...}`. `skipped` covers both a missing `JIRA_API_TOKEN` and a failed API call — not a blocker either way. -Trigger the GitHub release workflow directly, passing `` as defined in Phase 0 (`info.name`, e.g. `@mendix/combobox-web` — the workflow's `package` input wants the literal `package.json` name, not ``): +Trigger the GitHub release workflow directly: ```bash gh workflow run "CreateGitHubRelease.yml" --ref "tmp/" -f package= @@ -120,10 +140,12 @@ gh workflow run "CreateGitHubRelease.yml" --ref "tmp/" -f package=< Poll for completion: ```bash -gh run list --workflow="CreateGitHubRelease.yml" -L 1 --json databaseId,status,conclusion +gh run list --workflow="CreateGitHubRelease.yml" --branch "tmp/" -L 1 --json databaseId,status,conclusion gh run view --json status,conclusion,url ``` +Keep `--branch`: without it, `-L 1` returns the newest run on _any_ branch, so a colleague's concurrent release gets reported as this one. + 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) @@ -134,7 +156,9 @@ Download the MPK from the draft release and generate the SBOM zip via the packag pnpm exec rui-generate-oss-sbom "" ``` -Prints `{"path": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. +Prints `{"path": "", "mpk": "", "sha256": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. + +The zip is named ` v [].zip`. The OSS team keys their reply off that name — don't rename it. Works on the **draft** release, so nothing needs publishing first. **Submission is manual** — the OSS clearance request now goes through the OSS clearance portal (a Mendix app, log in with Mendix credentials), not email. Tell the user: @@ -152,7 +176,7 @@ Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper pnpm exec rui-upload-readme-oss "" ``` -Prints `{"uploaded": ""}`. If it errors with no match found, ask the user where the file was saved and pass that path as a 2nd argument: `rui-upload-readme-oss "" ""`. +Prints `{"uploaded": "", "status": "created"|"exists"}`. `exists` means a READMEOSS asset was already attached and nothing was re-uploaded — safe to re-run this phase, GitHub rejects a duplicate asset name with a 422 otherwise. If it errors with no match found, ask the user where the file was saved and pass that path as a 2nd argument: `rui-upload-readme-oss "" ""`. ### Phase 6 — Asset gate + publish (GATE — do not skip) @@ -180,25 +204,25 @@ gh run list --workflow="Publishes a package to marketplace" -L 5 --json database Find the run matching this tag/branch. -- `conclusion: success` → the workflow succeeded, but that only means the API call didn't error — it's not proof the version is live. `createDraft`/`publishDraft` are write-only (no idempotency or read-back check), so confirm with a read: call the `marketplace-mcp` MCP server's `get_content_versions` tool with `contentId` = `appNumber` (from Phase 0's `rui-package-info` output), and check `` appears among the returned versions. If `marketplace-mcp` isn't connected (e.g. missing `MARKETPLACE_API_TOKEN`) or the call errors, fall back to asking the user to open Marketplace → package page → Manage Versions and check manually. Don't declare the release done until one of these two confirms it. +- `conclusion: success` → means the API call didn't error, not that the version is live (`createDraft`/`publishDraft` are write-only, no read-back). Confirm with a read: `marketplace-mcp`'s `get_content_versions` with `contentId` = `appNumber` from Phase 0, and check `` is listed. If `marketplace-mcp` isn't connected or errors, ask the user to check Marketplace → package page → Manage Versions. Don't declare the release done until one of the two confirms it. - Once confirmed, merge the changelog PR (this repo's automation should trigger this, but verify): + Then verify the changelog PR merged (repo automation should have done it): ```bash gh pr list --head "tmp/" --json number,state ``` - If still open and unmerged after a successful publish, that's unexpected — check whether the workflow's own `merge-changelogs-pr` step ran, don't just merge it yourself without checking why it didn't auto-merge. + Still open after a successful publish is unexpected — check whether the workflow's `merge-changelogs-pr` step ran before merging it yourself. - `conclusion: failure` → **before assuming stuck-draft or escalating, check history first**: ```bash gh run view --log-failed | grep -A3 "Response status Code" ``` If it's a `409` on `POST .../packages//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 a prior run for the same tag succeeded, the 409 on this run means **the version is already published** — not a real failure. Report that, don't escalate, don't retry, don't teardown. - 2. If no prior success exists for this tag: this is the same failure mode from the last incident (real backend conflict, not caused by our script — `createDraft()` has no idempotency check, so a 409 here is either a genuine stuck server-side state or a double-trigger — check `gh run list` for more than one run created within seconds of each other for the same tag, which would indicate a double-trigger). - 3. Only after ruling out (1) and confirming a real conflict: this is an exceptional situation, don't act unilaterally — report the exact escalation details (appNumber, tag, endpoint, error) and ask the user which way to go: (a) dig further into the failed run's logs together (e.g. check the Marketplace UI for stuck drafts — navigation: Marketplace → package page → Manage Versions → search version), or (b) if they know something was just fixed/changed on the Marketplace side, retry now. Don't pick a direction yourself. - 4. Do not blindly `gh run rerun` more than once without new information — 3 identical reruns with no state change, as happened previously, wastes time. Rerun once after the user confirms they've taken an action (deleted a draft, etc.), not speculatively. + 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 one did, the 409 means **the version is already published** — report that, don't escalate, retry, or teardown. + 2. If no prior success: check for two runs created seconds apart for the same tag (double-trigger). Otherwise it's a genuine stuck server-side state, same as the last incident — not caused by our script. + 3. Only then escalate, and don't pick a direction yourself: report appNumber, tag, endpoint, error, and ask whether to (a) dig through the failed run's logs together and check Marketplace → package page → Manage Versions for a stuck draft, or (b) retry, if they know something changed on the Marketplace side. + 4. Never `gh run rerun` speculatively — 3 identical reruns with no state change happened before and changed nothing. Rerun once, after the user confirms they acted (deleted a draft, etc.). ### Phase 8 — Rollback (human-gated, always — carve-out does not apply here) @@ -219,14 +243,8 @@ Teardown list (present all, confirm once, then execute): ## Common Mistakes -- **Writing inline `ts-node -e` scripts instead of using the packaged CLI helpers** — `rui-package-info`, `rui-bump-version`, `rui-create-jira-version`, `rui-generate-oss-sbom`, and `rui-upload-readme-oss` (in `automation/utils/bin/`) already wrap all the release-pipeline logic this skill needs. Never reimplement that logic in an ad-hoc script. -- **Running `rui-package-info` / `rui-bump-version` without `cd`-ing into the widget/module dir first** — they read `process.cwd()`, not a path argument. -- **Treating `appNumber` presence via grep instead of reading the schema** — a module-wrapped widget's package.json simply omits the `marketplace.appNumber` key; check for `null`/undefined/`-1` via `rui-package-info`, don't grep for the string `"appNumber"` (unreliable — the field can exist with value `-1` too, which also means "not independently published"). -- **Publishing before the asset gate passes** — this is the exact mistake pattern that caused the 409 double-trigger risk. Never call `gh release edit --draft=false` without first confirming both MPK and READMEOSS assets are attached. -- **Escalating a 409 without checking run history first** — `PublishMarketplace.yml` fires automatically on `release: published` (Phase 6), but nothing stops a human from also manually re-running it for the same tag (e.g. impatience, or thinking it silently failed) while the automatic run is still in flight or already succeeded. The second run then hits a package that's already published and 409s — a real HTTP error, but not a real incident. Always check `gh run list` history for the tag before treating a 409 as a real incident. -- **Retrying `gh run rerun` speculatively** — reruns without new information (e.g., a deleted draft) just reproduce the same failure. Only rerun after the user confirms they changed something. -- **Running rollback commands without the explicit go-ahead** — this is the one phase where the autonomy carve-out does not apply. Always list and wait for confirmation. - -## Reference Files - -None yet — this skill is new (rebuilt from lost prior version + 2026-07 incident history) and running in a private trial (`.agents/skills/`, untracked) before being proposed for the shared skill set. If patterns emerge from real runs (new failure modes, widget-specific quirks), add them here rather than growing the phases above indefinitely. +- **Writing inline `ts-node -e` scripts instead of using the packaged CLI helpers** — `rui-package-info`, `rui-changelog`, `rui-bump-version`, `rui-create-jira-version`, `rui-generate-oss-sbom`, `rui-upload-readme-oss` (in `automation/utils/bin/`) already wrap every bit of release logic this skill needs. Never reimplement it ad hoc. +- **Working around a helper's refusal instead of fixing the input** — a refusal ("no positive marketplace.appNumber", "not greater than the current version") means the wrong package or version reached it. 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. This is what created the 409 double-trigger risk. +- **Escalating a 409 without checking run history first** — `PublishMarketplace.yml` fires automatically on `release: published`, but a human may also have re-run it manually for the same tag. The second run 409s on an already-published package: a real HTTP error, not a real incident. Check `gh run list` for the tag first. +- **Running rollback commands without the explicit go-ahead** — the one phase where the carve-out doesn't apply. List, then wait. From 43384efbf73f3b3b22c1ad268ff6afdc124afd2d Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Wed, 9 Sep 2026 14:27:11 +0200 Subject: [PATCH 15/16] fix(release-widget): address PR review feedback on release skill and helpers - skill: user merges the changelog PR, not the agent (needs team approvals) - skill: drop gh pr merge from the autonomy carve-out - skill: clarify why a successful publish call doesn't guarantee the version is live - skill: check the public Marketplace listing instead of the admin panel - skill: find a wrapped widget's owner in pluggableWidgets too, not just modules - skill: correct which phases reuse appNumber vs npm-package-name - skill: drop the unnecessary rui-helpers-not-linked check - rui-bump-version: accept only an explicit x.y.z version, no bump-type shorthand - rui-bump-version: keep the @mendix/ scope in bumpedPackages output - bump-version: getNewVersion now uses Version's bump methods instead of manual math - rui-changelog: read each wrapped widget's own CHANGELOG.md for module unreleased work --- .agents/skills/release-widget/SKILL.md | 27 ++++++++--------- automation/utils/bin/rui-bump-version.ts | 26 ++++++----------- automation/utils/bin/rui-changelog.ts | 29 ++++++++++++++++--- automation/utils/src/bump-version.ts | 9 +++--- .../utils/src/changelog-parser/index.ts | 5 +--- 5 files changed, 52 insertions(+), 44 deletions(-) diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index 49329b50e2..90c389a0ef 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -9,7 +9,7 @@ description: Use when releasing a standalone Mendix widget or module from the we Releases a widget (or the module wrapping it) from this monorepo: version bump → GitHub draft release → OSS clearance → Marketplace publish. -**Autonomy carve-out (this skill only):** unlike the repo's default stance of never pushing/publishing without asking, this skill is pre-authorized to run `git push`, `gh workflow run`, `gh pr merge`, and `gh release edit --draft=false` (publish) directly, without pausing for confirmation on each one — because a human already invoked this skill specifically to run a release. This does **not** extend to destructive rollback (deleting releases/tags/branches) or anything outside this skill's scope. +**Autonomy carve-out (this skill only):** unlike the repo's default stance of never pushing/publishing without asking, this skill is pre-authorized to run `git push`, `gh workflow run`, and `gh release edit --draft=false` (publish) directly, without pausing for confirmation on each one — because a human already invoked this skill specifically to run a release. This does **not** extend to destructive rollback (deleting releases/tags/branches), merging PRs (branch protection requires team approvals — that's on the user to gather), or anything outside this skill's scope. **State is re-derived every run.** No persisted release-state file: each invocation re-checks git/GitHub/Jira/Marketplace from scratch, so this skill is safe to stop and resume across sessions (e.g. while waiting days for OSS clearance). @@ -36,17 +36,17 @@ Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` `appName` is the Marketplace display name (e.g. `Maps`). The draft release is titled ` v`, which is what the OSS helpers match SBOM/READMEOSS filenames against — they derive it from the tag themselves. -- `appNumber` is a positive number → **standalone release** (a widget, or a module published directly). Keep this `info`, Phase 2 and 3 reuse it — no re-fetching. -- `appNumber` is `null`/absent/`-1` → widget is module-wrapped, not published on its own. Find the owning module: +- `appNumber` is a positive number → **standalone release** (a widget, or a module published directly). Keep this `info` — Phase 2 and 3 reuse `` from it (no re-fetching), and Phase 7 reuses `appNumber` itself. +- `appNumber` is `null`/absent/`-1` → widget is wrapped by another package, not published on its own. Find the owner — it's usually a module, but a widget like `charts-web` also wraps sub-widgets (e.g. `area-chart-web`) directly, so check both locations: ```bash - grep -l "\"@mendix/\"" packages/modules/*/package.json + grep -l "\"@mendix/\"" packages/modules/*/package.json packages/pluggableWidgets/*/package.json ``` - No module found → stop, the package is misconfigured; don't guess through it. Otherwise re-run from the module's directory: + No owner found → stop, the package is misconfigured; don't guess through it. Otherwise re-run from the owner's directory: ```bash - cd packages/modules/ + cd packages/modules/ # or packages/pluggableWidgets/ pnpm exec rui-package-info ``` - The module's `info` is the release target from here on; the widget's was only needed to find it. Tell the user which module wraps it. + The owner's `info` is the release target from here on; the original widget's was only needed to find it. Tell the user which module or widget wraps it. Three placeholders recur below, all derived from the release target's `info` — never guessed: @@ -60,7 +60,6 @@ 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 "== rui helpers =="; pnpm exec which rui-package-info 2>&1 | tail -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 @@ -71,8 +70,6 @@ If not on `main` or not in sync — fix it yourself (`git checkout main`, `git m 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 past a missing prereq. -If a helper doesn't resolve (`Command "rui-package-info" not found`), the bins aren't linked yet — `pnpm install` at the repo root, then re-check. Don't work around it by calling `ts-node bin/.ts` all run. - ### Phase 2 — Version selection Read the unreleased changelog using the packaged CLI helper. Current version is already known from Phase 0's `rui-package-info` output — don't re-run it: @@ -84,7 +81,7 @@ pnpm exec rui-changelog `rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. - For a **widget**, the content is in `sections` and `subcomponents` is empty. -- For a **module**, it's usually the other way round: module changelogs record entries per wrapped widget, so `sections` is often empty and everything real lives in `subcomponents[].sections`. Read those too — a module with `sections: []` is not "nothing to release". `hasUnreleasedLogs` accounts for both. +- For a **module**, it's usually the other way round: a module's own CHANGELOG.md rarely has module-level unreleased entries — real unreleased work sits in each wrapped widget's own CHANGELOG.md. `rui-changelog` reads those for you and surfaces them as `subcomponents[].sections`. Read those too — a module with `sections: []` is not "nothing to release". `hasUnreleasedLogs` accounts for both. Summarize the 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: @@ -96,7 +93,7 @@ Show the concrete ``, not just the bump-type word — e.g. "propose **m ### Phase 3 — Version bump + release branch (autonomous) -Bump to the `` confirmed in Phase 2. Pass the explicit version, not the bump-type word: +Bump to the `` confirmed in Phase 2. The helper only accepts an explicit `x.y.z` version — it has no bump-type shorthand: ```bash pnpm exec rui-bump-version @@ -107,7 +104,7 @@ Prints `{"previousVersion", "version", "xmlBumped", "bumpedPackages", "changedPa It refuses to run and exits non-zero when: - `` isn't independently releasable (no positive `marketplace.appNumber`) — Phase 0 pointed at the wrong package, go back and recheck. -- the argument is neither a bump type nor an `x.y.z` version. +- the argument isn't a valid `x.y.z` version. - the resulting version isn't greater than `previousVersion` (catches a typo'd downgrade or re-bumping an already-bumped package). **If the target wraps other packages (a module, or a widget like `charts-web` with sub-widgets), this bumps every wrapped dependency to the same version** — all of them, not only those with unreleased changelog entries, since they ship inside the same MPK. Use `changedPaths` verbatim in the `git add` below rather than reconstructing the list. @@ -204,7 +201,7 @@ gh run list --workflow="Publishes a package to marketplace" -L 5 --json database Find the run matching this tag/branch. -- `conclusion: success` → means the API call didn't error, not that the version is live (`createDraft`/`publishDraft` are write-only, no read-back). Confirm with a read: `marketplace-mcp`'s `get_content_versions` with `contentId` = `appNumber` from Phase 0, and check `` is listed. If `marketplace-mcp` isn't connected or errors, ask the user to check Marketplace → package page → Manage Versions. Don't declare the release done until one of the two confirms it. +- `conclusion: success` → means the API call didn't error, not that the version is live. The workflow calls the Marketplace `createDraft`/`publishDraft` endpoints, which only return an accepted/rejected response for the write — they don't hand back the resulting version state, so a 200 here isn't proof the version is visible yet. Confirm with a read: `marketplace-mcp`'s `get_content_versions` with `contentId` = `appNumber` from Phase 0, and check `` is listed. If `marketplace-mcp` isn't connected or errors, ask the user to check the widget's Marketplace listing page directly for ``. Don't declare the release done until one of the two confirms it. Then verify the changelog PR merged (repo automation should have done it): @@ -212,7 +209,7 @@ Find the run matching this tag/branch. gh pr list --head "tmp/" --json number,state ``` - Still open after a successful publish is unexpected — check whether the workflow's `merge-changelogs-pr` step ran before merging it yourself. + Still open after a successful publish is unexpected — check whether the workflow's `merge-changelogs-pr` step ran. Don't merge it yourself: branch protection requires team approvals, so tell the user the PR is waiting and it's on them to gather approvals and merge. - `conclusion: failure` → **before assuming stuck-draft or escalating, check history first**: ```bash diff --git a/automation/utils/bin/rui-bump-version.ts b/automation/utils/bin/rui-bump-version.ts index 605140bb86..eed3574ed9 100755 --- a/automation/utils/bin/rui-bump-version.ts +++ b/automation/utils/bin/rui-bump-version.ts @@ -1,6 +1,6 @@ #!/usr/bin/env ts-node-script -import { bumpPackageJson, bumpXml, getNewVersion, hasPackageXml } from "../src/bump-version"; +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"; @@ -16,31 +16,23 @@ async function bumpPackage(path: string, version: string): Promise { return true; } -function shortName(npmPackageName: string): string { - return npmPackageName.replace(/^@mendix\//, ""); -} - -function resolveVersion(bumpType: string, previousVersion: string): string { - const version = getNewVersion(bumpType, previousVersion); - +function checkVersion(version: string, previousVersion: string): void { if (!versionRegex.test(version)) { - throw new Error(`'${bumpType}' is not a bump type (patch|minor|major) nor a valid version number`); + 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}'`); } - - return version; } async function main(): Promise { const npmPackageName = process.argv[2]; - const bumpType = process.argv[3]; + const version = process.argv[3]; - if (!npmPackageName || !bumpType) { + if (!npmPackageName || !version) { throw new Error( - "Usage: rui-bump-version \nExample: rui-bump-version @mendix/combobox-web patch" + "Usage: rui-bump-version \nExample: rui-bump-version @mendix/combobox-web 1.2.3" ); } @@ -54,10 +46,10 @@ async function main(): Promise { } const previousVersion = info.version.format(); - const version = resolveVersion(bumpType, previousVersion); + checkVersion(version, previousVersion); const xmlBumped = await bumpPackage(path, version); - const bumpedPackages = [shortName(info.name)]; + const bumpedPackages = [info.name]; const changedPaths = [path]; // Wrapped widgets are released as part of the target and share its version, @@ -66,7 +58,7 @@ async function main(): Promise { const dependencyPath = await resolvePackagePath(dependencyName); await bumpPackage(dependencyPath, version); - bumpedPackages.push(shortName(dependencyName)); + bumpedPackages.push(dependencyName); changedPaths.push(dependencyPath); } diff --git a/automation/utils/bin/rui-changelog.ts b/automation/utils/bin/rui-changelog.ts index 93fb3e5ec5..e2e0ec0159 100644 --- a/automation/utils/bin/rui-changelog.ts +++ b/automation/utils/bin/rui-changelog.ts @@ -1,9 +1,29 @@ #!/usr/bin/env ts-node-script -import { getPackageChangelog } from "../src/changelog-parser"; -import { resolvePackagePath } from "../src/monorepo"; +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 { + 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 { const npmPackageName = process.argv[2]; @@ -23,11 +43,12 @@ async function main(): Promise { 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 ? unreleased.subcomponents : []; + const subcomponents = + "subcomponents" in unreleased ? await getUnreleasedSubcomponents(info.mxpackage.dependencies) : []; console.log( JSON.stringify({ - hasUnreleasedLogs: changelog.hasUnreleasedLogs(), + hasUnreleasedLogs: unreleased.sections.length !== 0 || subcomponents.length !== 0, sections: unreleased.sections, subcomponents }) diff --git a/automation/utils/src/bump-version.ts b/automation/utils/src/bump-version.ts index 71d0e11788..c2a29f7c28 100644 --- a/automation/utils/src/bump-version.ts +++ b/automation/utils/src/bump-version.ts @@ -5,18 +5,19 @@ import { nextTick } from "process"; import chalk from "chalk"; import { prompt } from "enquirer"; import { PackageListing } from "./monorepo"; +import { Version } from "./version"; export type BumpVersionType = "patch" | "minor" | "major" | string; export function getNewVersion(bumpVersionType: BumpVersionType, currentVersion: string): string { - const [major, minor, patch] = currentVersion.split("."); + const version = Version.fromString(currentVersion); switch (bumpVersionType) { case "patch": - return [major, minor, Number(patch) + 1].join("."); + return version.bumpPatch().format(); case "minor": - return [major, Number(minor) + 1, 0].join("."); + return version.bumpMinor().format(); case "major": - return [Number(major) + 1, 0, 0].join("."); + return version.bumpMajor().format(); default: return bumpVersionType; } diff --git a/automation/utils/src/changelog-parser/index.ts b/automation/utils/src/changelog-parser/index.ts index 1dc87d35b4..f3ef086fee 100644 --- a/automation/utils/src/changelog-parser/index.ts +++ b/automation/utils/src/changelog-parser/index.ts @@ -221,10 +221,7 @@ export class ModuleChangelogFileWrapper { } hasUnreleasedLogs(): boolean { - const [unreleased] = this.changelog.content; - // Module changelogs usually carry their entries under subcomponents - // (per wrapped widget), with no module level sections at all. - return unreleased.sections.length !== 0 || unreleased.subcomponents.length !== 0; + return this.changelog.content[0].sections.length !== 0; } moveUnreleasedToVersion(newVersion: Version): ModuleChangelogFileWrapper { From aa15dbec81cca0ba7903660e26ff73f755904391 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Wed, 9 Sep 2026 15:48:15 +0200 Subject: [PATCH 16/16] fix(release-widget): trim SKILL.md and drop dead xmlBumped field --- .agents/skills/release-widget/SKILL.md | 128 +++++++++++------------ automation/utils/bin/rui-bump-version.ts | 9 +- 2 files changed, 65 insertions(+), 72 deletions(-) diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md index 90c389a0ef..d2d9152d5b 100644 --- a/.agents/skills/release-widget/SKILL.md +++ b/.agents/skills/release-widget/SKILL.md @@ -7,52 +7,50 @@ description: Use when releasing a standalone Mendix widget or module from the we ## Overview -Releases a widget (or the module wrapping it) from this monorepo: version bump → GitHub draft release → OSS clearance → Marketplace publish. +Releases a widget (or the module wrapping it): version bump → GitHub draft release → OSS clearance → Marketplace publish. -**Autonomy carve-out (this skill only):** unlike the repo's default stance of never pushing/publishing without asking, this skill is pre-authorized to run `git push`, `gh workflow run`, and `gh release edit --draft=false` (publish) directly, without pausing for confirmation on each one — because a human already invoked this skill specifically to run a release. This does **not** extend to destructive rollback (deleting releases/tags/branches), merging PRs (branch protection requires team approvals — that's on the user to gather), or anything outside this skill's scope. +**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). -**State is re-derived every run.** No persisted release-state file: each invocation re-checks git/GitHub/Jira/Marketplace from scratch, so this skill is safe to stop and resume across sessions (e.g. while waiting days for OSS clearance). +**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** — the widget or module to release, e.g. `combobox-web` or `data-widgets`. If not given, ask: "Which widget or module are you releasing?" +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 (module detection, environment prereqs, version state) — check automatically in Phase 0, don't ask. +Everything else — check automatically in Phase 0, don't ask. ## Workflow ### Phase 0 — Detect release target -Read the widget's marketplace info via the packaged CLI helper (don't grep — the schema is the source of truth): - ```bash cd packages/pluggableWidgets/ pnpm exec rui-package-info ``` -Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. +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`). The draft release is titled ` v`, which is what the OSS helpers match SBOM/READMEOSS filenames against — they derive it from the tag themselves. +`appName` is the Marketplace display name (e.g. `Maps`). Draft release is titled ` v`. -- `appNumber` is a positive number → **standalone release** (a widget, or a module published directly). Keep this `info` — Phase 2 and 3 reuse `` from it (no re-fetching), and Phase 7 reuses `appNumber` itself. -- `appNumber` is `null`/absent/`-1` → widget is wrapped by another package, not published on its own. Find the owner — it's usually a module, but a widget like `charts-web` also wraps sub-widgets (e.g. `area-chart-web`) directly, so check both locations: +- `appNumber` positive → **standalone release**. Keep this `info` — Phase 2/3 reuse `` 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/\"" packages/modules/*/package.json packages/pluggableWidgets/*/package.json ``` - No owner found → stop, the package is misconfigured; don't guess through it. Otherwise re-run from the owner's directory: + No owner found → stop, package is misconfigured. Otherwise re-run from the owner's directory: ```bash cd packages/modules/ # or packages/pluggableWidgets/ pnpm exec rui-package-info ``` - The owner's `info` is the release target from here on; the original widget's was only needed to find it. Tell the user which module or widget wraps it. + Owner's `info` is the release target from here on. Tell the user which module or widget wraps it. -Three placeholders recur below, all derived from the release target's `info` — never guessed: +Placeholders used below, derived from the release target's `info`: -- `` — `info.name` (e.g. `@mendix/data-widgets`). Pass to `rui-changelog`, `rui-bump-version`, and `CreateGitHubRelease.yml`'s `package` input. -- `` — `` minus the `@mendix/` prefix, not a folder name. Used in commit messages, branch names, tags. -- `` — `-v`, assembled once Phase 2 confirms ``. Reused verbatim as the GitHub release tag, the `tmp/` branch, and the Jira version. +- `` — `info.name` (e.g. `@mendix/data-widgets`). Pass to `rui-changelog`, `rui-bump-version`, `CreateGitHubRelease.yml`'s `package` input. +- `` — `` minus `@mendix/` prefix, not a folder name. Used in commit messages, branch names, tags. +- `` — `-v`, assembled once Phase 2 confirms ``. Used as GitHub release tag, `tmp/` branch, and Jira version. ### Phase 1 — Prerequisite check @@ -66,50 +64,50 @@ 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`) rather than asking, unless `main` has diverged from `origin/main` (both `behind` and `ahead` non-zero) — that needs a human decision, stop and ask. +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 past a missing prereq. +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 -Read the unreleased changelog using the packaged CLI helper. Current version is already known from Phase 0's `rui-package-info` output — don't re-run it: +Current version already known from Phase 0 — don't re-run `rui-package-info`. ```bash pnpm exec rui-changelog ``` -`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. +Prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. -- For a **widget**, the content is in `sections` and `subcomponents` is empty. -- For a **module**, it's usually the other way round: a module's own CHANGELOG.md rarely has module-level unreleased entries — real unreleased work sits in each wrapped widget's own CHANGELOG.md. `rui-changelog` reads those for you and surfaces them as `subcomponents[].sections`. Read those too — a module with `sections: []` is not "nothing to release". `hasUnreleasedLogs` accounts for both. +- **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 the 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: +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 ``, 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; this is the pipeline's one judgment call. If their choice contradicts the changelog (patch despite breaking changes), flag it once, then respect it. +Show the concrete ``, 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 `` confirmed in Phase 2. The helper only accepts an explicit `x.y.z` version — it has no bump-type shorthand: +Bump to the `` confirmed in Phase 2: ```bash pnpm exec rui-bump-version ``` -Prints `{"previousVersion", "version", "xmlBumped", "bumpedPackages", "changedPaths"}`. `xmlBumped: false` is expected for modules (no `package.xml`) — not an error. +Prints `{"previousVersion", "version", "bumpedPackages", "changedPaths"}`. -It refuses to run and exits non-zero when: +Refuses to run, exits non-zero when: -- `` isn't independently releasable (no positive `marketplace.appNumber`) — Phase 0 pointed at the wrong package, go back and recheck. -- the argument isn't a valid `x.y.z` version. -- the resulting version isn't greater than `previousVersion` (catches a typo'd downgrade or re-bumping an already-bumped package). +- `` 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 the target wraps other packages (a module, or a widget like `charts-web` with sub-widgets), this bumps every wrapped dependency to the same version** — all of them, not only those with unreleased changelog entries, since they ship inside the same MPK. Use `changedPaths` verbatim in the `git add` below rather than reconstructing the list. +**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, directly (no wizard): +Then: ```bash git checkout -b tmp/ @@ -118,17 +116,17 @@ git commit -m "chore(): bump version to " git push -u origin tmp/ ``` -If the branch already exists locally or on remote, stop and ask — don't guess a suffix. +If the branch already exists locally or on remote, stop and ask. -**Jira version** — safe to re-run, and always exits 0 so it can't block the release: +**Jira version** — safe to re-run, always exits 0: ```bash pnpm exec rui-create-jira-version "" ``` -Prints `{"status": "created"|"exists"|"skipped", ...}`. `skipped` covers both a missing `JIRA_API_TOKEN` and a failed API call — not a blocker either way. +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 directly: +Trigger the GitHub release workflow: ```bash gh workflow run "CreateGitHubRelease.yml" --ref "tmp/" -f package= @@ -141,39 +139,35 @@ gh run list --workflow="CreateGitHubRelease.yml" --branch "tmp/" -L gh run view --json status,conclusion,url ``` -Keep `--branch`: without it, `-L 1` returns the newest run on _any_ branch, so a colleague's concurrent release gets reported as this one. +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) -Download the MPK from the draft release and generate the SBOM zip via the packaged CLI helper — don't use the interactive `oss-clearance` wizard: - ```bash pnpm exec rui-generate-oss-sbom "" ``` -Prints `{"path": "", "mpk": "", "sha256": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. +Prints `{"path": "", "mpk": "", "sha256": ""}`. Generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if elsewhere. -The zip is named ` v [].zip`. The OSS team keys their reply off that name — don't rename it. Works on the **draft** release, so nothing needs publishing first. +Zip is named ` v [].zip` — don't rename it. Works on the **draft** release, no publishing needed first. -**Submission is manual** — the OSS clearance request now goes through the OSS clearance portal (a Mendix app, log in with Mendix credentials), not email. Tell the user: +**Submission is manual** — goes through the OSS clearance portal (Mendix app, Mendix credentials login), not email. Tell the user: -- The zip is ready at the printed path. -- Ask them to submit it via the OSS clearance portal (they know the URL/login flow; don't guess or fetch a URL for this). +- 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)." This wait is inherently unbounded (days) — the skill can be safely re-invoked later; Phase 0–4 will just confirm state is unchanged and skip straight back here. +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) -Once the user has the READMEOSS HTML file, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`): - ```bash pnpm exec rui-upload-readme-oss "" ``` -Prints `{"uploaded": "", "status": "created"|"exists"}`. `exists` means a READMEOSS asset was already attached and nothing was re-uploaded — safe to re-run this phase, GitHub rejects a duplicate asset name with a 422 otherwise. If it errors with no match found, ask the user where the file was saved and pass that path as a 2nd argument: `rui-upload-readme-oss "" ""`. +Prints `{"uploaded": "", "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 "" ""`. ### Phase 6 — Asset gate + publish (GATE — do not skip) @@ -183,15 +177,15 @@ Prints `{"uploaded": "", "status": "created"|"exists"}`. `exists` me gh release view --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 the user explicitly says to publish anyway, comply but state clearly that this is an unverified publish (no asset-gate passed). +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 directly (carve-out applies — this is a forward release action): +Once the gate passes, publish: ```bash gh release edit --draft=false ``` -Publishing triggers `PublishMarketplace.yml` automatically (on `release: published`). Do not also manually re-run the marketplace-publish workflow for the same tag unless the automatic run actually failed — see Phase 7 for how to tell the difference. +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 @@ -201,29 +195,29 @@ gh run list --workflow="Publishes a package to marketplace" -L 5 --json database Find the run matching this tag/branch. -- `conclusion: success` → means the API call didn't error, not that the version is live. The workflow calls the Marketplace `createDraft`/`publishDraft` endpoints, which only return an accepted/rejected response for the write — they don't hand back the resulting version state, so a 200 here isn't proof the version is visible yet. Confirm with a read: `marketplace-mcp`'s `get_content_versions` with `contentId` = `appNumber` from Phase 0, and check `` is listed. If `marketplace-mcp` isn't connected or errors, ask the user to check the widget's Marketplace listing page directly for ``. Don't declare the release done until one of the two confirms it. +- `conclusion: success` → doesn't mean the version is live yet. Confirm: `marketplace-mcp`'s `get_content_versions` with `contentId` = `appNumber` from Phase 0, check `` is listed. If `marketplace-mcp` isn't connected or errors, ask the user to check the widget's Marketplace listing page for ``. Don't declare done until one of the two confirms it. - Then verify the changelog PR merged (repo automation should have done it): + Then verify changelog PR merged: ```bash gh pr list --head "tmp/" --json number,state ``` - Still open after a successful publish is unexpected — check whether the workflow's `merge-changelogs-pr` step ran. Don't merge it yourself: branch protection requires team approvals, so tell the user the PR is waiting and it's on them to gather approvals and merge. + 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` → **before assuming stuck-draft or escalating, check history first**: +- `conclusion: failure` → **check run history before escalating**: ```bash gh run view --log-failed | grep -A3 "Response status Code" ``` - If it's a `409` on `POST .../packages//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 one did, the 409 means **the version is already published** — report that, don't escalate, retry, or teardown. - 2. If no prior success: check for two runs created seconds apart for the same tag (double-trigger). Otherwise it's a genuine stuck server-side state, same as the last incident — not caused by our script. - 3. Only then escalate, and don't pick a direction yourself: report appNumber, tag, endpoint, error, and ask whether to (a) dig through the failed run's logs together and check Marketplace → package page → Manage Versions for a stuck draft, or (b) retry, if they know something changed on the Marketplace side. - 4. Never `gh run rerun` speculatively — 3 identical reruns with no state change happened before and changed nothing. Rerun once, after the user confirms they acted (deleted a draft, etc.). + If `409` on `POST .../packages//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**, regardless of how far the carve-out extends elsewhere in this skill: +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 --json tagName,isDraft,isPrerelease # confirm current state first @@ -240,8 +234,8 @@ Teardown list (present all, confirm once, then execute): ## Common Mistakes -- **Writing inline `ts-node -e` scripts instead of using the packaged CLI helpers** — `rui-package-info`, `rui-changelog`, `rui-bump-version`, `rui-create-jira-version`, `rui-generate-oss-sbom`, `rui-upload-readme-oss` (in `automation/utils/bin/`) already wrap every bit of release logic this skill needs. Never reimplement it ad hoc. -- **Working around a helper's refusal instead of fixing the input** — a refusal ("no positive marketplace.appNumber", "not greater than the current version") means the wrong package or version reached it. 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. This is what created the 409 double-trigger risk. -- **Escalating a 409 without checking run history first** — `PublishMarketplace.yml` fires automatically on `release: published`, but a human may also have re-run it manually for the same tag. The second run 409s on an already-published package: a real HTTP error, not a real incident. Check `gh run list` for the tag first. -- **Running rollback commands without the explicit go-ahead** — the one phase where the carve-out doesn't apply. List, then wait. +- **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. diff --git a/automation/utils/bin/rui-bump-version.ts b/automation/utils/bin/rui-bump-version.ts index eed3574ed9..784d49d107 100755 --- a/automation/utils/bin/rui-bump-version.ts +++ b/automation/utils/bin/rui-bump-version.ts @@ -5,15 +5,14 @@ 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 { +async function bumpPackage(path: string, version: string): Promise { bumpPackageJson(path, version); if (!hasPackageXml(path)) { - return false; // modules have no package.xml + return; // modules have no package.xml } await bumpXml(path, version); - return true; } function checkVersion(version: string, previousVersion: string): void { @@ -48,7 +47,7 @@ async function main(): Promise { const previousVersion = info.version.format(); checkVersion(version, previousVersion); - const xmlBumped = await bumpPackage(path, version); + await bumpPackage(path, version); const bumpedPackages = [info.name]; const changedPaths = [path]; @@ -62,7 +61,7 @@ async function main(): Promise { changedPaths.push(dependencyPath); } - console.log(JSON.stringify({ previousVersion, version, xmlBumped, bumpedPackages, changedPaths })); + console.log(JSON.stringify({ previousVersion, version, bumpedPackages, changedPaths })); } main().catch(error => {