diff --git a/.github/scripts/sync-changes.js b/.github/scripts/sync-changes.js new file mode 100644 index 0000000..1c8143d --- /dev/null +++ b/.github/scripts/sync-changes.js @@ -0,0 +1,134 @@ +#!/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.warn(`Could not read ${file}:`, error.message); + } + } + + // 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', + 'Content-Length': Buffer.byteLength(payload), + '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)`); + continue; + } + + 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) => { + console.log(`βœ… Updated ${file} β†’ ${url} (HTTP ${res.statusCode})`); + }); + + req.on('error', (error) => { + console.error(`❌ Failed to update ${file}: ${error.message}`); + }); + + req.write(payload); + req.end(); + } + } +} + +// Main execution +console.log(`πŸš€ Running in ${DEPLOY ? 'LIVE' : 'DRY RUN'} mode`); + +validateSingleCommit(); +const updates = getFileChanges(); +sendUpdates(updates); diff --git a/.github/workflows/sync-changes.yml b/.github/workflows/sync-changes.yml new file mode 100644 index 0000000..b3b6514 --- /dev/null +++ b/.github/workflows/sync-changes.yml @@ -0,0 +1,30 @@ +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 + + 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 }} \ No newline at end of file diff --git a/site_texts/admin_js.admin.config.site_texts.header_description.md b/site_texts/admin_js.admin.config.site_texts.header_description.md new file mode 100644 index 0000000..f42134d --- /dev/null +++ b/site_texts/admin_js.admin.config.site_texts.header_description.md @@ -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 [github.com/JuliaDiscourse/SiteTexts](https://github.com/JuliaDiscourse/SiteTexts).