fix(cms): deploy only on real changes, abort failed rebases, test publish
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Review follow-up on MITHOME-59:
- no-op publish no longer triggers a background deploy (deploy moved behind
a deterministic hadChanges flag)
- replace output-regex classification ('Already up to date.' also appears on
real publishes when the remote did not move, which misclassified them as
no_changes) with an explicit __NO_CONTENT_CHANGES__ marker echoed by the
shell skip-branch
- failed git pull --rebase is aborted immediately so the repo is never left
mid-rebase; the error is reported and nothing is pushed or deployed
- command + interpretation extracted to scripts/cms-publish.js
- CONTENT_EDITOR_CONTENT_DIR / CONTENT_EDITOR_DEPLOY_CMD env overrides enable
an integration test against throwaway git repos covering: no-change skip,
real publish + deploy, rebase conflict abort
- .gitignore: drop patterns already covered by .env.*
- user guide: new no-changes message
Closes MITHOME-60
This commit is contained in:
+1
-5
@@ -25,13 +25,9 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files (.env.* covers staging/production variants; examples stay tracked)
|
||||||
.env*.local
|
.env*.local
|
||||||
.env
|
.env
|
||||||
.env.staging
|
|
||||||
.env.production
|
|
||||||
.env.staging.*
|
|
||||||
.env.production.*
|
|
||||||
.env.*
|
.env.*
|
||||||
!.env.staging.example
|
!.env.staging.example
|
||||||
!.env.production.example
|
!.env.production.example
|
||||||
|
|||||||
+22
-18
@@ -15,9 +15,12 @@ const { exec } = require('child_process');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { validateContent } = require('./proto/src/content/schema');
|
const { validateContent } = require('./proto/src/content/schema');
|
||||||
const { renderMarkdown } = require('./scripts/markdown-render');
|
const { renderMarkdown } = require('./scripts/markdown-render');
|
||||||
|
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
|
||||||
|
|
||||||
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
|
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
|
||||||
const CONTENT_DIR = path.join(__dirname, 'proto', 'src', 'content');
|
// WHY: overridable so the publish integration test can run against a throwaway
|
||||||
|
// git clone instead of the real repository.
|
||||||
|
const CONTENT_DIR = process.env.CONTENT_EDITOR_CONTENT_DIR || path.join(__dirname, 'proto', 'src', 'content');
|
||||||
const BACKUP_DIR = path.join(__dirname, '.content-backups');
|
const BACKUP_DIR = path.join(__dirname, '.content-backups');
|
||||||
const MAX_REQUEST_BODY_BYTES = 256 * 1024;
|
const MAX_REQUEST_BODY_BYTES = 256 * 1024;
|
||||||
const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl');
|
const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl');
|
||||||
@@ -300,25 +303,26 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WHY: (git diff --cached --quiet || git commit) ensures we only commit when
|
// Command shape and result classification live in scripts/cms-publish.js
|
||||||
// staged changes exist. git pull --rebase origin main integrates remote changes
|
// (WHY comments there): commit only when staged changes exist, rebase with
|
||||||
// (or unpushed local commits) cleanly before git push origin main.
|
// abort-on-failure, deterministic no-changes marker instead of output matching.
|
||||||
const publishCmd = 'git add . && (git diff --cached --quiet || git commit -m "content: frissítve a CMS-ből") && git pull --rebase origin main && git push origin main';
|
exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
|
||||||
|
|
||||||
exec(publishCmd, { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
const combinedOutput = `${stdout}\n${stderr}`;
|
const outcome = interpretPublishResult(error, stdout, stderr);
|
||||||
const isNoChanges = /nothing to commit|nothing added to commit|working tree clean|everything up-to-date|already up to date/i.test(combinedOutput);
|
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result });
|
||||||
|
if (!outcome.ok) {
|
||||||
if (error && !isNoChanges) {
|
res.end(JSON.stringify({ ok: false, error: outcome.error }));
|
||||||
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'error' });
|
return;
|
||||||
res.end(JSON.stringify({ ok: false, error: stderr || stdout || error.message }));
|
|
||||||
} else {
|
|
||||||
// Deploy only the explicitly configured environment; never default to production.
|
|
||||||
exec(`cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1 &`);
|
|
||||||
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: isNoChanges ? 'no_changes' : 'ok' });
|
|
||||||
res.end(JSON.stringify({ ok: true, output: stdout || 'Sikeres publikálás' }));
|
|
||||||
}
|
}
|
||||||
|
// Deploy only when content actually changed — a no-op publish must not
|
||||||
|
// trigger a rebuild. Deploy only the explicitly configured environment;
|
||||||
|
// never default to production. Overridable for tests.
|
||||||
|
if (outcome.hadChanges) {
|
||||||
|
const deployCmd = process.env.CONTENT_EDITOR_DEPLOY_CMD
|
||||||
|
|| `cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1 &`;
|
||||||
|
exec(deployCmd);
|
||||||
|
}
|
||||||
|
res.end(JSON.stringify({ ok: true, output: outcome.output }));
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció
|
|||||||
### 🚀 Publikálás
|
### 🚀 Publikálás
|
||||||
|
|
||||||
- A Publikálás **commitolja és feltolja** a változtatásokat, majd elindítja a staging deployt.
|
- A Publikálás **commitolja és feltolja** a változtatásokat, majd elindítja a staging deployt.
|
||||||
- „No changes to commit" üzenet: nincs új változtatás — ez **nem hiba**.
|
- „Nincs új változtatás." üzenet: nincs új mentett változtatás — ez **nem hiba**, ilyenkor deploy sem indul.
|
||||||
- A publikálás korlátozva van (3 próbálkozás / 15 perc) a véletlen tömeges deploy elkerülésére.
|
- A publikálás korlátozva van (3 próbálkozás / 15 perc) a véletlen tömeges deploy elkerülésére.
|
||||||
- A deploy eltarthat 1-2 percig; az eredményt az Előnézet gombbal ellenőrizheted.
|
- A deploy eltarthat 1-2 percig; az eredményt az Előnézet gombbal ellenőrizheted.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Publish (git commit + push) command construction and result interpretation
|
||||||
|
// for the Content Editor. Extracted so it is unit-testable in isolation.
|
||||||
|
//
|
||||||
|
// WHY the shell shape:
|
||||||
|
// - `git diff --cached --quiet && echo MARKER || git commit` — commit only when
|
||||||
|
// staged changes exist; a skipped commit must NOT produce a failing exit code
|
||||||
|
// (that was the original bug: "nothing added to commit" surfaced as an error).
|
||||||
|
// - the MARKER echo is the only reliable signal for "no content changes": plain
|
||||||
|
// output matching ("Already up to date", "Everything up-to-date") also appears
|
||||||
|
// after REAL publishes (the pull prints it when the remote did not move), which
|
||||||
|
// used to misclassify genuine publishes as no-ops.
|
||||||
|
// - `git pull --rebase || (git rebase --abort; false)` — a failed rebase must be
|
||||||
|
// aborted, otherwise the repo stays mid-rebase and every later publish fails
|
||||||
|
// with "cannot pull with rebase".
|
||||||
|
|
||||||
|
const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
|
||||||
|
|
||||||
|
function buildPublishCommand(commitMessage) {
|
||||||
|
return [
|
||||||
|
'git add .',
|
||||||
|
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
|
||||||
|
'(git pull --rebase origin main || (git rebase --abort; false))',
|
||||||
|
'git push origin main',
|
||||||
|
].join(' && ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpretPublishResult(error, stdout, stderr) {
|
||||||
|
const hadChanges = !stdout.includes(NO_CHANGES_MARKER);
|
||||||
|
if (error) {
|
||||||
|
return { ok: false, hadChanges, result: 'error', error: stderr || stdout || error.message };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
hadChanges,
|
||||||
|
result: hadChanges ? 'ok' : 'no_changes',
|
||||||
|
output: hadChanges ? stdout : 'Nincs új változtatás.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult };
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the CMS publish flow (MITHOME-60):
|
||||||
|
*
|
||||||
|
* Unit (scripts/cms-publish.js):
|
||||||
|
* - command shape: marker echo, conditional commit, rebase-abort fallback
|
||||||
|
* - result interpretation: marker → no_changes; no marker → ok; error → error
|
||||||
|
*
|
||||||
|
* Integration (real server + throwaway git repos):
|
||||||
|
* 1. publish with no changes → ok, "Nincs új változtatás.", NO deploy
|
||||||
|
* 2. publish with real changes → ok, commit pushed, deploy ran
|
||||||
|
* 3. publish with rebase conflict → error reported, rebase aborted (repo not
|
||||||
|
* left mid-rebase), push never happened, no deploy
|
||||||
|
*/
|
||||||
|
const assert = require('assert/strict');
|
||||||
|
const { execFileSync, spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult } = require('../scripts/cms-publish');
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
// ── Unit ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
|
||||||
|
assert.ok(cmd.startsWith('git add . && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
|
||||||
|
assert.ok(cmd.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback');
|
||||||
|
assert.ok(cmd.endsWith('git push origin main'), 'push last');
|
||||||
|
|
||||||
|
const noChanges = interpretPublishResult(null, `__NO_CONTENT_CHANGES__\nAlready up to date.\nTo ssh://…\n * [new] nothing`, '');
|
||||||
|
assert.equal(noChanges.ok, true);
|
||||||
|
assert.equal(noChanges.hadChanges, false);
|
||||||
|
assert.equal(noChanges.result, 'no_changes');
|
||||||
|
assert.equal(noChanges.output, 'Nincs új változtatás.');
|
||||||
|
|
||||||
|
// Real publish on an unmoved remote: pull prints "Already up to date." AND the
|
||||||
|
// push line — the old regex misclassified this as no_changes; the marker must win.
|
||||||
|
const realPublish = interpretPublishResult(null, '[main abc1234] content: frissítve a CMS-ből\n 1 file changed\nAlready up to date.\nTo ssh://git…\n c2cc701..84a4527 main -> main\n', '');
|
||||||
|
assert.equal(realPublish.ok, true);
|
||||||
|
assert.equal(realPublish.hadChanges, true);
|
||||||
|
assert.equal(realPublish.result, 'ok');
|
||||||
|
|
||||||
|
const failed = interpretPublishResult(new Error('exit 1'), 'CONFLICT (content): Merge conflict in x\nerror: could not apply…', 'error: Failed to rebase');
|
||||||
|
assert.equal(failed.ok, false);
|
||||||
|
assert.equal(failed.result, 'error');
|
||||||
|
assert.equal(failed.error, 'error: Failed to rebase');
|
||||||
|
|
||||||
|
console.log('cms-publish unit tests: OK');
|
||||||
|
|
||||||
|
// ── Integration ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function git(cwd, ...args) {
|
||||||
|
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-publish-'));
|
||||||
|
const origin = path.join(tmp, 'origin.git');
|
||||||
|
const work = path.join(tmp, 'work');
|
||||||
|
const other = path.join(tmp, 'other');
|
||||||
|
const deployLog = path.join(tmp, 'deploy.log');
|
||||||
|
const auditFile = path.join(tmp, 'audit.jsonl');
|
||||||
|
const PORT = 4127;
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}`;
|
||||||
|
|
||||||
|
execFileSync('git', ['init', '--bare', '-b', 'main', origin]);
|
||||||
|
execFileSync('git', ['clone', origin, work]);
|
||||||
|
for (const repo of [work]) git(repo, 'config', 'user.email', 'test@test.hu'), git(repo, 'config', 'user.name', 'Test');
|
||||||
|
fs.writeFileSync(path.join(work, 'home.json'), '{"v": 1}\n');
|
||||||
|
git(work, 'add', '.');
|
||||||
|
git(work, 'commit', '-m', 'init');
|
||||||
|
git(work, 'push', '-u', 'origin', 'main');
|
||||||
|
|
||||||
|
const child = spawn('node', ['content-editor.js'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CONTENT_EDITOR_PORT: String(PORT),
|
||||||
|
CONTENT_EDITOR_AUDIT_FILE: auditFile,
|
||||||
|
CONTENT_EDITOR_CONTENT_DIR: work,
|
||||||
|
CONTENT_EDITOR_DEPLOY_CMD: `echo deploy >> ${deployLog}`,
|
||||||
|
CMS_USER: 'pub-test-user',
|
||||||
|
CMS_PASS: 'pub-test-pass',
|
||||||
|
CMS_DEPLOY_ENV: 'staging',
|
||||||
|
},
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForServer(timeoutMs = 10000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE}/logout`); // rate-limit-free probe
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('server did not start');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
const login = await fetch(`${BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user: 'pub-test-user', pass: 'pub-test-pass' }),
|
||||||
|
});
|
||||||
|
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
|
||||||
|
const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie } })).text();
|
||||||
|
const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
|
||||||
|
const res = await fetch(`${BASE}/publish`, { method: 'POST', headers: { Cookie: cookie, 'X-CSRF-Token': csrf } });
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deployCount() {
|
||||||
|
try { return fs.readFileSync(deployLog, 'utf8').trim().split('\n').filter(Boolean).length; } catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deploy command runs detached after the response — poll for its effect.
|
||||||
|
async function waitForDeployCount(expected, timeoutMs = 5000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (deployCount() === expected) return;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
assert.equal(deployCount(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await waitForServer();
|
||||||
|
|
||||||
|
// 1. no changes → ok, no deploy
|
||||||
|
const r1 = await publish();
|
||||||
|
assert.equal(r1.status, 200);
|
||||||
|
assert.equal(r1.body.ok, true);
|
||||||
|
assert.equal(r1.body.output, 'Nincs új változtatás.');
|
||||||
|
assert.equal(deployCount(), 0, 'no-op publish must not deploy');
|
||||||
|
|
||||||
|
// 2. real change → ok, pushed, deploy ran
|
||||||
|
fs.writeFileSync(path.join(work, 'home.json'), '{"v": 2}\n');
|
||||||
|
const r2 = await publish();
|
||||||
|
assert.equal(r2.body.ok, true);
|
||||||
|
await waitForDeployCount(1);
|
||||||
|
assert.match(git(work, 'log', '-1', '--format=%s'), /content: frissítve a CMS-ből/);
|
||||||
|
assert.match(git(work, 'status', '--porcelain'), /^$/, 'worktree clean after publish');
|
||||||
|
|
||||||
|
// 3. rebase conflict → error, rebase aborted, no push, no deploy
|
||||||
|
execFileSync('git', ['clone', origin, other]);
|
||||||
|
for (const args of [['config', 'user.email', 'o@test.hu'], ['config', 'user.name', 'Other']]) git(other, ...args);
|
||||||
|
fs.writeFileSync(path.join(other, 'home.json'), '{"v": "remote"}\n');
|
||||||
|
git(other, 'add', '.');
|
||||||
|
git(other, 'commit', '-m', 'remote edit');
|
||||||
|
git(other, 'push', 'origin', 'main');
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(work, 'home.json'), '{"v": "local"}\n');
|
||||||
|
const r3 = await publish();
|
||||||
|
assert.equal(r3.body.ok, false, 'conflicting publish must report an error');
|
||||||
|
await new Promise(r => setTimeout(r, 700)); // give a would-be deploy time to (not) appear
|
||||||
|
assert.equal(deployCount(), 1, 'failed publish must not deploy');
|
||||||
|
// The local commit exists locally (created before the rebase) but must NOT be pushed.
|
||||||
|
assert.equal(git(work, 'log', '-1', '--format=%s').trim(), 'content: frissítve a CMS-ből');
|
||||||
|
const localCommit = git(work, 'rev-parse', 'HEAD');
|
||||||
|
assert.ok(!git(work, 'ls-remote', origin, 'refs/heads/main').includes(localCommit), 'local conflicted commit must not be pushed');
|
||||||
|
assert.ok(!git(work, 'status').includes('rebase in progress'), 'rebase must be aborted');
|
||||||
|
|
||||||
|
// audit trail classification
|
||||||
|
const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||||
|
const publishResults = audit.filter(e => e.event === 'publish_finished').map(e => e.result);
|
||||||
|
assert.deepEqual(publishResults, ['no_changes', 'ok', 'error']);
|
||||||
|
|
||||||
|
console.log('Content Editor publish integration test: OK');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(err => { console.error('❌', err.message); process.exitCode = 1; })
|
||||||
|
.finally(() => {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user