Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/PublishMarketplace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ jobs:
CPAPI_USER_OPENID: ${{ secrets.SRV_WIDGETS_OPENID }}
CPAPI_PASS: ${{ secrets.CPAPI_PASS }}
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
- name: Merge changelogs PR
run: pnpm run merge-changelogs-pr ${{ env.TAG }}
env:
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
66 changes: 66 additions & 0 deletions automation/utils/bin/rui-merge-changelogs-pr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env ts-node-script

import { GitHub } from "../src/github";

const ALLOWED_FILENAMES = new Set(["package.xml", "CHANGELOG.md", "package.json"]);

/** Thrown when the script should stop gracefully without failing the workflow. */
class SkipError extends Error {}

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

if (!tag) {
throw new Error(
"Usage: rui-merge-changelogs-pr <release-tag>\nExample: rui-merge-changelogs-pr badge-web-v1.2.3"
);
}

const gh = new GitHub();
await gh.ensureAuth();

// 1. Fetch PR by release tag
console.log(`\nLooking up PR for tag: ${tag}`);
const pr = await gh.getPRByReleaseTag(tag);

if (!pr) {
throw new SkipError(`No PR found for tag "${tag}". Skipping.`);
}

console.log(`\nFound PR:`);
console.log(` #${pr.number}: ${pr.title}`);
console.log(` State : ${pr.state}`);
console.log(` Branch : ${pr.head.ref} → ${pr.base.ref}`);
console.log(` URL : ${pr.html_url}`);

if (pr.state !== "open") {
throw new SkipError(`PR #${pr.number} is already ${pr.state}. Skipping.`);
}

// 2. Validate changed files
console.log(`\nFetching changed files for PR #${pr.number}...`);
const files = await gh.listPRChangedFiles(pr.number);
const filenames = files.map(f => f.filename);

const disallowed = filenames.filter(f => !ALLOWED_FILENAMES.has(f.split("/").at(-1) ?? f));

if (disallowed.length > 0) {
throw new SkipError(
`PR #${pr.number} contains unexpected changed files:\n${disallowed.map(f => ` - ${f}`).join("\n")}\n\nOnly package.xml, CHANGELOG.md and package.json are allowed. Skipping.`
);
}

console.log(`Changed files (${filenames.length}):`);
filenames.forEach(f => console.log(` ✓ ${f}`));

// 3. Merge
console.log(`\nMerging PR #${pr.number}...`);
await gh.mergePR(pr.number, "squash");
console.log(`\n✅ PR #${pr.number} merged successfully.`);
}

main().catch(e => {
const isSkip = e instanceof SkipError;
console[isSkip ? "warn" : "error"](`\n${isSkip ? "⚠️" : "❌"} ${e instanceof Error ? e.message : String(e)}`);
process.exit(isSkip ? 0 : 1);
});
2 changes: 2 additions & 0 deletions automation/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"rui-create-translation": "bin/rui-create-translation.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-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",
Expand All @@ -31,6 +32,7 @@
"format": "prettier --write .",
"include-oss-in-artifact": "ts-node bin/rui-include-oss-in-artifact.ts",
"lint": "eslint --ext .jsx,.js,.ts,.tsx src/",
"merge-changelogs-pr": "ts-node bin/rui-merge-changelogs-pr.ts",
"oss-clearance": "ts-node bin/rui-oss-clearance.ts",
"prepare": "pnpm run compile:parser:widget && pnpm run compile:parser:module && tsc",
"prepare-release": "ts-node bin/rui-prepare-release.ts",
Expand Down
98 changes: 97 additions & 1 deletion automation/utils/src/github.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { mkdtemp, readFile, writeFile } from "fs/promises";
import { createWriteStream } from "fs";
import { mkdtemp, readFile, writeFile } from "fs/promises";
import { join } from "path";
import { pipeline } from "stream/promises";
import nodefetch from "node-fetch";
import { fetch } from "./fetch";
import { exec } from "./shell";

export interface GitHubPR {
number: number;
id: number;
title: string;
state: string;
html_url: string;
head: { ref: string; sha: string };
base: { ref: string };
}

export interface GitHubPRFile {
filename: string;
status: string;
additions: number;
deletions: number;
changes: number;
}

export interface GitHubReleaseAsset {
id: string;
name: string;
Expand Down Expand Up @@ -350,6 +368,84 @@ export class GitHub {
return (await response.json()) as GitHubReleaseAsset;
}

/**
* Find a PR associated with a release tag.
* Resolves the tag to its commit SHA, then looks up PRs for that commit.
*/
async getPRByReleaseTag(releaseTag: string): Promise<GitHubPR | undefined> {
await this.ensureAuth();

// Resolve the tag ref to a commit SHA (handles both lightweight and annotated tags)
const ref = await fetch<{ object: { type: string; sha: string; url: string } }>(
"GET",
`https://api.github.com/repos/${this.owner}/${this.repo}/git/ref/tags/${encodeURIComponent(releaseTag)}`,
undefined,
this.ghAPIHeaders
);

let sha = ref.object.sha;

// Annotated tags point to a tag object, not a commit — resolve one level further
if (ref.object.type === "tag") {
const tagObject = await fetch<{ object: { sha: string } }>(
"GET",
ref.object.url,
undefined,
this.ghAPIHeaders
);
sha = tagObject.object.sha;
}

const prs = await fetch<GitHubPR[]>(
"GET",
`https://api.github.com/repos/${this.owner}/${this.repo}/commits/${sha}/pulls`,
undefined,
this.ghAPIHeaders
);

return prs[0];
}

/**
* List filenames changed in a PR.
*/
async listPRChangedFiles(prNumber: number): Promise<GitHubPRFile[]> {
await this.ensureAuth();

return fetch<GitHubPRFile[]>(
"GET",
`https://api.github.com/repos/${this.owner}/${this.repo}/pulls/${prNumber}/files`,
undefined,
this.ghAPIHeaders
);
}

/**
* Merge a PR.
*/
async mergePR(prNumber: number, mergeMethod: "merge" | "squash" | "rebase" = "squash"): Promise<void> {
await this.ensureAuth();

const response = await nodefetch(
`https://api.github.com/repos/${this.owner}/${this.repo}/pulls/${prNumber}/merge`,
{
method: "PUT",
headers: {
...this.ghAPIHeaders,
"Content-Type": "application/json"
},
body: JSON.stringify({ merge_method: mergeMethod })
}
);

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to merge PR #${prNumber}: ${response.status} ${response.statusText} - ${errorText}`
);
}
}

/**
* Update a release asset's name
*/
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"create-translation": "turbo run create-translation",
"include-oss-in-artifact": "pnpm --filter @mendix/automation-utils run include-oss-in-artifact",
"lint": "turbo run lint --continue --concurrency 1",
"merge-changelogs-pr": "pnpm --filter @mendix/automation-utils run merge-changelogs-pr",
"oss-clearance": "pnpm --filter @mendix/automation-utils run oss-clearance",
"prepare": "husky install",
"prepare-release": "pnpm --filter @mendix/automation-utils run prepare-release",
Expand Down
Loading