From d628af5785753cc9c6e871559ae126b1b23dbff0 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 3 Aug 2026 14:27:02 +0200 Subject: [PATCH 01/14] 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 adae4af292f8b5684f60b26bdab3f2d4a136ee12 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 21 Aug 2026 18:06:37 +0200 Subject: [PATCH 02/14] 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 83421ce5f142856127ec03aa85a1f6e30a016b1e Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 7 Sep 2026 14:46:27 +0200 Subject: [PATCH 03/14] 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 e896d93eed52dc655444a081abd237123c92fec8 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 7 Sep 2026 16:27:10 +0200 Subject: [PATCH 04/14] 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 fd2129d1aebdc3ffe73a36c5fdd3a2e41834d6d4 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 15:02:59 +0200 Subject: [PATCH 05/14] 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 1ac50b0c1c338d1b5dd74054435802673b7cdeae Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 15:31:38 +0200 Subject: [PATCH 06/14] 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 58508092f6f7f3beac526aeed5fbb9edd208a63a Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:10 +0200 Subject: [PATCH 07/14] 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 b7d006c54eadbad0e994f7fa96c6d07f76f0f015 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:16 +0200 Subject: [PATCH 08/14] 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 6b709d5f927c15c6d97b46293d014919c428ce9e Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:18 +0200 Subject: [PATCH 09/14] 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/14] 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 3e2efd2bea7de07591e9bf745bc663e2ce198b8b Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:21 +0200 Subject: [PATCH 11/14] 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 1bf2eef1bb11469b456066c9aff7acf89058fab3 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:23 +0200 Subject: [PATCH 12/14] 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 16ff6280b30f6c55dfb887afc2e4889301ca549f Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:24 +0200 Subject: [PATCH 13/14] 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 48d9b8a91e0926204839da75d9d1055355941fdb Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 8 Sep 2026 16:41:26 +0200 Subject: [PATCH 14/14] 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.