diff --git a/.github/workflows/github-script/generate-wiki-page.js b/.github/workflows/github-script/generate-wiki-page.js
new file mode 100644
index 00000000..0f42d632
--- /dev/null
+++ b/.github/workflows/github-script/generate-wiki-page.js
@@ -0,0 +1,599 @@
+// @ts-nocheck
+
+const fs = require('fs').promises;
+const { join } = require('path');
+const { load } = require('js-yaml');
+
+async function getWorkspaceList(workspacesDir, core) {
+ try {
+ const entries = await fs.readdir(workspacesDir, { withFileTypes: true });
+ return entries
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
+ .map(entry => entry.name)
+ .sort();
+ } catch (error) {
+ core.setFailed(`Error reading workspaces directory ${workspacesDir}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function parseSourceJson(workspacePath, core) {
+ const sourceFile = join(workspacePath, 'source.json');
+ try {
+ const content = await fs.readFile(sourceFile, 'utf-8');
+ return JSON.parse(content);
+ } catch (error) {
+ core.setFailed(`Error reading ${sourceFile}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function parsePluginsList(workspacePath, core) {
+ const pluginsFile = join(workspacePath, 'plugins-list.yaml');
+ try {
+ const content = await fs.readFile(pluginsFile, 'utf-8');
+ const trimmed = content.trim();
+ if (!trimmed) {
+ return [];
+ }
+
+ const data = load(trimmed);
+ if (typeof data === 'object' && data !== null) {
+ if (Array.isArray(data)) {
+ return data;
+ } else if (typeof data === 'object') {
+ return Object.keys(data);
+ }
+ }
+ return [];
+ } catch (error) {
+ core.setFailed(`Error reading ${pluginsFile}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function getPluginDetails(octokit, repoUrl, commitSha, pluginPath, core) {
+ if (!repoUrl.startsWith('https://github.com/')) {
+ return pluginPath;
+ }
+
+ const repoName = repoUrl.replace('https://github.com/', '').replace(/\/$/, '');
+ const [owner, repo] = repoName.split('/');
+
+ const cleanPluginPath = pluginPath === '.' ? '' : pluginPath;
+ const filePath = cleanPluginPath ? `${cleanPluginPath}/package.json` : 'package.json';
+
+ try {
+ const response = await octokit.rest.repos.getContent({
+ owner,
+ repo,
+ path: filePath,
+ ref: commitSha
+ });
+
+ if ('content' in response.data && response.data.encoding === 'base64') {
+ const content = Buffer.from(response.data.content, 'base64').toString('utf-8');
+ const packageJson = JSON.parse(content);
+ const name = packageJson.name || 'unknown';
+ const version = packageJson.version || 'unknown';
+ return `${name}@${version}`;
+ }
+ } catch (error) {
+ core.warning(`Error fetching package.json for ${pluginPath} in ${repoName}@${commitSha}: ${error.message}`);
+ }
+
+ return pluginPath;
+}
+
+async function getCommitDetails(octokit, repoUrl, commitSha, core) {
+ if (!repoUrl.startsWith('https://github.com/')) {
+ return {
+ shortSha: commitSha.substring(0, 7),
+ message: 'N/A',
+ date: 'N/A'
+ };
+ }
+
+ const repoName = repoUrl.replace('https://github.com/', '').replace(/\/$/, '');
+ const [owner, repo] = repoName.split('/');
+
+ try {
+ const response = await octokit.rest.repos.getCommit({
+ owner,
+ repo,
+ ref: commitSha
+ });
+
+ const commit = response.data.commit;
+ const message = commit.message.split('\n')[0];
+ const dateStr = commit.author?.date || '';
+
+ let formattedDate = 'N/A';
+ if (dateStr) {
+ try {
+ const dt = new Date(dateStr);
+ formattedDate = dt.toISOString().replace('T', ' ').substring(0, 16) + ' UTC';
+ } catch (dateError) {
+ core.warning(`Error formatting date "${dateStr}": ${dateError.message}`);
+ formattedDate = dateStr;
+ }
+ }
+
+ return {
+ shortSha: response.data.sha.substring(0, 7),
+ message,
+ date: formattedDate
+ };
+ } catch (error) {
+ core.warning(`Error fetching commit details for ${repoName}@${commitSha}: ${error.message}`);
+ return {
+ shortSha: commitSha.substring(0, 7),
+ message: 'N/A',
+ date: 'N/A'
+ };
+ }
+}
+
+async function checkPendingPRs(octokit, workspaceName, repoName, targetBranch, core) {
+ const workspacePath = `workspaces/${workspaceName}`;
+ const [owner, repo] = repoName.split('/');
+
+ try {
+ const response = await octokit.rest.pulls.list({
+ owner,
+ repo,
+ base: targetBranch,
+ state: 'open',
+ per_page: 100
+ });
+
+ const prNumbers = [];
+ for (const pr of response.data) {
+ try {
+ const filesResponse = await octokit.rest.pulls.listFiles({
+ owner,
+ repo,
+ pull_number: pr.number
+ });
+
+ const hasWorkspaceFile = filesResponse.data.some(file =>
+ file.filename.startsWith(workspacePath)
+ );
+
+ const hasRequiredLabel = pr.labels?.some(label =>
+ label.name === 'workspace_addition' || label.name === 'workspace_update'
+ );
+
+ if (hasWorkspaceFile && hasRequiredLabel) {
+ prNumbers.push(pr.number.toString());
+ }
+ } catch (error) {
+ core.warning(`Error checking files for PR #${pr.number}: ${error.message}`);
+ if (error.status) {
+ core.warning(`HTTP status: ${error.status}`);
+ }
+ continue;
+ }
+ }
+
+ return {
+ hasPending: prNumbers.length > 0,
+ prNumbers
+ };
+ } catch (error) {
+ core.warning(`Error checking pending PRs for workspace ${workspaceName} in ${repoName}: ${error.message}`);
+ return { hasPending: false, prNumbers: [] };
+ }
+}
+
+async function getLocalBackstageVersion(workspacePath, core) {
+ const backstageFile = join(workspacePath, 'backstage.json');
+ try {
+ const content = await fs.readFile(backstageFile, 'utf-8');
+ const data = JSON.parse(content);
+ if (data.version) {
+ return data.version;
+ }
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ core.warning(`Error reading backstage.json from ${workspacePath}: ${error.message}`);
+ }
+ }
+ return null;
+}
+
+async function getSourceBackstageVersion(octokit, repoUrl, commitSha, sourceData, core) {
+ let upstreamVersion = null;
+
+ // Try to fetch from upstream backstage.json first
+ if (repoUrl && commitSha && repoUrl.startsWith('https://github.com/')) {
+ const repoName = repoUrl.replace('https://github.com/', '').replace(/\/$/, '');
+ const [owner, repo] = repoName.split('/');
+
+ try {
+ const response = await octokit.rest.repos.getContent({
+ owner,
+ repo,
+ path: 'backstage.json',
+ ref: commitSha
+ });
+
+ if ('content' in response.data && response.data.encoding === 'base64') {
+ const content = Buffer.from(response.data.content, 'base64').toString('utf-8');
+ const data = JSON.parse(content);
+ upstreamVersion = data.version || null;
+ }
+ } catch (error) {
+ if (error.status !== 404) {
+ core.warning(`Error fetching upstream backstage.json for ${repoUrl}@${commitSha}: ${error.message}`);
+ }
+ }
+ }
+
+ // If upstream found, use it (to detect overrides)
+ if (upstreamVersion) {
+ return upstreamVersion;
+ }
+
+ // Fallback to source.json's repo-backstage-version if upstream file missing
+ if (sourceData && sourceData['repo-backstage-version']) {
+ return sourceData['repo-backstage-version'];
+ }
+
+ return null;
+}
+
+async function loadPluginLists(core) {
+ const supported = [];
+ const community = [];
+ const techpreview = [];
+
+ const supportedPath = 'rhdh-supported-packages.txt';
+ try {
+ const content = await fs.readFile(supportedPath, 'utf-8');
+ supported.push(...content
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line && !line.startsWith('#'))
+ );
+ } catch (error) {
+ core.setFailed(`Error reading ${supportedPath}: ${error.message}`);
+ throw error;
+ }
+
+ const communityPath = 'rhdh-community-packages.txt';
+ try {
+ const content = await fs.readFile(communityPath, 'utf-8');
+ community.push(...content
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line && !line.startsWith('#'))
+ );
+ } catch (error) {
+ core.setFailed(`Error reading ${communityPath}: ${error.message}`);
+ throw error;
+ }
+
+ const techpreviewPath = 'rhdh-techpreview-packages.txt';
+ try {
+ const content = await fs.readFile(techpreviewPath, 'utf-8');
+ techpreview.push(...content
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line && !line.startsWith('#'))
+ );
+ } catch (error) {
+ core.setFailed(`Error reading ${techpreviewPath}: ${error.message}`);
+ throw error;
+ }
+
+ return { supported, community, techpreview };
+}
+
+function checkSupportStatus(pluginPath, workspaceName, supportedList, communityList, techpreviewList) {
+ const cleanPluginPath = pluginPath.replace(/^\.?\//, '').replace(/^\//, '');
+ const fullPath = `${workspaceName}/${cleanPluginPath}`;
+
+ if (supportedList.includes(fullPath)) {
+ return 'Supported';
+ }
+ if (techpreviewList.includes(fullPath)) {
+ return 'TechPreview';
+ }
+ if (communityList.includes(fullPath)) {
+ return 'Community';
+ }
+
+ if (supportedList.includes(cleanPluginPath)) {
+ return 'Supported';
+ }
+ if (techpreviewList.includes(cleanPluginPath)) {
+ return 'TechPreview';
+ }
+ if (communityList.includes(cleanPluginPath)) {
+ return 'Community';
+ }
+
+ return 'Unknown';
+}
+
+async function countFilesRecursive(dirPath, core) {
+ let count = 0;
+ try {
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = join(dirPath, entry.name);
+ if (entry.isFile()) {
+ count++;
+ } else if (entry.isDirectory()) {
+ count += await countFilesRecursive(fullPath, core);
+ }
+ }
+ } catch (error) {
+ core.warning(`Error counting files in ${dirPath}: ${error.message}`);
+ }
+ return count;
+}
+
+async function countAdditionalFiles(workspacePath, core) {
+ const counts = {
+ metadata: 0,
+ plugins: 0,
+ patches: 0,
+ tests: 0
+ };
+
+ for (const key of Object.keys(counts)) {
+ const dirPath = join(workspacePath, key);
+ try {
+ const stat = await fs.stat(dirPath);
+ if (stat.isDirectory()) {
+ counts[key] = await countFilesRecursive(dirPath, core);
+ }
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ core.warning(`Error counting files in ${dirPath}: ${error.message}`);
+ }
+ }
+ }
+
+ return counts;
+}
+
+function generateMarkdown(branchName, workspacesData, repoName) {
+ const md = [];
+
+ md.push(`# Workspace Status: \`${branchName}\``);
+ md.push('');
+ const now = new Date();
+ const utcDate = now.toISOString().replace('T', ' ').substring(0, 16) + ' UTC';
+ md.push(`**Last Updated:** ${utcDate}`);
+ md.push('');
+ md.push(`**Total Workspaces:** ${workspacesData.length}`);
+ md.push('');
+ md.push('---');
+ md.push('');
+
+ md.push('## Workspace Overview');
+ md.push('');
+ md.push('| Type | Workspace | Source | Backstage Version | Plugins |');
+ md.push('|:----:|-----------|--------|------------------|---------|');
+
+ for (const ws of workspacesData) {
+ const sourceJsonUrl = `https://github.com/${repoName}/blob/${branchName}/workspaces/${ws.name}/source.json`;
+ const structIcon = ws.repo_flat ? '📄' : '🌳';
+ const structTooltip = ws.repo_flat ? 'Flat (root-level plugins)' : 'Monorepo (workspace-based)';
+
+ const structureBadges = [];
+ structureBadges.push(`[${structIcon}](${sourceJsonUrl} "${structTooltip}")`);
+
+ if (ws.additional_files.patches > 0) {
+ const patchesUrl = `https://github.com/${repoName}/tree/${branchName}/workspaces/${ws.name}/patches`;
+ structureBadges.push(`[🩹](${patchesUrl})`);
+ }
+
+ if (ws.additional_files.plugins > 0) {
+ const pluginsUrl = `https://github.com/${repoName}/tree/${branchName}/workspaces/${ws.name}/plugins`;
+ structureBadges.push(`[🔄](${pluginsUrl})`);
+ }
+
+ if (ws.additional_files.metadata > 0) {
+ structureBadges.push('🟢');
+ } else {
+ structureBadges.push('🔴');
+ }
+
+ if (ws.has_pending_prs && ws.pr_numbers.length > 0) {
+ const prNum = ws.pr_numbers[0];
+ const prUrl = `https://github.com/${repoName}/pull/${prNum}`;
+ const prTooltip = `Pending update PR #${prNum}`;
+ structureBadges.push(`[🔴](${prUrl})`);
+ }
+
+ const structure = structureBadges.join('
');
+ const overlayRepoUrl = `https://github.com/${repoName}/tree/${branchName}/workspaces/${ws.name}`;
+ const workspaceName = `[${ws.name}](${overlayRepoUrl})`;
+
+ let source = 'N/A';
+ if (ws.repo_url && ws.commit_sha) {
+ const repoNameOnly = ws.repo_url.replace('https://github.com/', '');
+ if (ws.repo_flat) {
+ const sourceUrl = `${ws.repo_url}/tree/${ws.commit_sha}`;
+ source = `[${repoNameOnly}@${ws.commit_short}](${sourceUrl})`;
+ } else {
+ const workspacePath = `workspaces/${ws.name}`;
+ const sourceUrl = `${ws.repo_url}/tree/${ws.commit_sha}/${workspacePath}`;
+ source = `[${repoNameOnly}@${ws.commit_short}](${sourceUrl})`;
+ }
+ }
+
+ const overlayVersion = ws.overlay_backstage_version;
+ const sourceVersion = ws.source_backstage_version;
+ const displayVersion = sourceVersion || overlayVersion;
+
+ let backstageVersion = 'N/A';
+ if (displayVersion) {
+ if (overlayVersion && sourceVersion && overlayVersion !== sourceVersion) {
+ const tooltip = `Overlay overrides upstream version ${sourceVersion} to ${overlayVersion}`.replace(/"/g, '"');
+ backstageVersion = `\`${displayVersion}\` ⚠️`;
+ } else {
+ backstageVersion = `\`${displayVersion}\``;
+ }
+ }
+
+ let pluginsList = 'No plugins';
+ if (ws.plugins && ws.plugins.length > 0) {
+ const pluginsListItems = ws.plugins.map(p => {
+ const nameVer = p.details;
+ const status = p.status;
+
+ let icon, tooltip;
+ if (status === 'Supported') {
+ icon = '🟢';
+ tooltip = 'Red Hat Supported';
+ } else if (status === 'TechPreview') {
+ icon = '🔵';
+ tooltip = 'Tech Preview';
+ } else if (status === 'Community') {
+ icon = '🟡';
+ tooltip = 'Community';
+ } else {
+ icon = '▪️';
+ tooltip = 'Unknown';
+ }
+
+ return `${icon} \`${nameVer}\``;
+ });
+
+ pluginsList = pluginsListItems.join('
');
+ }
+
+ md.push(`| ${structure} | ${workspaceName} | ${source} | ${backstageVersion} | ${pluginsList} |`);
+ }
+
+ md.push('');
+ md.push('---');
+ md.push('');
+
+ return md.join('\n');
+}
+
+/** @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments */
+module.exports = async ({github, context, core}) => {
+ try {
+ const branchName = context.ref.replace('refs/heads/', '');
+ const repoName = `${context.repo.owner}/${context.repo.repo}`;
+
+ core.info(`Generating wiki page for branch: ${branchName}`);
+ core.info(`Repository: ${repoName}`);
+
+ const octokit = github;
+
+ const workspacesDir = 'workspaces';
+ const workspaceNames = await getWorkspaceList(workspacesDir, core);
+ core.info(`Found ${workspaceNames.length} workspaces`);
+
+ const { supported: supportedPlugins, community: communityPlugins, techpreview: techpreviewPlugins } = await loadPluginLists(core);
+ core.info(`Loaded ${supportedPlugins.length} supported, ${techpreviewPlugins.length} tech preview, and ${communityPlugins.length} community plugins`);
+
+ const workspacesData = [];
+
+ for (const wsName of workspaceNames) {
+ core.info(`Processing workspace: ${wsName}`);
+ const wsPath = join(workspacesDir, wsName);
+
+ const sourceData = await parseSourceJson(wsPath, core);
+ const plugins = await parsePluginsList(wsPath, core);
+
+ let commitSha = null;
+ let commitShort = null;
+ let commitMessage = 'N/A';
+ let commitDate = 'N/A';
+ let repoUrl = null;
+ let repoFlat = false;
+
+ if (sourceData) {
+ repoUrl = sourceData.repo || null;
+ commitSha = sourceData['repo-ref'] || null;
+ repoFlat = sourceData['repo-flat'] || false;
+
+ if (repoUrl && commitSha) {
+ const commitDetails = await getCommitDetails(octokit, repoUrl, commitSha, core);
+ commitShort = commitDetails.shortSha;
+ commitMessage = commitDetails.message;
+ commitDate = commitDetails.date;
+ }
+ }
+
+ const overlayBackstageVersion = await getLocalBackstageVersion(wsPath, core);
+
+ const sourceBackstageVersion = await getSourceBackstageVersion(octokit, repoUrl, commitSha, sourceData, core);
+
+ const enhancedPlugins = [];
+ if (repoUrl && commitSha) {
+ core.info(` Fetching plugin details for ${plugins.length} plugins...`);
+ for (const pluginPath of plugins) {
+ const cleanPath = pluginPath.replace(/^\.?\//, '');
+ const fullPluginPath = repoFlat
+ ? cleanPath
+ : `workspaces/${wsName}/${cleanPath}`;
+
+ const details = await getPluginDetails(octokit, repoUrl, commitSha, fullPluginPath, core);
+ const supportStatus = checkSupportStatus(pluginPath, wsName, supportedPlugins, communityPlugins, techpreviewPlugins);
+
+ enhancedPlugins.push({
+ details,
+ path: pluginPath,
+ status: supportStatus
+ });
+ }
+ } else {
+ for (const pluginPath of plugins) {
+ const supportStatus = checkSupportStatus(pluginPath, wsName, supportedPlugins, communityPlugins, techpreviewPlugins);
+ enhancedPlugins.push({
+ details: pluginPath,
+ path: pluginPath,
+ status: supportStatus
+ });
+ }
+ }
+
+ const additionalFiles = await countAdditionalFiles(wsPath, core);
+ const { hasPending: hasPendingPRs, prNumbers } = await checkPendingPRs(
+ octokit,
+ wsName,
+ repoName,
+ branchName,
+ core
+ );
+
+ workspacesData.push({
+ name: wsName,
+ repo_url: repoUrl,
+ commit_sha: commitSha,
+ commit_short: commitShort,
+ commit_message: commitMessage,
+ commit_date: commitDate,
+ repo_flat: repoFlat,
+ overlay_backstage_version: overlayBackstageVersion,
+ source_backstage_version: sourceBackstageVersion,
+ plugins: enhancedPlugins,
+ additional_files: additionalFiles,
+ has_pending_prs: hasPendingPRs,
+ pr_numbers: prNumbers
+ });
+ }
+
+ core.info('Generating Markdown content...');
+ const markdownContent = generateMarkdown(branchName, workspacesData, repoName);
+
+ const safeBranchName = branchName.replace(/\//g, '-');
+ const outputFile = `${safeBranchName}.md`;
+ await fs.writeFile(outputFile, markdownContent, 'utf-8');
+
+ core.info(`Wiki page generated: ${outputFile}`);
+ core.info(`Total workspaces documented: ${workspacesData.length}`);
+ } catch (error) {
+ core.setFailed(`Fatal error in main: ${error.message}`);
+ }
+};
diff --git a/.github/workflows/update-wiki.yml b/.github/workflows/update-wiki.yml
new file mode 100644
index 00000000..98b05c2a
--- /dev/null
+++ b/.github/workflows/update-wiki.yml
@@ -0,0 +1,73 @@
+name: Update Wiki Pages
+
+on:
+ push:
+ branches:
+ - main
+ - 'release-*'
+
+permissions:
+ contents: write
+ pull-requests: read
+
+jobs:
+ update-wiki:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24'
+
+ - name: Install dependencies
+ run: |
+ npm install js-yaml
+
+ - name: Get branch name
+ id: branch
+ run: |
+ BRANCH_NAME="${GITHUB_REF#refs/heads/}"
+ # Sanitize branch name for filename (replace / with -)
+ SAFE_BRANCH_NAME="${BRANCH_NAME//\//-}"
+ echo "name=${BRANCH_NAME}" >> $GITHUB_OUTPUT
+ echo "safe_name=${SAFE_BRANCH_NAME}" >> $GITHUB_OUTPUT
+ echo "Branch: ${BRANCH_NAME}"
+
+ - name: Generate wiki content
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const script = require('./.github/workflows/github-script/generate-wiki-page.js');
+ await script({github, context, core});
+
+ - name: Checkout wiki repository
+ uses: actions/checkout@v5
+ with:
+ repository: ${{ github.repository }}.wiki
+ path: wiki
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Copy generated wiki page to wiki repo
+ run: |
+ cp "${{ steps.branch.outputs.safe_name }}.md" wiki/
+
+ - name: Commit and push wiki changes
+ working-directory: wiki
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ # Add the file first
+ git add "${{ steps.branch.outputs.safe_name }}.md"
+
+ # Check if there are changes (after staging)
+ if git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+
+ git commit -m "Update ${{ steps.branch.outputs.name }} workspace status [automated]"
+ git push