Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
141 changes: 141 additions & 0 deletions .github/workflows/check-redirects-on-rename.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: Check Redirects on File Rename

on:
pull_request:
branches: [master]

jobs:
check-redirects:
name: Check redirects for renamed files
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for git diff

- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Set up git for diff
run: |
git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }}
echo "GITHUB_BASE_REF=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
echo "GITHUB_BASE_SHA=$(git rev-parse origin/${{ github.event.pull_request.base.ref }})" >> $GITHUB_ENV
echo "GITHUB_SHA=${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV

- name: Run redirect validation
id: validate
continue-on-error: true
run: |
set +e
OUTPUT=$(bun scripts/check-redirects-on-rename.ts 2>&1)
EXIT_CODE=$?
set -e

echo "$OUTPUT"

# Extract JSON output if present
if echo "$OUTPUT" | grep -q "---JSON_OUTPUT---"; then
JSON_OUTPUT=$(echo "$OUTPUT" | sed -n '/---JSON_OUTPUT---/,/---JSON_OUTPUT---/p' | sed '1d;$d')
echo "validation_result<<EOF" >> $GITHUB_OUTPUT
echo "$JSON_OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "has_results=true" >> $GITHUB_OUTPUT
else
echo "has_results=false" >> $GITHUB_OUTPUT
fi

# Save exit code
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT

- name: Post comment if redirects are missing
if: steps.validate.outputs.exit_code == '1' && steps.validate.outputs.has_results == 'true'
uses: actions/github-script@v7
with:
script: |
const validationResultJson = `${{ steps.validate.outputs.validation_result }}`;
Copy link

Choose a reason for hiding this comment

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

Bug: Unsafe interpolation of JSON in JS template literals

The validation_result JSON is directly interpolated into a JavaScript template literal without escaping. If the JSON contains backticks or template literal syntax, it can cause syntax errors or a code injection vulnerability.

Fix in Cursor Fix in Web

let validationResult;
try {
validationResult = JSON.parse(validationResultJson);
} catch (e) {
console.error('Failed to parse validation result:', e);
return;
}

const missingRedirects = validationResult.missingRedirects || [];

if (missingRedirects.length === 0) {
return;
}

// Group by redirects array type
const devDocsRedirects = missingRedirects.filter(mr => mr.isDeveloperDocs);
const userDocsRedirects = missingRedirects.filter(mr => !mr.isDeveloperDocs);

let comment = '## ⚠️ Missing Redirects Detected\n\n';
comment += 'This PR renames or moves MDX files, but some redirects may be missing from `redirects.js`.\n\n';
comment += 'Please add the following redirects to ensure old URLs continue to work:\n\n';

if (userDocsRedirects.length > 0) {
comment += '### User Docs Redirects (userDocsRedirects array)\n\n';
comment += '```javascript\n';
userDocsRedirects.forEach(mr => {
comment += ` {\n`;
comment += ` source: '${mr.oldUrl}',\n`;
comment += ` destination: '${mr.newUrl}',\n`;
comment += ` },\n`;
});
comment += '```\n\n';
}

if (devDocsRedirects.length > 0) {
comment += '### Developer Docs Redirects (developerDocsRedirects array)\n\n';
comment += '```javascript\n';
devDocsRedirects.forEach(mr => {
comment += ` {\n`;
comment += ` source: '${mr.oldUrl}',\n`;
comment += ` destination: '${mr.newUrl}',\n`;
comment += ` },\n`;
});
comment += '```\n\n';
}

comment += '---\n';
comment += '_Note: This is a warning and will not block your PR. However, adding redirects ensures old links continue to work._\n';

// Check for existing comments from this action
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const existingComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Missing Redirects Detected')
);

if (existingComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: comment,
});
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment,
});
}
171 changes: 171 additions & 0 deletions scripts/check-redirects-on-rename.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import fs from 'fs';
import path from 'path';

import {afterEach, beforeEach, describe, expect, it} from 'vitest';

import {
filePathToUrls,
parseRedirectsJs,
redirectMatches,
} from './check-redirects-on-rename';

// Mock redirects fixture
const mockRedirectsJs = `
const isDeveloperDocs = !!process.env.NEXT_PUBLIC_DEVELOPER_DOCS;

const developerDocsRedirects = [
{
source: '/sdk/old-path/',
destination: '/sdk/new-path/',
},
];

const userDocsRedirects = [
{
source: '/platforms/javascript/old-guide/',
destination: '/platforms/javascript/new-guide/',
},
{
source: '/platforms/python/old-tutorial',
destination: '/platforms/python/new-tutorial',
},
];
`;

describe('filePathToUrls', () => {
it('should convert docs file path to URLs', () => {
const result = filePathToUrls('docs/platforms/javascript/index.mdx');
expect(result.isDeveloperDocs).toBe(false);
expect(result.urls).toContain('/platforms/javascript/');
expect(result.urls).toContain('/platforms/javascript');
});

it('should convert develop-docs file path to URLs', () => {
const result = filePathToUrls('develop-docs/backend/api/index.mdx');
expect(result.isDeveloperDocs).toBe(true);
expect(result.urls).toContain('/backend/api/');
expect(result.urls).toContain('/backend/api');
});

it('should handle non-index files', () => {
const result = filePathToUrls('docs/platforms/javascript/guide.mdx');
expect(result.isDeveloperDocs).toBe(false);
expect(result.urls).toContain('/platforms/javascript/guide');
expect(result.urls).toContain('/platforms/javascript/guide/');
});

it('should return empty for paths outside docs/develop-docs', () => {
const result = filePathToUrls('scripts/something.mdx');
expect(result.isDeveloperDocs).toBe(false);
expect(result.urls).toEqual([]);
});
});

describe('parseRedirectsJs', () => {
let tempFile: string;

beforeEach(() => {
tempFile = path.join(process.cwd(), 'redirects-test-temp.js');
});

afterEach(() => {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
});

it('should parse developer docs redirects', () => {
fs.writeFileSync(tempFile, mockRedirectsJs);
const result = parseRedirectsJs(tempFile);
expect(result.developerDocsRedirects).toHaveLength(1);
expect(result.developerDocsRedirects[0].source).toBe('/sdk/old-path/');
expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/');
});

it('should parse user docs redirects', () => {
fs.writeFileSync(tempFile, mockRedirectsJs);
const result = parseRedirectsJs(tempFile);
expect(result.userDocsRedirects).toHaveLength(2);
expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/old-guide/');
expect(result.userDocsRedirects[0].destination).toBe(
'/platforms/javascript/new-guide/'
);
});

it('should return empty arrays for non-existent file', () => {
const result = parseRedirectsJs('/nonexistent/file.js');
expect(result.developerDocsRedirects).toEqual([]);
expect(result.userDocsRedirects).toEqual([]);
});

it('should parse real redirects.js file', () => {
const result = parseRedirectsJs('redirects.js');
// Should have some redirects
expect(result.developerDocsRedirects.length).toBeGreaterThan(0);
expect(result.userDocsRedirects.length).toBeGreaterThan(0);
});
});

describe('redirectMatches', () => {
it('should match exact redirects', () => {
const redirect = {
source: '/old/path/',
destination: '/new/path/',
};
expect(redirectMatches(redirect, '/old/path/', '/new/path/')).toBe(true);
expect(redirectMatches(redirect, '/different/path/', '/new/path/')).toBe(false);
});

it('should match redirects with path parameters', () => {
const redirect = {
source: '/platforms/:platform/old/:path*',
destination: '/platforms/:platform/new/:path*',
};
expect(
redirectMatches(
redirect,
'/platforms/javascript/old/guide',
'/platforms/javascript/new/guide'
)
).toBe(true);
expect(
redirectMatches(
redirect,
'/platforms/python/old/tutorial/',
'/platforms/python/new/tutorial/'
)
).toBe(true);
});

it('should handle redirects with single path parameter', () => {
const redirect = {
source: '/platforms/:platform/old',
destination: '/platforms/:platform/new',
};
expect(
redirectMatches(redirect, '/platforms/javascript/old', '/platforms/javascript/new')
).toBe(true);
expect(
redirectMatches(redirect, '/platforms/python/old', '/platforms/python/new')
).toBe(true);
});

it('should not match when source pattern does not match', () => {
const redirect = {
source: '/platforms/:platform/old',
destination: '/platforms/:platform/new',
};
expect(
redirectMatches(redirect, '/different/path', '/platforms/javascript/new')
).toBe(false);
});

it('should match destination with exact path when no params', () => {
const redirect = {
source: '/old/path',
destination: '/new/exact/path',
};
expect(redirectMatches(redirect, '/old/path', '/new/exact/path')).toBe(true);
expect(redirectMatches(redirect, '/old/path', '/different/path')).toBe(false);
});
});
Loading
Loading