projectops 4.2.47 → 4.4.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 CHANGED
@@ -7,7 +7,7 @@
7
7
  > 이슈 등록부터 커밋, 보고서, 배포까지. 개발자는 코드만 작성하세요.
8
8
 
9
9
  <!-- AUTO-VERSION-SECTION: DO NOT EDIT MANUALLY -->
10
- ## 최신 버전 : v4.2.46 (2026-09-15)
10
+ ## 최신 버전 : v4.3.0 (2026-09-16)
11
11
 
12
12
  [전체 버전 기록 보기](CHANGELOG.md)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectops",
3
- "version": "4.2.47",
3
+ "version": "4.4.0",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -15,6 +15,7 @@ import {
15
15
  import { copyUtilModules } from "../core/copy/util.js";
16
16
  import { copyCoderabbit } from "../core/copy/coderabbit.js";
17
17
  import { ensureGitignore } from "../core/copy/gitignore.js";
18
+ import { verifyInstall } from "../core/verify.js";
18
19
 
19
20
  // context: { version, types, paths:Map, branch, versionCode, deployTarget, publishTargets, includeSecretBackup,
20
21
  // force, repoName, resolvers, now, today }
@@ -24,7 +25,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
24
25
  force = true, now, today, templateVersion = "unknown",
25
26
  deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
26
27
  changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true,
27
- deployBranch = "", intent = null, semverAuto = true } = context;
28
+ deployBranch = "", intent = null, semverAuto = true , appRelease = null } = context;
28
29
 
29
30
  // project_paths 마커 계산 (.sh existing_marker_in_dir 등가 — 대표 마커명)
30
31
  const pathMarkers = new Map();
@@ -41,7 +42,7 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
41
42
  version, types, paths, pathMarkers, branch, deployBranch, versionCode, now, today,
42
43
  deployValues,
43
44
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
44
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto },
45
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, intent, mode: "full", semverAuto, appRelease },
45
46
  }));
46
47
 
47
48
  // 2. README 버전 섹션
@@ -64,5 +65,14 @@ export function runFull(context, tempDir, targetRoot = ".", hooks = {}) {
64
65
  ensureGitignore(targetRoot);
65
66
  copySetupGuide(tempDir, targetRoot);
66
67
 
67
- return { workflows: wfCounters };
68
+ // 9. 설치 검증 (#549) — 디스크에 쓰인 최종 결과물을 다시 읽는다.
69
+ // 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에 의존하므로,
70
+ // 최종 내용을 보는 것이 실제 배포될 것과 같은 것을 보는 유일한 방법이다.
71
+ const verification = verifyInstall(targetRoot);
72
+ hooks.trace?.emit?.("verify", "scan", {
73
+ unresolved: verification.unresolved.length,
74
+ secrets: verification.secrets.size,
75
+ });
76
+
77
+ return { workflows: wfCounters, verification };
68
78
  }
@@ -113,6 +113,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
113
113
  // semver 자동 승격(#546) — 질문하지 않는다(#485 질문 부담 축소 방향 유지).
114
114
  // 저장값이 있으면 보존, 없으면 신규 통합만 ON. 기존 레포는 업데이트만으로 버전이 튀지 않는다.
115
115
  const semverAuto = existing?.options?.semverAuto ?? (existing ? false : true);
116
+ const appRelease = existing?.options?.appRelease ?? null; // #553 저장값 보존 (묻지 않음)
116
117
  const showOptional = mode === "full" || mode === "workflows";
117
118
  const realTty = process.stdout.isTTY === true;
118
119
 
@@ -281,7 +282,7 @@ export async function runInteractive(baseCtx, { cwd = process.cwd(), source = {
281
282
  const { now, today } = clock || utcNow();
282
283
  const ctx = createContext({
283
284
  mode, force: true, types, version, versionCode, branch, paths, deployTarget, publishTargets, includeSecretBackup,
284
- codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto,
285
+ codeReviewCoderabbit, changelogProvider, changelogBaseUrl, deployBranch, intent, semverAuto, appRelease,
285
286
  repoName, templateVersion, resolvers, envValues, envUseDefaults, now, today,
286
287
  // #502 — version 모드가 기존 full 기록을 강등하지 않도록 (full이 우세)
287
288
  recordMode: existing?.templateMode === "full" ? "full" : "version",
@@ -14,7 +14,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
14
14
  const { version, types = [], paths = new Map(), branch = "main", versionCode = 1,
15
15
  now, today, templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false,
16
16
  changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true,
17
- deployBranch = "", recordMode = "version", semverAuto = true } = context;
17
+ deployBranch = "", recordMode = "version", semverAuto = true , appRelease = null } = context;
18
18
 
19
19
  const pathMarkers = new Map();
20
20
  for (const [t] of paths) pathMarkers.set(t, markerForType(t));
@@ -25,7 +25,7 @@ export function runVersion(context, tempDir, targetRoot = ".") {
25
25
  // mode(#502): version 모드가 기존 full 통합 기록을 "version"으로 강등하지 않도록
26
26
  // 호출부가 recordMode로 기존 값을 넘긴다 (full이 우세 — 업데이트 재실행 범위 축소 방지).
27
27
  templateOptions: { templateVersion, deployTarget, publishTargets, includeSecretBackup, optionsDate: today,
28
- changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto },
28
+ changelogProvider, changelogBaseUrl, codeReviewCoderabbit, mode: recordMode, semverAuto, appRelease },
29
29
  }));
30
30
  addVersionSectionToReadme(version, targetRoot);
31
31
  copyScripts(tempDir, targetRoot);
@@ -8,6 +8,7 @@ import { copyWorkflows } from "../core/copy/workflows.js";
8
8
  import { copyScripts, copyConfigFolder, copySetupGuide } from "../core/copy/simple.js";
9
9
  import { copyUtilModules } from "../core/copy/util.js";
10
10
  import { convertLegacySingularType } from "../core/version-yml.js";
11
+ import { verifyInstall } from "../core/verify.js";
11
12
 
12
13
  export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
13
14
  const { types = [], force = true } = context;
@@ -31,7 +32,15 @@ export function runWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
31
32
  copyConfigFolder(tempDir, targetRoot);
32
33
  for (const t of types) copyUtilModules(tempDir, t, { force }, targetRoot);
33
34
  copySetupGuide(tempDir, targetRoot);
34
- return { workflows: wf };
35
+
36
+ // 설치 후 검증 (#549) — 워크플로우만 설치하는 모드라 오히려 더 필요하다.
37
+ const verification = verifyInstall(targetRoot);
38
+ hooks.trace?.emit?.("verify", "scan", {
39
+ unresolved: verification.unresolved.length,
40
+ secrets: verification.secrets.size,
41
+ });
42
+
43
+ return { workflows: wf, verification };
35
44
  }
36
45
 
37
46
  // 기존 version.yml에서 deploy: 블록을 제거하고 새로 append (.sh update_version_yml_deploy 멱등).
package/src/context.js CHANGED
@@ -26,6 +26,7 @@ export function createContext(overrides = {}) {
26
26
  deployBranch: "", // 릴리스 PR head 브랜치 (#456). 빈 값=metadata.deploy_branch 미출력
27
27
  intent: null, // 프로젝트 성격 (#485 — app/library/both/none/manual). null=미설정(역추론)
28
28
  semverAuto: null, // semver 자동 승격 (#546). null=미설정(신규 통합 true / 기존 레포 false)
29
+ appRelease: null, // 앱 심사 배포 레포인가 (#553). null=미설정(키 기록 안 함)
29
30
  templateVersion: "",
30
31
  tempDir: "",
31
32
  deployValues: new Map(), // "type.KEY" -> value
@@ -16,6 +16,10 @@ export function copyScripts(tempDir, targetRoot = ".") {
16
16
  "changelog_manager.py",
17
17
  "truncate_release_notes.sh", "truncate_release_notes.py",
18
18
  "issue_helper.py",
19
+ // 릴리스 워크플로우가 PAT 없이 머지한 뒤 후속 워크플로우를 깨울 때 호출 (#551).
20
+ "dispatch_downstream.py",
21
+ // AI PR SUMMARY 워크플로우가 요약 댓글을 작성·갱신할 때 호출 (#553).
22
+ "pr_summary_comment.py",
19
23
  "changelog_providers/_common.py", "changelog_providers/ladder.py",
20
24
  "changelog_providers/commit.py", "changelog_providers/github_ai.py",
21
25
  "changelog_providers/openai_compatible.py",
@@ -0,0 +1,109 @@
1
+ // 설치 후 검증 (#549) — 설치된 워크플로우를 다시 읽어 "이대로 돌아가는가"를 본다.
2
+ //
3
+ // 왜 설치 전이 아니라 후인가: 치환은 파일 단위로 흩어져 일어나고 auto 토큰은 resolver 결과에
4
+ // 의존한다. 최종 디스크 내용을 보는 것이 실제로 배포될 것과 같은 것을 보는 유일한 방법이다.
5
+ import { join } from "node:path";
6
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
7
+ import { PATHS } from "./paths.js";
8
+
9
+ // 치환 대상이 아닌 토큰 — 워크플로우 스크립트 안의 heredoc 구분자다. 값이 아니라 문법이므로
10
+ // 미치환 검사에서 제외한다. (예: cat <<'__SUH_FILE_CONTENT_EOF__')
11
+ const SENTINEL_RE = /^__SUH_[A-Z0-9_]*__$/;
12
+ const PLACEHOLDER_RE = /__[A-Z][A-Z0-9_]*__/g;
13
+
14
+ // 주석으로 죽어 있는 줄 — 실행되지 않으므로 검사 대상이 아니다.
15
+ // 템플릿에는 "[선택] ..." 예시 스텝이 통째로 주석 처리돼 들어 있는데, 이걸 세면
16
+ // 쓰지도 않는 Secret을 "등록하세요"라고 안내하게 된다.
17
+ const isCommented = (line) => /^\s*#/.test(line);
18
+
19
+ // 검사 대상 파일 목록. 호출부가 명시하지 않으면 설치된 워크플로우 전체를 스캔한다
20
+ // (복사 결과 목록을 조합해 넘기는 것보다, 최종 디스크 상태를 그대로 보는 쪽이 목적에 맞다).
21
+ function listWorkflowFiles(workflowsDir, filenames) {
22
+ if (filenames && filenames.length) return filenames;
23
+ if (!existsSync(workflowsDir)) return [];
24
+ try {
25
+ return readdirSync(workflowsDir, { withFileTypes: true })
26
+ .filter((e) => e.isFile() && /\.ya?ml$/.test(e.name))
27
+ .map((e) => e.name)
28
+ .sort();
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function readLines(workflowsDir, filename) {
35
+ const p = join(workflowsDir, filename);
36
+ if (!existsSync(p)) return null;
37
+ try {
38
+ return readFileSync(p, "utf8").split(/\r?\n/);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ // 미치환 플레이스홀더 스캔.
45
+ // auto 토큰 계산이 실패해도(예: application.yaml을 못 찾아 경로가 빈 문자열) 그 줄을 건드리지
46
+ // 않고 넘어가므로, __APPLICATION_YML_DIR__ 이 그대로 남은 워크플로우가 "설치 성공"으로 끝난다.
47
+ // 문제는 배포 시점에야 그 이름의 디렉터리가 만들어지며 드러난다.
48
+ //
49
+ // 반환: [{ filename, line, token, text }] — 사람이 바로 고칠 수 있게 줄 번호까지 준다.
50
+ export function scanUnsubstituted(workflowsDir, filenames = []) {
51
+ const found = [];
52
+ for (const filename of listWorkflowFiles(workflowsDir, filenames)) {
53
+ const lines = readLines(workflowsDir, filename);
54
+ if (!lines) continue;
55
+ lines.forEach((text, i) => {
56
+ if (isCommented(text)) return;
57
+ for (const token of text.match(PLACEHOLDER_RE) || []) {
58
+ if (SENTINEL_RE.test(token)) continue;
59
+ found.push({ filename, line: i + 1, token, text: text.trim() });
60
+ }
61
+ });
62
+ }
63
+ return found;
64
+ }
65
+
66
+ // GITHUB_TOKEN은 Actions가 자동 주입하므로 사용자가 등록할 대상이 아니다.
67
+ const AUTO_SECRETS = new Set(["GITHUB_TOKEN"]);
68
+
69
+ // 없어도 워크플로우가 도는 secret — 폴백이 문서화돼 있다. 필수와 섞어 "등록해야 동작합니다"라고
70
+ // 하면 안내 자체를 못 믿게 되므로 분리한다.
71
+ // MODEL_API_KEY → 없으면 GitHub Models(무료) → commit 규칙 fallback (#455 provider 사다리)
72
+ // _GITHUB_PAT_TOKEN → 없으면 GITHUB_TOKEN으로 머지하고 후속 워크플로우를 직접 깨운다 (#551)
73
+ export const OPTIONAL_SECRETS = new Set(["MODEL_API_KEY", "_GITHUB_PAT_TOKEN"]);
74
+
75
+ const SECRET_RE = /secrets\.([A-Z_][A-Z0-9_]*)/g;
76
+
77
+ // 설치된 워크플로우가 요구하는 GitHub Secret 목록.
78
+ // 완료 화면이 아무것도 안내하지 않는 바람에, 배포 워크플로우가 실제로 필요로 하는
79
+ // SERVER_HOST·SSH_KEY 같은 값이 드러나지 않았다. 설치 직후 상태로는 배포가 돌지 않는데
80
+ // 그 사실을 알 방법이 없다.
81
+ //
82
+ // 반환: Map<secretName, string[] 그 secret을 쓰는 파일명> (이름 오름차순)
83
+ export function collectRequiredSecrets(workflowsDir, filenames = []) {
84
+ const out = new Map();
85
+ for (const filename of listWorkflowFiles(workflowsDir, filenames)) {
86
+ const lines = readLines(workflowsDir, filename);
87
+ if (!lines) continue;
88
+ for (const line of lines) {
89
+ if (isCommented(line)) continue;
90
+ for (const m of line.matchAll(SECRET_RE)) {
91
+ const name = m[1];
92
+ if (AUTO_SECRETS.has(name) || OPTIONAL_SECRETS.has(name)) continue;
93
+ if (!out.has(name)) out.set(name, []);
94
+ const users = out.get(name);
95
+ if (!users.includes(filename)) users.push(filename);
96
+ }
97
+ }
98
+ }
99
+ return new Map([...out.entries()].sort(([a], [b]) => a.localeCompare(b)));
100
+ }
101
+
102
+ // 설치 직후 한 번에 돌리는 진입점. targetRoot 기준으로 워크플로우 폴더를 찾는다.
103
+ // 반환: { unresolved: [...], secrets: Map, ok: boolean }
104
+ export function verifyInstall(targetRoot = ".", filenames = []) {
105
+ const workflowsDir = join(targetRoot, PATHS.workflowsDir);
106
+ const unresolved = scanUnsubstituted(workflowsDir, filenames);
107
+ const secrets = collectRequiredSecrets(workflowsDir, filenames);
108
+ return { unresolved, secrets, ok: unresolved.length === 0 };
109
+ }
@@ -57,7 +57,7 @@ const HEADER = `# ==============================================================
57
57
  export function parseTemplateOptions(content) {
58
58
  const out = { deploy: null, publish: null, secretBackup: null,
59
59
  changelogProvider: null, changelogBaseUrl: null, codeReviewCoderabbit: null,
60
- deployBranch: null, intent: null, semverAuto: null };
60
+ deployBranch: null, intent: null, semverAuto: null, appRelease: null };
61
61
  // deploy_branch는 metadata 직속(#456) — template.options 밖이라 별도로 스캔한다.
62
62
  for (const line of String(content || "").split("\n")) {
63
63
  if (line.startsWith("#")) continue;
@@ -131,6 +131,12 @@ export function parseTemplateOptions(content) {
131
131
  out.semverAuto = m[1] === "true";
132
132
  continue;
133
133
  }
134
+ // 프로젝트 성격(#553) — 앱 심사로 이어지는 레포인가. 워크플로우와 스킬이 같은 값을 본다.
135
+ m = line.match(/^\s+app_release:\s*["']?(true|false)["']?/);
136
+ if (m) {
137
+ out.appRelease = m[1] === "true";
138
+ continue;
139
+ }
134
140
  m = line.match(/^\s+npm_publish:\s*(.+)/);
135
141
  if (m) {
136
142
  const v = strip(m[1]);
@@ -308,7 +314,7 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
308
314
  if (templateOptions) {
309
315
  const { templateVersion = "unknown", deployTarget = "docker-ssh", publishTargets = [], includeSecretBackup = false, optionsDate = today,
310
316
  changelogProvider = "github-ai", changelogBaseUrl = "", codeReviewCoderabbit = true, intent = null, mode = null,
311
- semverAuto = true } = templateOptions;
317
+ semverAuto = true, appRelease = null } = templateOptions;
312
318
  const publishJson = `[${publishTargets.map((t) => `"${t}"`).join(",")}]`;
313
319
  // intent(프로젝트 성격, #485) — 미지정이면 deploy/publish에서 역추론해 기록 (재통합 시 진입 질문 생략용)
314
320
  const intentVal = intent || inferIntent(deployTarget, publishTargets) || "manual";
@@ -326,6 +332,10 @@ export function buildVersionYml({ version, types = [], paths = new Map(), pathMa
326
332
  out += ` secret_backup: ${includeSecretBackup}\n`;
327
333
  // semver 자동 승격(#546) — 릴리스 시 커밋 제목으로 major/minor/patch 결정. false면 항상 patch.
328
334
  out += ` semver_auto: ${semverAuto} # 커밋 제목으로 버전 승격 폭 결정 (false면 항상 patch)\n`;
335
+ // 앱 심사 배포 레포 여부(#553) — 미지정이면 키를 쓰지 않는다(기존 레포 무변화).
336
+ if (appRelease !== null) {
337
+ out += ` app_release: ${appRelease} # 앱스토어·플레이스토어 심사로 이어지는 배포인가\n`;
338
+ }
329
339
  out += ` code_review:\n`;
330
340
  out += ` coderabbit: ${codeReviewCoderabbit}\n`;
331
341
  out += ` changelog:\n`;
package/src/index.js CHANGED
@@ -144,6 +144,9 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
144
144
  // semver 자동 승격(#546): 저장값 → (기존 통합 레포면 false / 신규면 true).
145
145
  // 이미 통합된 레포의 버전이 업데이트만으로 예고 없이 minor로 튀지 않게 하는 안전장치다.
146
146
  semverAuto: existing?.options?.semverAuto ?? (existing ? false : true),
147
+ // 앱 심사 배포 레포 여부(#553): 저장값만 보존한다. 마법사가 묻지 않으므로 새로 켜지 않는다
148
+ // (사용자가 version.yml에 직접 쓰거나 스킬이 기록한 값을 그대로 유지).
149
+ appRelease: existing?.options?.appRelease ?? null,
147
150
  repoName,
148
151
  // 실 resolver 4종 (.sh resolve_token 등가 — spring-app-yml 스텁 제거)
149
152
  resolvers: makeResolvers(cwd, repoName, paths),
@@ -221,6 +224,7 @@ export async function run(argv, { cwd = process.cwd(), source = { type: "git" },
221
224
  printSummary({
222
225
  mode: opts.mode, types, version, deployBranch: context.deployBranch, migrationGuidePath,
223
226
  counters: { workflows: result?.workflows?.copied ?? 0, workflowFiles: result?.workflows?.copiedFiles ?? [], utilModules: 0 },
227
+ verification: result?.verification, // #549 설치 후 검증 결과 (full/workflows 모드에서만 존재)
224
228
  }, cwd);
225
229
  return 0;
226
230
  }
package/src/ui/summary.js CHANGED
@@ -138,15 +138,55 @@ export function printSummary(ctx, targetRoot = ".") {
138
138
  }
139
139
  err("");
140
140
 
141
+ // 설치 후 검증 결과 (#549) — 문제가 있을 때만 펼치고, 정상이면 한 줄로 압축한다.
142
+ const verification = ctx?.verification;
143
+ if (verification) {
144
+ const unresolved = verification.unresolved || [];
145
+ err(SEPARATOR);
146
+ err("");
147
+ if (unresolved.length === 0) {
148
+ err("✅ 설치 검증: 치환되지 않은 값 없음");
149
+ } else {
150
+ err(`${YELLOW}⚠️ 설치 검증: 치환되지 않은 값 ${unresolved.length}건${NC}`);
151
+ err("");
152
+ err(" 아래 위치에 템플릿 값이 그대로 남아 있습니다. 그대로 두면 배포 시점에 실패합니다.");
153
+ err(" (프로젝트 구조를 자동으로 찾지 못한 경우입니다 — 직접 값을 채워주세요)");
154
+ err("");
155
+ for (const u of unresolved.slice(0, 10)) {
156
+ err(` ${u.filename}:${u.line} ${u.token}`);
157
+ }
158
+ if (unresolved.length > 10) err(` … 외 ${unresolved.length - 10}건`);
159
+ }
160
+ err("");
161
+
162
+ // 필요한 Secret 목록 — 설치된 워크플로우 기준이라 "이 레포에 실제로 필요한 것"만 나온다.
163
+ const secrets = verification.secrets;
164
+ if (secrets && secrets.size > 0) {
165
+ err(`${CYAN}🔑 등록이 필요한 GitHub Secret (${secrets.size}개)${NC}`);
166
+ err(" → Repository Settings > Secrets and variables > Actions");
167
+ err("");
168
+ for (const [name, users] of secrets) {
169
+ const where = users.length > 2 ? `${users.slice(0, 2).join(", ")} 외 ${users.length - 2}개` : users.join(", ");
170
+ err(` ${name}`);
171
+ err(` └ ${where}`);
172
+ }
173
+ err("");
174
+ err(" 💡 등록 전까지 해당 워크플로우는 실패합니다. 쓰지 않는 워크플로우라면 무시해도 됩니다.");
175
+ err("");
176
+ }
177
+ }
178
+
141
179
  // 필수 3가지 작업 안내 (.sh L5605~5625 — 원문 유지)
142
180
  err(SEPARATOR);
143
181
  err("");
144
- err(`${YELLOW}⚠️ 다음 3가지 작업을 완료해주세요:${NC}`);
182
+ err(`${YELLOW}⚠️ 다음 작업을 확인해주세요:${NC}`);
145
183
  err("");
146
- err(" 1️⃣ GitHub Personal Access Token 설정");
184
+ // #551 PAT는 없어도 릴리스가 완주한다. 있으면 후속 자동화가 더 매끄러워질 뿐이다.
185
+ err(" 1️⃣ (선택) GitHub Personal Access Token 설정");
186
+ err(" → 없어도 릴리스는 정상 동작합니다. 등록하면 릴리스 후 후속 워크플로우가");
187
+ err(" 더 확실하게 이어지고, 브랜치 보호 규칙이 있어도 자동 머지가 가능합니다.");
147
188
  err(" → Repository Settings > Secrets > Actions");
148
- err(" → Secret Name: _GITHUB_PAT_TOKEN");
149
- err(" → Scopes: repo, workflow");
189
+ err(" → Secret Name: _GITHUB_PAT_TOKEN / Scopes: repo, workflow");
150
190
  err("");
151
191
  // #490 — 마법사가 브랜치를 직접 생성(또는 존재 확인)했으면 같은 작업을 재지시하지 않는다
152
192
  if (deployBranchReady) {