diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md new file mode 100644 index 0000000000..d2d9152d5b --- /dev/null +++ b/.agents/skills/release-widget/SKILL.md @@ -0,0 +1,241 @@ +--- +name: release-widget +description: Use when releasing a standalone Mendix widget or module from the web-widgets monorepo — version bump through Marketplace publish. Guides module-vs-standalone detection, prereqs, changelog-driven version selection, and drives the release pipeline directly (git/gh/pnpm) instead of a manual wizard. +--- + +# Release Widget + +## Overview + +Releases a widget (or the module wrapping it): version bump → GitHub draft release → OSS clearance → Marketplace publish. + +**Autonomy carve-out (this skill only):** pre-authorized to run `git push`, `gh workflow run`, and `gh release edit --draft=false` (publish) directly without per-step confirmation. Does **not** extend to rollback (deleting releases/tags/branches) or merging PRs (branch protection needs team approvals — user's job). + +**No persisted release-state file** — each invocation re-checks git/GitHub/Jira/Marketplace from scratch. Safe to stop and resume across sessions. + +## Prerequisites + +Ask only if not already known: + +1. **Package name** — widget or module to release, e.g. `combobox-web` or `data-widgets`. If not given, ask: "Which widget or module are you releasing?" + +Everything else — check automatically in Phase 0, don't ask. + +## Workflow + +### Phase 0 — Detect release target + +```bash +cd packages/pluggableWidgets/ +pnpm exec rui-package-info +``` + +Prints `{"name", "version", "appNumber", "appName"}`. Reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. + +`appName` is the Marketplace display name (e.g. `Maps`). Draft release is titled ` v`. + +- `appNumber` positive → **standalone release**. Keep this `info` — Phase 2/3 reuse `` from it, Phase 7 reuses `appNumber`. +- `appNumber` is `null`/absent/`-1` → widget is wrapped by another package. Find the owner (usually a module, but a widget like `charts-web` also wraps sub-widgets e.g. `area-chart-web`): + ```bash + grep -l "\"@mendix/\"" packages/modules/*/package.json packages/pluggableWidgets/*/package.json + ``` + No owner found → stop, package is misconfigured. Otherwise re-run from the owner's directory: + ```bash + cd packages/modules/ # or packages/pluggableWidgets/ + pnpm exec rui-package-info + ``` + Owner's `info` is the release target from here on. Tell the user which module or widget wraps it. + +Placeholders used below, derived from the release target's `info`: + +- `` — `info.name` (e.g. `@mendix/data-widgets`). Pass to `rui-changelog`, `rui-bump-version`, `CreateGitHubRelease.yml`'s `package` input. +- `` — `` minus `@mendix/` prefix, not a folder name. Used in commit messages, branch names, tags. +- `` — `-v`, assembled once Phase 2 confirms ``. Used as GitHub release tag, `tmp/` branch, and Jira version. + +### Phase 1 — Prerequisite check + +Run once, report all results together (don't ask one at a time): + +```bash +echo "== SBOM jar =="; ls "${SBOM_GENERATOR_JAR:-$HOME/SBOM_Generator.jar}" 2>&1 +echo "== gh auth =="; gh auth status 2>&1 +echo "== git branch/status =="; git branch --show-current; git status --short +echo "== main sync =="; git fetch origin main --quiet +echo "behind: $(git rev-list HEAD..origin/main --count)"; echo "ahead: $(git rev-list origin/main..HEAD --count)" +``` + +If not on `main` or not in sync — fix it yourself (`git checkout main`, `git merge --ff-only origin/main`), unless `main` has diverged from `origin/main` (both `behind` and `ahead` non-zero) — stop and ask. + +If the SBOM jar is missing, say what's missing and how to fix it (where to get `SBOM_Generator.jar`, or point `SBOM_GENERATOR_JAR` at it) — don't proceed. + +### Phase 2 — Version selection + +Current version already known from Phase 0 — don't re-run `rui-package-info`. + +```bash +pnpm exec rui-changelog +``` + +Prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. + +- **Widget**: content in `sections`, `subcomponents` empty. +- **Module**: unreleased work usually sits in each wrapped widget's CHANGELOG.md, surfaced as `subcomponents[].sections`. Read those too — a module with `sections: []` is not "nothing to release". `hasUnreleasedLogs` accounts for both. + +Summarize unreleased entries by type (Fixed/Added/Changed/Breaking changes), across subcomponents for a module (name the widget each entry came from), and propose a semver bump: + +- Any "Breaking changes" section present → propose **major**, but flag it as a recommendation, not a mandate. +- Only "Added" → propose **minor**. +- Only "Fixed" → propose **patch**. + +Show the concrete ``, not just the bump-type word — e.g. "propose **minor**: 2.9.0 → 2.10.0". Always ask the user to confirm or override it. If their choice contradicts the changelog (patch despite breaking changes), flag it once, then respect it. + +### Phase 3 — Version bump + release branch (autonomous) + +Bump to the `` confirmed in Phase 2: + +```bash +pnpm exec rui-bump-version +``` + +Prints `{"previousVersion", "version", "bumpedPackages", "changedPaths"}`. + +Refuses to run, exits non-zero when: + +- `` isn't independently releasable (no positive `marketplace.appNumber`) — go back to Phase 0. +- argument isn't a valid `x.y.z` version. +- resulting version isn't greater than `previousVersion`. + +**If target wraps other packages (module, or widget like `charts-web` with sub-widgets), this bumps every wrapped dependency to the same version** — all of them, since they ship inside the same MPK. Use `changedPaths` verbatim in the `git add` below. + +Then: + +```bash +git checkout -b tmp/ +git add +git commit -m "chore(): bump version to " +git push -u origin tmp/ +``` + +If the branch already exists locally or on remote, stop and ask. + +**Jira version** — safe to re-run, always exits 0: + +```bash +pnpm exec rui-create-jira-version "" +``` + +Prints `{"status": "created"|"exists"|"skipped", ...}`. `skipped` covers missing `JIRA_API_TOKEN` or a failed API call — not a blocker either way. + +Trigger the GitHub release workflow: + +```bash +gh workflow run "CreateGitHubRelease.yml" --ref "tmp/" -f package= +``` + +Poll for completion: + +```bash +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. + +Wait (re-poll, don't ask the user to check) until `status == completed`. Report the conclusion and the draft release URL. + +### Phase 4 — OSS clearance SBOM (autonomous prep, manual submission) + +```bash +pnpm exec rui-generate-oss-sbom "" +``` + +Prints `{"path": "", "mpk": "", "sha256": ""}`. Generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if elsewhere. + +Zip is named ` v [].zip` — don't rename it. Works on the **draft** release, no publishing needed first. + +**Submission is manual** — goes through the OSS clearance portal (Mendix app, Mendix credentials login), not email. Tell the user: + +- Zip is ready at the printed path. +- Ask them to submit it via the OSS clearance portal (they know the URL/login flow). +- Draft the request content (widget/module name, version, draft release URL, one-line summary of changes from the changelog) so they can paste it into the portal. + +Then ask: "Submitted? Waiting on OSS team reply (a READMEOSS HTML file)." Wait is unbounded (days) — skill can be safely re-invoked later; Phase 0–4 confirm state unchanged and skip straight back here. + +### Phase 5 — Include OSS Readme (autonomous once file is provided) + +```bash +pnpm exec rui-upload-readme-oss "" +``` + +Prints `{"uploaded": "", "status": "created"|"exists"}`. `exists` = already attached, safe to re-run (GitHub rejects duplicate asset name with 422 otherwise). If no match found, ask the user where the file was saved and pass as 2nd arg: `rui-upload-readme-oss "" ""`. + +### Phase 6 — Asset gate + publish (GATE — do not skip) + +**Before ever publishing, verify both assets are present:** + +```bash +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 user explicitly says to publish anyway, comply but state clearly this is unverified (no asset-gate passed). + +Once the gate passes, publish: + +```bash +gh release edit --draft=false +``` + +Publishing triggers `PublishMarketplace.yml` automatically (on `release: published`). Don't manually re-run the marketplace-publish workflow for the same tag unless the automatic run failed — see Phase 7. + +### Phase 7 — Marketplace publish verification + +```bash +gh run list --workflow="Publishes a package to marketplace" -L 5 --json databaseId,status,conclusion,headBranch,createdAt +``` + +Find the run matching this tag/branch. + +- `conclusion: success` → doesn't mean the version is live yet. Confirm: `marketplace-mcp`'s `get_content_versions` with `contentId` = `appNumber` from Phase 0, check `` is listed. If `marketplace-mcp` isn't connected or errors, ask the user to check the widget's Marketplace listing page for ``. Don't declare done until one of the two confirms it. + + Then verify changelog PR merged: + + ```bash + gh pr list --head "tmp/" --json number,state + ``` + + Still open after successful publish → check whether `merge-changelogs-pr` step ran. Don't merge it yourself: branch protection requires team approvals — tell the user it's on them. + +- `conclusion: failure` → **check run history before escalating**: + ```bash + gh run view --log-failed | grep -A3 "Response status Code" + ``` + If `409` on `POST .../packages//versions`: + 1. Check whether an **earlier run for this exact tag already succeeded**: `gh run list --workflow="Publishes a package to marketplace" --json databaseId,status,conclusion,createdAt,headBranch` filtered to this tag. If yes, the 409 means **version already published** — report that, don't escalate/retry/teardown. + 2. If no prior success: check for two runs created seconds apart for the same tag (double-trigger). Otherwise stuck server-side state. + 3. Escalate: report appNumber, tag, endpoint, error, ask whether to (a) dig through logs together and check Marketplace → package page → Manage Versions for a stuck draft, or (b) retry. + 4. Never `gh run rerun` speculatively. Rerun once, only after user confirms they acted (deleted a draft, etc.). + +### Phase 8 — Rollback (human-gated, always — carve-out does not apply here) + +If the user wants to undo a release attempt, list the exact teardown commands and **wait for explicit confirmation before running any of them**. + +```bash +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/` (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 + +- **Writing inline `ts-node -e` scripts instead of using the packaged CLI helpers** — use `rui-package-info`, `rui-changelog`, `rui-bump-version`, `rui-create-jira-version`, `rui-generate-oss-sbom`, `rui-upload-readme-oss` (in `automation/utils/bin/`). Never reimplement ad hoc. +- **Working around a helper's refusal instead of fixing the input** — go back to Phase 0/2, don't bump the widget by hand. +- **Publishing before the asset gate passes** — never `gh release edit --draft=false` without confirming both MPK and READMEOSS are attached. +- **Escalating a 409 without checking run history first** — check `gh run list` for the tag first. +- **Running rollback commands without explicit go-ahead** — list, then wait. diff --git a/automation/utils/bin/rui-bump-version.ts b/automation/utils/bin/rui-bump-version.ts new file mode 100755 index 0000000000..784d49d107 --- /dev/null +++ b/automation/utils/bin/rui-bump-version.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env ts-node-script + +import { bumpPackageJson, bumpXml, hasPackageXml } from "../src/bump-version"; +import { resolvePackagePath } from "../src/monorepo"; +import { getPackageInfo, isReleasable } from "../src/package-info"; +import { Version, versionRegex } from "../src/version"; + +async function bumpPackage(path: string, version: string): Promise { + bumpPackageJson(path, version); + + if (!hasPackageXml(path)) { + return; // modules have no package.xml + } + + await bumpXml(path, version); +} + +function checkVersion(version: string, previousVersion: string): void { + if (!versionRegex.test(version)) { + throw new Error(`'${version}' is not a valid version number (expected x.y.z)`); + } + + if (!Version.fromString(version).isGreaterThan(Version.fromString(previousVersion))) { + throw new Error(`Version '${version}' is not greater than the current version '${previousVersion}'`); + } +} + +async function main(): Promise { + const npmPackageName = process.argv[2]; + const version = process.argv[3]; + + if (!npmPackageName || !version) { + throw new Error( + "Usage: rui-bump-version \nExample: rui-bump-version @mendix/combobox-web 1.2.3" + ); + } + + const path = await resolvePackagePath(npmPackageName); + const info = await getPackageInfo(path); + + if (!isReleasable(info)) { + throw new Error( + `'${npmPackageName}' has no positive marketplace.appNumber, so it is not published on its own. If it is a widget, bump the module wrapping it instead.` + ); + } + + const previousVersion = info.version.format(); + checkVersion(version, previousVersion); + + await bumpPackage(path, version); + const bumpedPackages = [info.name]; + const changedPaths = [path]; + + // Wrapped widgets are released as part of the target and share its version, + // so all of them are bumped, not only the ones with changelog entries. + for (const dependencyName of info.mxpackage.dependencies) { + const dependencyPath = await resolvePackagePath(dependencyName); + + await bumpPackage(dependencyPath, version); + bumpedPackages.push(dependencyName); + changedPaths.push(dependencyPath); + } + + console.log(JSON.stringify({ previousVersion, version, bumpedPackages, changedPaths })); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/automation/utils/bin/rui-changelog.ts b/automation/utils/bin/rui-changelog.ts new file mode 100644 index 0000000000..e2e0ec0159 --- /dev/null +++ b/automation/utils/bin/rui-changelog.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env ts-node-script + +import { getPackageChangelog, getWidgetChangelog } from "../src/changelog-parser"; +import { SubComponentEntry } from "../src/changelog-parser/types"; +import { listPackages, resolvePackagePath } from "../src/monorepo"; +import { getPackageInfo, isReleasable } from "../src/package-info"; + +/** + * A module's own CHANGELOG.md never carries unreleased subcomponent entries: + * they're only added there (and immediately moved into a release) by + * rui-update-changelog-module, at release time. Before that, unreleased work + * for a wrapped widget lives solely in that widget's own CHANGELOG.md. + */ +async function getUnreleasedSubcomponents(dependencyNames: string[]): Promise { + const dependencies = await listPackages(dependencyNames); + const entries = await Promise.all( + dependencies.map(async ({ path }) => { + const depInfo = await getPackageInfo(path); + const [unreleased] = (await getWidgetChangelog(path)).changelog.content; + return { name: depInfo.mxpackage.name, sections: unreleased.sections }; + }) + ); + + return entries.filter(entry => entry.sections.length !== 0); +} + +async function main(): Promise { + const npmPackageName = process.argv[2]; + + if (!npmPackageName) { + throw new Error("Usage: rui-changelog \nExample: rui-changelog @mendix/combobox-web"); + } + + const path = await resolvePackagePath(npmPackageName); + const info = await getPackageInfo(path); + + if (!isReleasable(info)) { + throw new Error( + `'${npmPackageName}' has no positive marketplace.appNumber, so it is not published on its own. If it is a widget, read the changelog of the module wrapping it instead.` + ); + } + + const changelog = await getPackageChangelog(path); + // The parsers keep the Unreleased entry first, released versions follow. + const unreleased = changelog.changelog.content[0]; + const subcomponents = + "subcomponents" in unreleased ? await getUnreleasedSubcomponents(info.mxpackage.dependencies) : []; + + console.log( + JSON.stringify({ + hasUnreleasedLogs: unreleased.sections.length !== 0 || subcomponents.length !== 0, + sections: unreleased.sections, + subcomponents + }) + ); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); 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..e46e322189 --- /dev/null +++ b/automation/utils/bin/rui-create-jira-version.ts @@ -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 { + 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..017612f0d5 --- /dev/null +++ b/automation/utils/bin/rui-generate-oss-sbom.ts @@ -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 { + const releaseTag = process.argv[2]; + + if (!releaseTag) { + throw new Error( + "Usage: rui-generate-oss-sbom \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); +}); 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/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..a9cc0a8a51 --- /dev/null +++ b/automation/utils/bin/rui-upload-readme-oss.ts @@ -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 { + const releaseTag = process.argv[2]; + const explicitPath = process.argv[3]; + + if (!releaseTag) { + throw new Error( + "Usage: rui-upload-readme-oss [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); +}); diff --git a/automation/utils/package.json b/automation/utils/package.json index f97cfb193b..c74c4e1ca0 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -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", diff --git a/automation/utils/src/bump-version.ts b/automation/utils/src/bump-version.ts index 3b80f2c4af..c2a29f7c28 100644 --- a/automation/utils/src/bump-version.ts +++ b/automation/utils/src/bump-version.ts @@ -1,56 +1,79 @@ 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"; import { prompt } from "enquirer"; import { PackageListing } from "./monorepo"; +import { Version } from "./version"; export type BumpVersionType = "patch" | "minor" | "major" | string; export function getNewVersion(bumpVersionType: BumpVersionType, currentVersion: string): string { - const [major, minor, patch] = currentVersion.split("."); + const version = Version.fromString(currentVersion); switch (bumpVersionType) { case "patch": - return [major, minor, Number(patch) + 1].join("."); + return version.bumpPatch().format(); case "minor": - return [major, Number(minor) + 1, 0].join("."); + return version.bumpMinor().format(); case "major": - return [Number(major) + 1, 0, 0].join("."); + return version.bumpMajor().format(); default: return bumpVersionType; } } +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/changelog-parser/index.ts b/automation/utils/src/changelog-parser/index.ts index 549a7dceac..f3ef086fee 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"; @@ -302,3 +303,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/github.ts b/automation/utils/src/github.ts index 884716d0fc..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,28 +165,43 @@ export class GitHub { } async getReleaseIdByReleaseTag(releaseTag: string): Promise { + return (await this.getReleaseByTag(releaseTag))?.id; + } + + /** + * 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 { - 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; + try { + 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 { @@ -204,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); 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/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"); 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); 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 other; + } + } + + return false; + } + equals(anotherVersion: Version): boolean { return ( this.major === anotherVersion.major &&