projectops 4.25.1 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/interactive.js +1 -0
- package/src/core/copy/workflows.js +34 -6
- package/src/core/incoming.js +56 -0
- package/src/core/migration-guide.js +13 -0
- package/src/core/run-trace.js +1 -1
- package/src/index.js +1 -0
- package/src/ui/summary.js +20 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -407,6 +407,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
|
|
|
407
407
|
io.summary?.({
|
|
408
408
|
mode, types, version, deployBranch, deployBranchReady, migrationGuidePath,
|
|
409
409
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
410
|
+
skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
|
|
410
411
|
verification: result?.verification, // #549 설치 검증 결과
|
|
411
412
|
aiPrSummary, codeReviewCoderabbit, // #569 — 고른 것만 안내
|
|
412
413
|
logDir: files ? MIGRATION_DIR : null, // #561 기록 위치 안내
|
|
@@ -9,6 +9,7 @@ import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
|
|
|
9
9
|
import { isUnchanged, substituteEnv } from "../wizard-env.js";
|
|
10
10
|
import { isUserModified, readBaseline, writeBaseline, sha256 } from "../baseline.js";
|
|
11
11
|
import { substituteBranches } from "../branch-sub.js";
|
|
12
|
+
import { saveIncoming, lineDiffCounts } from "../incoming.js";
|
|
12
13
|
|
|
13
14
|
// 한 파일에 env 치환을 적용해 대상 파일을 갱신 (.sh configure_workflow_env 등가).
|
|
14
15
|
// values/useDefaults: env 계획(promptEnvPlan) 결과 — 미지정이면 기본값 경로(현행 force 동작).
|
|
@@ -79,7 +80,8 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
79
80
|
const projectTypesDir = join(tempDir, PATHS.workflowsDir, PATHS.projectTypesDir);
|
|
80
81
|
if (!exists(projectTypesDir)) throw new Error("템플릿 저장소 구조 오류 — project-types 폴더를 찾지 못했습니다.");
|
|
81
82
|
|
|
82
|
-
const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, copiedFiles: []
|
|
83
|
+
const counters = { copied: 0, skipped: 0, templateAdded: 0, optionalCopied: 0, copiedFiles: [],
|
|
84
|
+
skippedConflicts: [] }; // #654 — 유지한 사용자 수정본별 { filename, incoming, added, removed }
|
|
83
85
|
const deployValues = new Map(); // Map<type, Map<key,value>> — deploy 블록용 ask 값
|
|
84
86
|
counters.deployValues = deployValues;
|
|
85
87
|
// 브랜치 전략 (#477) — 표준(main/develop)이면 치환·가상비교 모두 no-op
|
|
@@ -87,6 +89,27 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
87
89
|
// values/useDefaults는 치환 경로에서만 의미 (isUnchanged는 내부에서 useDefaults:true 강제 — 가상 비교 무손상)
|
|
88
90
|
const envOptsFor = (type) => ({ type, projectPath: paths.get(type) || ".", repoName, resolvers, values: envValues, useDefaults: envUseDefaults, branches });
|
|
89
91
|
|
|
92
|
+
// 사용자 수정본을 유지할 때 새 템플릿 사본을 incoming에 남긴다 (#654).
|
|
93
|
+
// 실패는 경고 한 줄로 흡수한다 — 병합 안내 때문에 복사 전체가 죽으면 안 된다.
|
|
94
|
+
const onSkip = (srcDir, filename, type) => {
|
|
95
|
+
const info = { filename, incoming: "", added: null, removed: null };
|
|
96
|
+
try {
|
|
97
|
+
// 설치 때와 같은 렌더(기본값 치환 + 브랜치 치환)를 거쳐야 diff가 "진짜 차이"만 보여준다
|
|
98
|
+
const rendered = substituteBranches(
|
|
99
|
+
substituteEnv(readFileSync(join(srcDir, filename), "utf8"), { ...envOptsFor(type), useDefaults: true }), branches);
|
|
100
|
+
info.incoming = saveIncoming(targetRoot, filename, rendered);
|
|
101
|
+
const d = lineDiffCounts(readFileSync(join(workflowsDir, filename), "utf8"), rendered);
|
|
102
|
+
info.added = d.added; info.removed = d.removed;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
trace?.event("copy", "incoming-failed", filename, { message: e?.message || String(e) });
|
|
105
|
+
console.error(`⚠️ incoming 저장 실패(${filename}): ${e?.message || e} — 병합 안내 없이 계속합니다.`);
|
|
106
|
+
}
|
|
107
|
+
counters.skippedConflicts.push(info);
|
|
108
|
+
return info;
|
|
109
|
+
};
|
|
110
|
+
// 반환 객체의 열거 가능한 필드를 늘리지 않는다 (기존 호출부·테스트가 counters 모양에 기대고 있다)
|
|
111
|
+
Object.defineProperty(counters, "onSkip", { value: onSkip, enumerable: false });
|
|
112
|
+
|
|
90
113
|
// (1) common — 타입별과 동일한 판정·보호를 받는다 (#560).
|
|
91
114
|
//
|
|
92
115
|
// 종전에는 "unchanged면 스킵, 아니면 무조건 덮어쓰기"였다. 그런데 릴리스 파이프라인처럼
|
|
@@ -132,7 +155,7 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
|
|
|
132
155
|
continue;
|
|
133
156
|
}
|
|
134
157
|
// 사용자가 손댄 것이 확인된 파일 — 결정에 따라 처리(미지정이면 유지).
|
|
135
|
-
applyDecision(decisions.get(filename), commonDir, workflowsDir, filename, counters, trace);
|
|
158
|
+
applyDecision(decisions.get(filename), commonDir, workflowsDir, filename, counters, trace, "common");
|
|
136
159
|
}
|
|
137
160
|
}
|
|
138
161
|
|
|
@@ -251,7 +274,7 @@ function recordBaseline(workflowsDir, targetRoot, counters, previous, templateVe
|
|
|
251
274
|
|
|
252
275
|
// changed(기존에 있고 내용이 바뀐) 파일 1개를 결정에 따라 처리 (.sh 3440~3508 3지선 case 등가).
|
|
253
276
|
// 'skip'(기본): 기존 유지. 'backup': 기존→.bak 후 교체. 'template': 기존 유지 + 새 버전을 .template.yaml로.
|
|
254
|
-
function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null) {
|
|
277
|
+
function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace = null, type = "") {
|
|
255
278
|
const src = join(srcDir, filename);
|
|
256
279
|
const dst = join(workflowsDir, filename);
|
|
257
280
|
if (decision === "backup") {
|
|
@@ -272,7 +295,12 @@ function applyDecision(decision, srcDir, workflowsDir, filename, counters, trace
|
|
|
272
295
|
return;
|
|
273
296
|
}
|
|
274
297
|
counters.skipped++; // 'skip'/미지정/ESC → 기존 유지 (.sh S)·force 기본)
|
|
275
|
-
|
|
298
|
+
// #654 — 새 템플릿을 incoming에 남기고 diff 줄 수를 detail로 함께 기록
|
|
299
|
+
const inc = counters.onSkip?.(srcDir, filename, type) ?? {};
|
|
300
|
+
trace?.event("copy", "skipped-conflict", filename, {
|
|
301
|
+
decision: decision ?? "skip", note: "사용자 수정본 유지 — 병합 검토 후보",
|
|
302
|
+
incoming: inc.incoming || null, added: inc.added ?? null, removed: inc.removed ?? null,
|
|
303
|
+
});
|
|
276
304
|
}
|
|
277
305
|
|
|
278
306
|
// 대상 워크플로우 디렉토리에서 changed(충돌) 파일 목록만 뽑는다 — copyWorkflowsInteractive의 사전 조사용.
|
|
@@ -345,7 +373,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
345
373
|
// upstream(#557): 사용자가 손대지 않았고 템플릿만 바뀐 파일 — 물어볼 것 없이 최신으로 올린다.
|
|
346
374
|
for (const f of upstream) { copyFileSync(join(typeDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: type, reason: "baseline-match" }); }
|
|
347
375
|
// changed: 결정 Map에 따라 처리 (미지정=skip → 현행 force 동작과 동일)
|
|
348
|
-
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace);
|
|
376
|
+
for (const f of changed) applyDecision(decisions.get(f), typeDir, workflowsDir, f, counters, trace, type);
|
|
349
377
|
}
|
|
350
378
|
|
|
351
379
|
// server-deploy — deploy=docker-ssh일 때만 포함 (#439)
|
|
@@ -355,7 +383,7 @@ function copyWorkflowsForType(type, projectTypesDir, workflowsDir, ctx, counters
|
|
|
355
383
|
for (const f of unchanged) { counters.skipped++; trace?.event("copy", "skipped-unchanged", f, { group: `${type}/server-deploy` }); }
|
|
356
384
|
for (const f of newFiles) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "copied", f, { group: `${type}/server-deploy` }); }
|
|
357
385
|
for (const f of upstream) { copyFileSync(join(serverDeployDir, f), join(workflowsDir, f)); counters.copied++; counters.copiedFiles.push(f); trace?.event("copy", "upstream-updated", f, { group: `${type}/server-deploy`, reason: "baseline-match" }); }
|
|
358
|
-
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace);
|
|
386
|
+
for (const f of changed) applyDecision(decisions.get(f), serverDeployDir, workflowsDir, f, counters, trace, type);
|
|
359
387
|
}
|
|
360
388
|
|
|
361
389
|
// publish/<target> (opt-in — #439 publish 축. 타입은 파일 위치일 뿐 게이트가 아니다)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 건너뛴 워크플로의 새 템플릿 보관소 (#654).
|
|
2
|
+
// 사용자가 수정한 워크플로는 덮어쓰지 않고 유지한다. 대신 "새 템플릿이 어떻게 다른지"를
|
|
3
|
+
// 비교할 수 있도록 추적되지 않는 자리에 사본을 남긴다.
|
|
4
|
+
// 절대 .github/workflows 에 쓰지 않는다 — 거기 생긴 파일은 GitHub 이 실제 워크플로로 실행한다.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { writeText } from "./fsutil.js";
|
|
7
|
+
|
|
8
|
+
export const INCOMING_DIR = ".github/.projectops/incoming";
|
|
9
|
+
// 폴더가 스스로 추적 제외 규칙을 들고 다닌다 (logs/.gitignore 와 같은 방식, 루트 .gitignore 무수정)
|
|
10
|
+
export const INCOMING_GITIGNORE = [
|
|
11
|
+
"# projectops 가 건너뛴 워크플로의 새 템플릿 사본 — 저장소에 추적하지 않습니다.",
|
|
12
|
+
"# 병합 검토용 임시 자료이며 실행할 때마다 최신으로 덮어씁니다.",
|
|
13
|
+
"*",
|
|
14
|
+
"!.gitignore",
|
|
15
|
+
"",
|
|
16
|
+
].join("\n");
|
|
17
|
+
|
|
18
|
+
const toLines = (s) => {
|
|
19
|
+
const lines = String(s).split(/\r?\n/);
|
|
20
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // 끝 개행이 만드는 빈 줄 제외
|
|
21
|
+
return lines;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// 줄 단위 추가/삭제 개수. LCS 기반이라 줄이 이동만 한 경우도 과대 계상하지 않는다.
|
|
25
|
+
// removed: 사용자본에만 있는 줄, added: 새 템플릿에만 있는 줄.
|
|
26
|
+
export function lineDiffCounts(userText, incomingText) {
|
|
27
|
+
const a = toLines(userText);
|
|
28
|
+
const b = toLines(incomingText);
|
|
29
|
+
// 공통 접두/접미는 LCS에서 뺀다 (워크플로 대부분이 같아 표가 작아진다)
|
|
30
|
+
let s = 0;
|
|
31
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
32
|
+
let ea = a.length; let eb = b.length;
|
|
33
|
+
while (ea > s && eb > s && a[ea - 1] === b[eb - 1]) { ea--; eb--; }
|
|
34
|
+
const x = a.slice(s, ea); const y = b.slice(s, eb);
|
|
35
|
+
if (x.length * y.length > 25_000_000) {
|
|
36
|
+
// 비정상적으로 큰 파일 — 정확도 대신 크기 차이로 근사한다 (실패시키지 않는다)
|
|
37
|
+
return { added: y.length, removed: x.length };
|
|
38
|
+
}
|
|
39
|
+
let prev = new Array(y.length + 1).fill(0);
|
|
40
|
+
for (let i = 1; i <= x.length; i++) {
|
|
41
|
+
const cur = new Array(y.length + 1).fill(0);
|
|
42
|
+
for (let j = 1; j <= y.length; j++) {
|
|
43
|
+
cur[j] = x[i - 1] === y[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
|
|
44
|
+
}
|
|
45
|
+
prev = cur;
|
|
46
|
+
}
|
|
47
|
+
const common = prev[y.length];
|
|
48
|
+
return { added: y.length - common, removed: x.length - common };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// incoming 폴더에 새 템플릿을 저장한다. 반환: 대상 기준 상대 경로(항상 '/').
|
|
52
|
+
export function saveIncoming(targetRoot, filename, content) {
|
|
53
|
+
writeText(join(targetRoot, INCOMING_DIR, ".gitignore"), INCOMING_GITIGNORE);
|
|
54
|
+
writeText(join(targetRoot, INCOMING_DIR, filename), content);
|
|
55
|
+
return `${INCOMING_DIR}/${filename}`;
|
|
56
|
+
}
|
|
@@ -52,6 +52,9 @@ export function deriveWorkflowLists(events = []) {
|
|
|
52
52
|
added: pick("copied"),
|
|
53
53
|
replacedBak: pick("replaced-bak"),
|
|
54
54
|
skippedConflict: pick("skipped-conflict"),
|
|
55
|
+
// #654 — 건너뛴 파일별 새 템플릿 사본 경로와 diff 줄 수 (이벤트 detail에서 파생)
|
|
56
|
+
skippedConflictDetails: events.filter((e) => e.phase === "copy" && e.action === "skipped-conflict")
|
|
57
|
+
.map((e) => ({ file: e.target, incoming: e.detail?.incoming ?? "", added: e.detail?.added ?? null, removed: e.detail?.removed ?? null })),
|
|
55
58
|
templateAdded: pick("template-added"),
|
|
56
59
|
excluded: pick("excluded"),
|
|
57
60
|
};
|
|
@@ -112,6 +115,11 @@ export function renderGuideEntry(report) {
|
|
|
112
115
|
}
|
|
113
116
|
if (wf.skippedConflict.length) {
|
|
114
117
|
checklist.push(`- [ ] **기존 수정본 유지 ${wf.skippedConflict.length}개, 신형과 병합 검토**: ${wf.skippedConflict.map((f) => `\`${f}\``).join(", ")}`);
|
|
118
|
+
// #654 — 새 템플릿 사본이 있으면 비교 방법까지 적는다
|
|
119
|
+
for (const d of wf.skippedConflictDetails.filter((x) => x.incoming)) {
|
|
120
|
+
const cnt = d.added != null && d.removed != null ? ` (추가 ${d.added}줄, 삭제 ${d.removed}줄)` : "";
|
|
121
|
+
checklist.push(` - \`${d.file}\`${cnt}: \`diff -u .github/workflows/${d.file} ${d.incoming}\``);
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
124
|
if (wf.added.length || wf.replacedBak.length) {
|
|
117
125
|
checklist.push(`- [ ] **새/갱신 CICD가 요구하는 GitHub Secrets 등록 확인** (Settings → Secrets → Actions, \`_GITHUB_PAT_TOKEN\` 포함)`);
|
|
@@ -162,6 +170,11 @@ export function renderGuideEntry(report) {
|
|
|
162
170
|
L.push(` added: ${ylist(wf.added)}`);
|
|
163
171
|
L.push(` replaced_bak: ${ylist(wf.replacedBak)}`);
|
|
164
172
|
L.push(` skipped_conflict: ${ylist(wf.skippedConflict)}`);
|
|
173
|
+
const inc = wf.skippedConflictDetails.filter((d) => d.incoming);
|
|
174
|
+
if (inc.length) {
|
|
175
|
+
L.push(" skipped_conflict_incoming:");
|
|
176
|
+
for (const d of inc) L.push(` - { file: ${yq(d.file)}, incoming: ${yq(d.incoming)}, added: ${d.added ?? "null"}, removed: ${d.removed ?? "null"} }`);
|
|
177
|
+
}
|
|
165
178
|
L.push(` template_added: ${ylist(wf.templateAdded)}`);
|
|
166
179
|
if (legacyNeutralized.length) {
|
|
167
180
|
L.push(" legacy_neutralized:");
|
package/src/core/run-trace.js
CHANGED
|
@@ -50,7 +50,7 @@ function elapsed(t0, clockIso) {
|
|
|
50
50
|
const ERROR_ACTIONS = new Set(["error", "failed"]);
|
|
51
51
|
const WARN_ACTIONS = new Set([
|
|
52
52
|
"unresolved", "cancelled", "skipped-conflict", "leftover-old-gen",
|
|
53
|
-
"neutralized", "detected", "replaced-bak",
|
|
53
|
+
"neutralized", "detected", "replaced-bak", "incoming-failed",
|
|
54
54
|
]);
|
|
55
55
|
const DEBUG_ACTIONS = new Set([
|
|
56
56
|
"copied", "skipped-unchanged", "excluded", "substituted", "branch-substituted",
|
package/src/index.js
CHANGED
|
@@ -321,6 +321,7 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
|
|
|
321
321
|
printSummary({
|
|
322
322
|
mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
|
|
323
323
|
counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
|
|
324
|
+
skippedConflicts: result?.workflows?.skippedConflicts ?? [], // #654 병합 안내
|
|
324
325
|
verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
|
|
325
326
|
// #569 — 고른 것만 안내하려면 선택값이 필요하다
|
|
326
327
|
aiPrSummary: context.aiPrSummary, codeReviewCoderabbit: context.codeReviewCoderabbit,
|
package/src/ui/summary.js
CHANGED
|
@@ -90,6 +90,26 @@ export function printSummary(ctx, targetRoot = ".") {
|
|
|
90
90
|
err(" 📦 새로 설치·갱신된 워크플로우 없음 — 모두 최신 상태");
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// #654 — 사용자 수정본을 유지한 워크플로: 무엇이 얼마나 다른지, 어떻게 병합하는지 안내한다.
|
|
94
|
+
// 건너뛴 것이 없으면 아무것도 출력하지 않는다 (기존 출력 유지).
|
|
95
|
+
const kept = ctx?.skippedConflicts ?? [];
|
|
96
|
+
if (kept.length > 0) {
|
|
97
|
+
err("");
|
|
98
|
+
err(` ${YELLOW}✋ 수정한 워크플로 ${kept.length}개는 덮어쓰지 않고 그대로 두었습니다${NC}`);
|
|
99
|
+
for (const k of kept) {
|
|
100
|
+
const cnt = k.added != null && k.removed != null ? ` (새 템플릿 기준 추가 ${k.added}줄, 삭제 ${k.removed}줄)` : "";
|
|
101
|
+
err(` 📝 ${k.filename}${cnt}`);
|
|
102
|
+
if (k.incoming) {
|
|
103
|
+
err(` diff -u .github/workflows/${k.filename} ${k.incoming}`);
|
|
104
|
+
err(` git diff --no-index .github/workflows/${k.filename} ${k.incoming}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (kept.some((k) => k.incoming)) {
|
|
108
|
+
err(" 💡 새 템플릿 사본은 저장소에 추적되지 않는 .github/.projectops/incoming/ 에 있습니다.");
|
|
109
|
+
err(" 필요한 변경만 워크플로에 직접 옮겨 반영하세요.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
93
113
|
err("");
|
|
94
114
|
err(" 🔧 .github/scripts/");
|
|
95
115
|
err(" ├─ version_manager.sh");
|