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
140 changes: 140 additions & 0 deletions .github/scripts/sync-changes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env node

const { execSync } = require('child_process');
const { readFileSync } = require('fs');
const https = require('https');

// Configuration
const API_KEY = process.env.API_KEY;
const API_USER = process.env.API_USER;
const EVENT_NAME = process.env.GITHUB_EVENT_NAME;
const PR_BASE_SHA = process.env.PR_BASE_SHA;
const PR_HEAD_SHA = process.env.PR_HEAD_SHA;

const DEPLOY = EVENT_NAME === 'push';

// Validate PR has only one commit
function validateSingleCommit() {
if (DEPLOY) return; // GitHub branch protections disallow merge commits

const commitCount = execSync(`git rev-list --count ${PR_BASE_SHA}..${PR_HEAD_SHA}`, { encoding: 'utf8' }).trim();

if (parseInt(commitCount) !== 1) {
console.error(`❌ PR must contain exactly 1 commit, found ${commitCount}`);
console.error('Please squash your commits before merging');
process.exit(1);
}

console.log('✅ PR contains exactly 1 commit');
}

// Get file changes from the single commit
function getFileChanges() {
const updates = {};

// Get added/modified files from HEAD commit
const changedFiles = execSync('git diff --name-only --diff-filter=AM HEAD~1 HEAD', { encoding: 'utf8' })
.trim().split('\n').filter(Boolean)
.filter(path => { return !path.startsWith('.') && path.includes('/'); });

for (const file of changedFiles) {
try {
updates[file] = readFileSync(file, 'utf8');
} catch (error) {
console.error(`Could not read ${file}:`, error.message);
process.exit(1);
}
}

// Get deleted files from HEAD commit
const deletedFiles = execSync('git diff --name-only --diff-filter=D HEAD~1 HEAD', { encoding: 'utf8' })
.trim().split('\n').filter(Boolean)
.filter(path => { return !path.startsWith('.') && path.includes('/'); });

for (const file of deletedFiles) {
updates[file] = null; // null indicates deletion
}

return updates;
}

// Helper function to create PUT options for a file
function createPutOptions(file, content) {
const path = require('path').dirname(file);
const filename = require('path').basename(file, require('path').extname(file));
const url = `https://discourse.julialang.org/admin/customize/${path}/${filename}`;

const { URLSearchParams } = require('url');
const params = new URLSearchParams();
params.append('site_text[value]', content);
params.append('site_text[locale]', 'en');
const payload = params.toString();

const urlObj = new URL(url);
return {
url,
options: {
hostname: urlObj.hostname,
path: urlObj.pathname,
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Accept': '*/*',
'Api-Key': API_KEY,
'Api-Username': API_USER
}
},
payload
};
}

// Send updates to external API or print dry run
function sendUpdates(updates) {
const fileCount = Object.keys(updates).length;
if (fileCount === 0) {
console.log('No file changes detected');
return;
}
console.log(`\n🔍 Sending ${fileCount} updates:`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');

for (const [file, content] of Object.entries(updates)) {
if (content === null) {
console.error(`⚠️ Skipping deletion of ${file} (not supported)`);
process.exit(1);
}

const { url, options, payload } = createPutOptions(file, content);

console.log(`📝 UPDATE: ${file} → ${url}`);
console.log(` PUT ${url}`);
console.log(` Body: ${payload}\n`);
if (DEPLOY) {
// Actual API calls for main branch

const req = https.request(options, (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log(`✅ Updated ${file} → ${url} (HTTP ${res.statusCode})`);
} else {
console.error(`❌ Failed to update ${file}: HTTP ${res.statusCode}`);
process.exit(1);
}
});

req.on('error', (error) => {
console.error(`❌ Failed to update ${file}: ${error.message}`);
process.exit(1);
});

req.write(payload);
req.end();
}
}
}

// Main execution
console.log(`🚀 Running in ${DEPLOY ? 'LIVE' : 'DRY RUN'} mode`);

validateSingleCommit();
const updates = getFileChanges();
sendUpdates(updates);
31 changes: 31 additions & 0 deletions .github/workflows/sync-changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Sync File Changes

on:
push:
branches: [main] # Actual sync on main branch only
pull_request: # Dry run on all PRs

jobs:
sync-changes:
runs-on: ubuntu-latest
environment: "GitHub Actions CI"

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full history for PR validation

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'

- name: Run sync script
run: node .github/scripts/sync-changes.js
env:
API_KEY: ${{ secrets.API_KEY }}
API_USER: ${{ secrets.API_USER }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Customize any text used in Discourse to match your community’s voice and tone. BUT NOT HERE! Do this through the automated mechanism at https://github.com/JuliaDiscourse/SiteTexts.