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

on:
pull_request:
branches: [master]

jobs:
check-redirects:
name: Check redirects for renamed files
runs-on: ubuntu-latest
continue-on-error: true # Fail the check but don't block merge
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
HAS_JSON=false
if echo "$OUTPUT" | grep -Fq -- "---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
HAS_JSON=true
fi

echo "has_results=$HAS_JSON" >> $GITHUB_OUTPUT
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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 check will fail until redirects are added. 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.user.login === 'github-actions[bot]' || comment.user.login.includes('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,
});
console.log(`Updated existing comment ${existingComment.id}`);
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment,
});
console.log('Created new comment');
}

- name: Report failure if redirects are missing
if: steps.validate.outputs.exit_code == '1' && steps.validate.outputs.has_results == 'true'
run: |
echo "::warning::Missing redirects detected. Please add the redirects shown in the PR comment above."
echo "::warning::This check will show as failed, but will not block merging. However, adding redirects is recommended."
exit 1

- name: Success - no redirects needed
if: steps.validate.outputs.exit_code == '0'
run: |
echo "✅ No file renames detected, or all renames have corresponding redirects."
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