diff --git a/.agents/skills/release-widget/SKILL.md b/.agents/skills/release-widget/SKILL.md new file mode 100644 index 0000000000..49329b50e2 --- /dev/null +++ b/.agents/skills/release-widget/SKILL.md @@ -0,0 +1,250 @@ +--- +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.** 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 + +Ask only if not already known: + +1. **Package name** — the widget or module to release, e.g. `combobox-web` or `data-widgets`. If not given, ask: "Which widget or module are you releasing?" + +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 via the packaged CLI helper (don't grep — the schema is the source of truth): + +```bash +cd packages/pluggableWidgets/ +pnpm exec rui-package-info +``` + +Prints `{"name", "version", "appNumber", "appName"}`. It reads `process.cwd()` — always `cd` into the widget/module dir first, never pass a path argument. + +`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 + ``` + 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 below, all derived from the release target's `info` — never guessed: + +- `` — `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:-$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 +echo "behind: $(git rev-list HEAD..origin/main --count)"; echo "ahead: $(git rev-list origin/main..HEAD --count)" +``` + +If not on `main` or not in sync — fix it yourself (`git checkout main`, `git merge --ff-only origin/main`) rather than asking, unless `main` has diverged from `origin/main` (both `behind` and `ahead` non-zero) — that needs a human decision, stop and ask. + +If 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 +pnpm exec rui-changelog +``` + +`rui-changelog` prints `{"hasUnreleasedLogs", "sections", "subcomponents"}`. + +- For a **widget**, the content is in `sections` and `subcomponents` is empty. +- For a **module**, it's usually the other way round: module changelogs record entries per wrapped widget, so `sections` is often empty and everything real lives in `subcomponents[].sections`. Read those too — a module with `sections: []` is not "nothing to release". `hasUnreleasedLogs` accounts for both. + +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**. + +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. Pass the explicit version, not the bump-type word: + +```bash +pnpm exec rui-bump-version +``` + +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): + +```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 — don't guess a suffix. + +**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 — not a blocker either way. + +Trigger the GitHub release workflow directly: + +```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, 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) + +Download the MPK from the draft release and generate the SBOM zip via the packaged CLI helper — don't use the interactive `oss-clearance` wizard: + +```bash +pnpm exec rui-generate-oss-sbom "" +``` + +Prints `{"path": "", "mpk": "", "sha256": ""}`. The generator jar defaults to `~/SBOM_Generator.jar`; override with `SBOM_GENERATOR_JAR` if it lives elsewhere. + +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: + +- 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, upload it via the packaged CLI helper (default search locations are `~/Downloads` and `~/Documents`): + +```bash +pnpm exec rui-upload-readme-oss "" +``` + +Prints `{"uploaded": "", "status": "created"|"exists"}`. `exists` means a READMEOSS asset was already attached and nothing was re-uploaded — safe to re-run this phase, GitHub rejects a duplicate asset name with a 422 otherwise. If it errors with no match found, ask the user where the file was saved and pass that path as a 2nd argument: `rui-upload-readme-oss "" ""`. + +### 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 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 --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` → 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. + + Then verify the changelog PR merged (repo automation should have done it): + + ```bash + gh pr list --head "tmp/" --json number,state + ``` + + Still open after a successful publish is unexpected — check whether the workflow's `merge-changelogs-pr` step ran 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 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) + +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/" --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** — `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. diff --git a/automation/utils/bin/rui-bump-version.ts b/automation/utils/bin/rui-bump-version.ts new file mode 100755 index 0000000000..605140bb86 --- /dev/null +++ b/automation/utils/bin/rui-bump-version.ts @@ -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 { + 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); + + 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 npmPackageName = process.argv[2]; + const bumpType = process.argv[3]; + + if (!npmPackageName || !bumpType) { + throw new Error( + "Usage: rui-bump-version \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)); + 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); +}); diff --git a/automation/utils/bin/rui-changelog.ts b/automation/utils/bin/rui-changelog.ts new file mode 100644 index 0000000000..93fb3e5ec5 --- /dev/null +++ b/automation/utils/bin/rui-changelog.ts @@ -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 { + 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 ? 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); +}); 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..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/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/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 &&