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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.GH_TOKEN }}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and display the deploy.yml file, particularly around line 53
fd -t f 'deploy.yml' -x cat -n {} | grep -A 5 -B 5 'github_token'

Repository: reportportal/docs

Length of output: 43


🏁 Script executed:

# Search for GH_TOKEN references across all workflow files
rg -n 'GH_TOKEN' --glob '*.yml' --glob '*.yaml'

Repository: reportportal/docs

Length of output: 43


🏁 Script executed:

# Check if there's any documentation about secret setup
fd -t f -e md | xargs rg -l 'GH_TOKEN|GITHUB_TOKEN' 2>/dev/null | head -20

Repository: reportportal/docs

Length of output: 43


🏁 Script executed:

# Look at the full deploy.yml file to understand context
find . -name 'deploy.yml' -path '*/.github/workflows/*' -exec wc -l {} \; -exec echo {} \; | paste - - | sort -n | head -5

Repository: reportportal/docs

Length of output: 139


🏁 Script executed:

# Find deploy.yml using git
git ls-files | grep -i deploy.yml

Repository: reportportal/docs

Length of output: 89


🏁 Script executed:

# Search for GH_TOKEN with a simpler rg command
rg 'GH_TOKEN' --type yaml

Repository: reportportal/docs

Length of output: 43


🏁 Script executed:

# Look for any workflow files that reference tokens
git ls-files '.github/workflows/' | head -20

Repository: reportportal/docs

Length of output: 183


🏁 Script executed:

# Read the deploy.yml file with line numbers
cat -n .github/workflows/deploy.yml

Repository: reportportal/docs

Length of output: 1978


🏁 Script executed:

# Search for GH_TOKEN in all file types
rg 'GH_TOKEN'

Repository: reportportal/docs

Length of output: 317


🏁 Script executed:

# Check README and documentation for secret setup info
rg 'GH_TOKEN|secret' README.md 2>/dev/null || echo "Not found in README"

Repository: reportportal/docs

Length of output: 80


🏁 Script executed:

# Check what tokens are used in other workflows
rg 'github_token|GITHUB_TOKEN|GH_TOKEN' .github/workflows/

Repository: reportportal/docs

Length of output: 217


🏁 Script executed:

# Check if there's any setup documentation or docs folder
find . -name '*.md' -type f | head -20

Repository: reportportal/docs

Length of output: 932


Ensure GH_TOKEN secret is provisioned and properly scoped.

The change from the built-in GITHUB_TOKEN to a custom GH_TOKEN secret (used in both deploy.yml and sync-releases.yml) requires manual configuration. If this secret is not set in the repository settings, the deploy step will fail.

Verify:

  1. The GH_TOKEN secret is configured in repository settings.
  2. The PAT has the minimum required scopes (typically repo or contents: write).
  3. There is a rotation/expiry plan for the PAT.
🤖 Prompt for AI Agents
In @.github/workflows/deploy.yml at line 53, The workflows now rely on a custom
secret named GH_TOKEN (referenced as github_token: ${{ secrets.GH_TOKEN }}) so
ensure that a Personal Access Token is added to the repository Secrets as
GH_TOKEN, that the PAT includes the minimum required scopes (e.g., repo or
contents: write) and that you have a rotation/expiry plan for it; update any
consumer workflows (deploy step and the sync-releases usage of secrets.GH_TOKEN)
to either validate the secret exists (fail fast with a clear error) or fall back
to GITHUB_TOKEN if appropriate, and document the required secret name and scope
in the repo README or CONTRIBUTING guide.

publish_dir: ./${{ env.BUILD_DIR }}
publish_branch: gh-pages
commit_message: ${{ github.event.head_commit.message }}
77 changes: 77 additions & 0 deletions .github/workflows/sync-releases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright 2026 EPAM Systems
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

name: Sync GitHub Releases

on:
workflow_dispatch:
inputs:
scope:
description: 'How many releases to check'
required: false
default: 'latest'
type: choice
options:
- latest
- last-n
- last-month
- all
count:
description: 'Number of releases to check (only used with "last-n")'
required: false
default: '5'
type: string
Comment on lines +29 to +33
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

No validation that count is a positive integer.

The count input is a free-form string. If a user enters a non-numeric value (e.g., "abc"), SYNC_SCOPE becomes last-abc, which silently falls back to "latest" in the script. Consider adding a validation step, or at minimum, document the expected format in the description.

🤖 Prompt for AI Agents
In @.github/workflows/sync-releases.yml around lines 29 - 33, The input "count"
is not validated and can be non-numeric, causing SYNC_SCOPE to become invalid;
fix by (a) updating the input to enforce numeric type and document format
(change the input declaration for count to type: number and adjust the
description to "Number of releases to check (positive integer, only used with
\"last-n\")"), and/or (b) add a validation step early in the workflow that reads
the input "count" and checks it against a regex like ^[1-9]\d*$ (fail the job
with a clear error if it doesn't match) before constructing SYNC_SCOPE so you
never set SYNC_SCOPE to an invalid "last-<value>".


env:
RELEASES_URL: https://api.github.com/repos/reportportal/reportportal/releases

jobs:
sync-releases:
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20

- name: Sync release pages
run: node scripts/sync-releases.js
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
RELEASES_URL: ${{ env.RELEASES_URL }}
SYNC_SCOPE: ${{ inputs.scope == 'last-n' && format('last-{0}', inputs.count) || inputs.scope }}

- name: Check for changes
id: changes
run: |
if [ -n "$(git status docs/releases/ --porcelain)" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No new release pages to add."
fi

- name: Commit and push new release pages
if: steps.changes.outputs.has_changes == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/releases/
git commit -m "docs: sync release pages from GitHub Releases"
git push
200 changes: 200 additions & 0 deletions scripts/sync-releases.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
const fs = require('fs');
const path = require('path');

const RELEASES_API_URL =
process.env.RELEASES_URL ||
'https://api.github.com/repos/reportportal/reportportal/releases';
const RELEASES_PER_PAGE = 100;
const RELEASES_DIR = path.join(__dirname, '..', 'docs', 'releases');

async function main() {
const releases = await fetchAllReleases();

const published = releases.filter((r) => !r.draft);

published.sort(
(a, b) => new Date(b.published_at) - new Date(a.published_at),
);

const scope = (process.env.SYNC_SCOPE || 'latest').trim().toLowerCase();
const filtered = applyScope(published, scope);
console.log(`Scope: "${scope}" -> checking ${filtered.length} of ${published.length} releases.\n`);

fs.mkdirSync(RELEASES_DIR, { recursive: true });

const existingFiles = new Set(
fs.readdirSync(RELEASES_DIR).map((f) => f.toLowerCase()),
);

let created = 0;

for (let i = 0; i < filtered.length; i++) {
const release = filtered[i];
const name = release.name?.trim();
if (!name) {
console.log(`Skipping release id=${release.id} - empty name`);
continue;
}

const fileName = buildFileName(name);

if (existingFiles.has(fileName.toLowerCase())) {
console.log(`Already exists: ${fileName}`);
continue;
}

const sidebarLabel = buildSidebarLabel(name);
const body = transformBody(release.body || '');
const position = i + 1;
Comment on lines +46 to +48
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

sidebar_position will conflict with previously created files on subsequent runs.

position is derived from the index in the current filtered array (line 48), but existing files are skipped without updating their front matter. When new releases are published and the script is re-run, the new release shifts all indices, yet the already-written files retain their old sidebar_position values. This produces duplicates and breaks the intended Docusaurus sidebar ordering.

For example: Run 1 creates releases at positions 1, 2, 3. A new release is added. Run 2 assigns position 1 to the new release, but the old file at position 1 is never updated.

Consider one of:

  • Rewriting all files on every run (remove the skip-existing logic).
  • Computing position only from the count of newly created files.
  • Using published_at as a stable sort key (e.g., a timestamp-based position) so that each release always gets a deterministic, non-colliding position.
🤖 Prompt for AI Agents
In `@scripts/sync-releases.js` around lines 46 - 48, The sidebar_position conflict
happens because position = i + 1 is based on the current filtered index but
existing files are skipped, so their front-matter positions aren't updated and
new inserts shift indices; to fix, stop using the loop index and instead compute
a deterministic, non-colliding position: either (A) track createdCount and set
position = createdCount + 1 only for newly-created files (increment createdCount
when you actually write a file), or (B) sort releases by a stable key like
release.published_at and compute position from that order (e.g., position = 1 +
index in the published_at-sorted array) and then rewrite front-matter for all
files accordingly; update the code around buildSidebarLabel(name),
transformBody(...), and where position is assigned so position is derived from
createdCount or the published_at-sorted index instead of i.


const content = [
'---',
`sidebar_position: ${position}`,
`sidebar_label: ${sidebarLabel}`,
'---',
'',
`# ${sidebarLabel}`,
'',
body,
'',
].join('\n');

const filePath = path.join(RELEASES_DIR, fileName);
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`Created: ${fileName}`);
created++;
}

console.log(
`\nDone. ${created} new file(s) created, ${filtered.length - created} already existed or skipped.`,
);

if (created > 0) {
fs.writeFileSync(
path.join(__dirname, '..', '.releases-updated'),
String(created),
);
}
}

function applyScope(releases, scope) {
if (scope === 'all') return releases;

if (scope === 'latest') return releases.slice(0, 1);

const lastN = scope.match(/^last-(\d+)$/);
if (lastN) return releases.slice(0, parseInt(lastN[1], 10));

if (scope === 'last-month') {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 1);
return releases.filter((r) => new Date(r.published_at) >= cutoff);
}
Comment on lines +88 to +92
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

last-month cutoff may be inaccurate on month-boundary dates.

Date.setMonth(getMonth() - 1) on dates like March 31 rolls forward because February doesn't have 31 days (resulting in ~March 3 instead of ~February 28). This could silently include or exclude releases near the boundary.

A safer approach is to also clamp the day:

Proposed fix
   if (scope === 'last-month') {
     const cutoff = new Date();
-    cutoff.setMonth(cutoff.getMonth() - 1);
+    const targetMonth = cutoff.getMonth() - 1;
+    cutoff.setDate(1);          // avoid day overflow
+    cutoff.setMonth(targetMonth);
     return releases.filter((r) => new Date(r.published_at) >= cutoff);
   }
🤖 Prompt for AI Agents
In `@scripts/sync-releases.js` around lines 88 - 92, The cutoff calculation using
cutoff.setMonth(cutoff.getMonth() - 1) can roll forward on month-boundary dates
(e.g., Mar 31 -> Mar 3); replace that logic in the scope === 'last-month' branch
by computing now = new Date(), then create cutoff = new Date(now.getFullYear(),
now.getMonth() - 1, now.getDate()) and if cutoff.getMonth() !== (now.getMonth()
- 1 + 12) % 12, clamp to the previous month's last day with cutoff = new
Date(now.getFullYear(), now.getMonth(), 0); keep the existing
releases.filter((r) => new Date(r.published_at) >= cutoff) unchanged.


console.warn(`Unknown scope "${scope}", falling back to "latest".`);
return releases.slice(0, 1);
}

async function fetchAllReleases() {
const all = [];
let page = 1;

while (true) {
const url = `${RELEASES_API_URL}?per_page=${RELEASES_PER_PAGE}&page=${page}`;
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'reportportal-docs-sync',
};

if (process.env.GH_TOKEN) {
headers.Authorization = `Bearer ${process.env.GH_TOKEN}`;
} else {
console.warn('GH_TOKEN not set - using unauthenticated API (60 req/hr limit)');
}
Comment on lines +110 to +114
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

GH_TOKEN warning is emitted on every paginated request instead of once.

The token check is inside the while (true) loop, so the warning prints on every page fetch. Move it before the loop.

Proposed fix
 async function fetchAllReleases() {
   const all = [];
   let page = 1;
+  const headers = {
+    Accept: 'application/vnd.github+json',
+    'X-GitHub-Api-Version': '2022-11-28',
+    'User-Agent': 'reportportal-docs-sync',
+  };
+
+  if (process.env.GH_TOKEN) {
+    headers.Authorization = `Bearer ${process.env.GH_TOKEN}`;
+  } else {
+    console.warn('GH_TOKEN not set - using unauthenticated API (60 req/hr limit)');
+  }
 
   while (true) {
     const url = `${RELEASES_API_URL}?per_page=${RELEASES_PER_PAGE}&page=${page}`;
-    const headers = {
-      Accept: 'application/vnd.github+json',
-      'X-GitHub-Api-Version': '2022-11-28',
-      'User-Agent': 'reportportal-docs-sync',
-    };
-
-    if (process.env.GH_TOKEN) {
-      headers.Authorization = `Bearer ${process.env.GH_TOKEN}`;
-    } else {
-      console.warn('GH_TOKEN not set - using unauthenticated API (60 req/hr limit)');
-    }
 
     console.log(`Fetching releases page ${page}...`);
     const res = await fetch(url, { headers });
🤖 Prompt for AI Agents
In `@scripts/sync-releases.js` around lines 110 - 114, The GH token check and
warning are inside the while (true) pagination loop causing the warning to print
for every page; move the block that checks process.env.GH_TOKEN and sets
headers.Authorization (or emits the console.warn) to execute once before
entering the loop so headers is prepared once and the warning is only emitted a
single time; update the code that currently references headers inside the loop
to use the preconfigured headers.


console.log(`Fetching releases page ${page}...`);
const res = await fetch(url, { headers });

if (!res.ok) {
throw new Error(`GitHub API ${res.status}: ${res.statusText}`);
}

const data = await res.json();
if (!Array.isArray(data) || data.length === 0) break;

all.push(...data);
if (data.length < RELEASES_PER_PAGE) break;
page++;
}

console.log(`Fetched ${all.length} releases total.\n`);
return all;
}

function transformBody(body) {
let result = body;

result = result.replace(/\r\n/g, '\n');

result = result.replace(/<img\b[^>]*>/gi, (tag) => {
const srcMatch = tag.match(/src="([^"]+)"/i);
const altMatch = tag.match(/alt="([^"]*)"/i);
const src = srcMatch ? srcMatch[1] : '';
const alt = altMatch ? altMatch[1] : 'image';
return src ? `![${alt}](${src})` : '';
});

result = result.replace(
/(?<!["\(])(?<!\]\()https?:\/\/[^\s)<>\]]+/g,
(url) => `[${extractLabel(url)}](${url})`,
);

return result;
}

function extractLabel(url) {
try {
const parts = new URL(url).pathname.split('/').filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : url;
} catch {
return url;
}
}

function buildFileName(name) {
let v = stripPrefix(name);

if (/^BETA/i.test(v)) {
const nums = v.match(/[\d.]+/);
return nums ? `Version${nums[0]}RC.md` : `Version${v.replace(/\s+/g, '')}.md`;
}

v = v.replace(/\s+(Final|RC|Beta|Alpha)$/i, '').trim();

return `Version${v}.md`;
}
Comment on lines +165 to +176
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

File-name collisions between release stages, and "BETA" mislabeled as "RC".

Two issues:

  1. Collision: Line 173 strips Final, RC, Beta, and Alpha suffixes, so releases like "5.12 RC" and "5.12 Final" both produce Version5.12.md. The second one is silently dropped. If this is intentional (keep only the latest stage), it deserves a code comment explaining the design choice. If not, the suffix should be preserved in the filename.

  2. Semantic mismatch: Lines 168-170 treat BETA names as RC (filename VersionX.YRC.md). Beta and RC are distinct stages. Consider using VersionX.YBeta.md instead, or preserving the original qualifier.

🤖 Prompt for AI Agents
In `@scripts/sync-releases.js` around lines 165 - 176, The buildFileName function
currently normalizes release names by stripping stage suffixes via stripPrefix
and v.replace(...Final|RC|Beta|Alpha...), which causes collisions between stages
(e.g., "5.12 RC" and "5.12 Final" both become Version5.12.md) and also mislabels
BETA as RC in the /^BETA/i branch; update buildFileName to preserve the stage
qualifier in the generated filename (or add a clear code comment if intentional
to dedupe stages) and change the /^BETA/i branch to emit a Beta-specific
filename (e.g., Version{nums}Beta.md or preserve the original qualifier) rather
than Version{nums}RC.md; reference the buildFileName function and stripPrefix
call to locate and adjust the suffix-handling logic accordingly.


function buildSidebarLabel(name) {
let v = stripPrefix(name);

if (/^BETA/i.test(v)) {
const nums = v.match(/[\d.]+/);
return nums ? `Version ${nums[0]} RC` : `Version ${v}`;
}

return `Version ${v}`;
}

function stripPrefix(name) {
return name
.replace(/^Release\s+/i, '')
.replace(/^ReportPortal\s+/i, '')
.replace(/^v\.?\s*/i, '')
.trim();
}

main().catch((err) => {
console.error(err);
process.exit(1);
});