Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions .agents/skills/release-widget/SKILL.md

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions automation/utils/bin/rui-bump-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env ts-node-script

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<boolean> {
bumpPackageJson(path, version);

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

await bumpXml(path, version);
return true;
}

function shortName(npmPackageName: string): string {
return npmPackageName.replace(/^@mendix\//, "");
}

function resolveVersion(bumpType: string, previousVersion: string): string {
const version = getNewVersion(bumpType, previousVersion);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like getNewVersion already does what Version class is capable of. We should use Version math instead of using manual math done in getNewVersion.


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<void> {
const npmPackageName = process.argv[2];
const bumpType = process.argv[3];

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

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 = resolveVersion(bumpType, previousVersion);

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we strip @mendix here? If we get package name with @mendix, we should also return back the same format.

changedPaths.push(dependencyPath);
}

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

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

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

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

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

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

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

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

console.log(
JSON.stringify({
hasUnreleasedLogs: changelog.hasUnreleasedLogs(),
sections: unreleased.sections,
subcomponents
})
);
}

main().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
50 changes: 50 additions & 0 deletions automation/utils/bin/rui-create-jira-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/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 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<void> {
const versionName = process.argv[2];

if (!versionName) {
throw new Error(
"Usage: rui-create-jira-version <version-name>\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);
});
49 changes: 49 additions & 0 deletions automation/utils/bin/rui-generate-oss-sbom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env ts-node-script

import { homedir } from "node:os";
import { join } from "node:path";
import { gh } from "../src/github";
import {
createSBomGeneratorFolderStructure,
generateSBomArtifactsInFolder,
verifyAssetDigest
} from "../src/oss-clearance";

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

if (!releaseTag) {
throw new Error(
"Usage: rui-generate-oss-sbom <release-tag>\nExample: rui-generate-oss-sbom combobox-web-v2.9.0"
);
}

await gh.ensureAuth();

const release = await gh.getReleaseByTag(releaseTag);
if (!release) {
throw new Error(`No GitHub release found for tag '${releaseTag}'`);
}
const releaseName = release.name;

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} [${fileHash}].zip`);

await generateSBomArtifactsInFolder(tmpFolder, generatorJar, releaseName, finalPath);

console.log(JSON.stringify({ path: finalPath, mpk: mpk.name, sha256: fileHash }));
}

main().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
19 changes: 5 additions & 14 deletions automation/utils/bin/rui-oss-clearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:", "");
Expand Down Expand Up @@ -214,13 +212,6 @@ async function runSbomGenerator(tmpFolder: string, releaseName: string, fileHash
return finalPath;
}

async function computeHash(filepath: string): Promise<string> {
const input = createReadStream(filepath);
const hash = crypto.createHash("sha256");
await pipeline(input, hash);
return hash.digest("hex");
}

// ============================================================================
// Command Handlers
// ============================================================================
Expand Down
22 changes: 22 additions & 0 deletions automation/utils/bin/rui-package-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env ts-node-script

import { getPackageInfo } from "../src/package-info";

async function main(): Promise<void> {
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);
});
46 changes: 46 additions & 0 deletions automation/utils/bin/rui-upload-readme-oss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env ts-node-script

import { basename } from "node:path";
import { gh } from "../src/github";
import { findAllReadmeOssLocally, getRecommendedReadmeOss, hasReadmeOssInAssets } from "../src/oss-clearance";

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

if (!releaseTag) {
throw new Error(
"Usage: rui-upload-readme-oss <release-tag> [explicit-path]\nExample: rui-upload-readme-oss combobox-web-v2.9.0"
);
}

await gh.ensureAuth();

const release = await gh.getReleaseByTag(releaseTag);
if (!release) {
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(
`No matching READMEOSS found in ~/Downloads or ~/Documents for '${release.name}'. Pass the path explicitly as a 2nd argument.`
);
}

const asset = await gh.uploadReleaseAsset(release.id, readmePath, basename(readmePath));
console.log(JSON.stringify({ uploaded: asset.name, status: "created" }));
}

main().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
6 changes: 6 additions & 0 deletions automation/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@
"copyright": "© Mendix Technology BV 2025. All rights reserved.",
"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",
"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",
Expand Down
Loading
Loading