projectops 4.28.2 → 4.28.4

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.28.1 (2026-10-01)
10
+ ## 최신 버전 : v4.28.3 (2026-10-01)
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.28.2",
3
+ "version": "4.28.4",
4
4
  "description": "ProjectOps — 완전 자동화 GitHub 프로젝트 관리 템플릿 통합 CLI",
5
5
  "keywords": [
6
6
  "devops",
@@ -162,7 +162,16 @@ export function localChecks(cwd = ".") {
162
162
  }
163
163
 
164
164
  // ── 원격 점검 ─────────────────────────────────────────────────────────
165
- export async function remoteChecks(slug, token, requiredSecrets = []) {
165
+ // permissions 블록을 한 번도 선언하지 않은 워크플로우 — 이때만 저장소 기본 권한(Settings)이 그대로 적용된다.
166
+ // 선언이 있으면 저장소 기본값이 read 여도 선언한 범위로 동작한다 (조직 read 기본값에서 릴리스 automerge 실측, #723).
167
+ export function workflowsWithoutPermissions(cwd = ".") {
168
+ const wfDir = join(cwd, PATHS.workflowsDir);
169
+ if (!existsSync(wfDir)) return [];
170
+ return readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f))
171
+ .filter((f) => !/^\s*permissions:/m.test(readFileSync(join(wfDir, f), "utf8")));
172
+ }
173
+
174
+ export async function remoteChecks(slug, token, requiredSecrets = [], undeclared = []) {
166
175
  const rows = [];
167
176
  const add = (r) => rows.push(r);
168
177
 
@@ -175,15 +184,20 @@ export async function remoteChecks(slug, token, requiredSecrets = []) {
175
184
  } else if (perm.data?.default_workflow_permissions === "write") {
176
185
  add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "OK",
177
186
  value: "Read and write permissions" });
187
+ } else if (undeclared.length === 0) {
188
+ // 모든 워크플로우가 permissions 를 스스로 선언 → 저장소 기본값(read)은 실제 동작에 영향이 없다
189
+ add({ name: "Workflow permissions", purpose: PERM_PURPOSE, status: "INFO",
190
+ value: "읽기 전용 (워크플로우가 권한을 직접 선언해 영향 없음)",
191
+ detail: ["설치된 워크플로우가 permissions 를 모두 선언하고 있어 기본값이 읽기 전용이어도 정상 동작합니다."] });
178
192
  } else {
179
193
  add({
180
194
  name: "Workflow permissions", purpose: PERM_PURPOSE, status: "WARN",
181
195
  value: "Read repository contents permission (읽기 전용)",
182
196
  detail: [
183
- "워크플로우가 저장소에 쓰거나 다른 워크플로우를 실행할 수 없습니다.",
184
- "버전 확정 커밋·릴리스 태그·후속 워크플로우 실행이 전부 실패합니다.",
185
- "조치: Settings > Actions > General > Workflow permissions",
186
- " → 'Read and write permissions' 선택",
197
+ "permissions 선언이 없는 워크플로우는 저장소에 쓰거나 다른 워크플로우를 실행할 수 없습니다.",
198
+ ...undeclared.map((f) => ` ${f}`),
199
+ "조치: 해당 파일에 permissions 블록을 추가하거나",
200
+ " Settings > Actions > General > Workflow permissions → 'Read and write permissions' 선택",
187
201
  ],
188
202
  });
189
203
  }
@@ -225,7 +239,8 @@ export async function remoteChecks(slug, token, requiredSecrets = []) {
225
239
  "AI를 쓰려면 GEMINI_API_KEY(무료)를 등록하세요 — https://aistudio.google.com/apikey",
226
240
  ],
227
241
  });
228
- const missing = requiredSecrets.filter((n) => !have.has(n));
242
+ // AI 키는 위에서 따로 안내하는 선택 항목이다 — '배포에 필요한 값' 미등록 목록에 섞으면 필수처럼 읽힌다 (#723)
243
+ const missing = requiredSecrets.filter((n) => !have.has(n) && !(n in AI_KEYS));
229
244
  add({
230
245
  name: "Secret 등록 여부", purpose: "배포에 필요한 값",
231
246
  status: missing.length ? "WARN" : "OK",
@@ -280,7 +295,7 @@ export async function runDoctor({ cwd = ".", token = process.env.GITHUB_TOKEN ||
280
295
 
281
296
  if (slug && token) {
282
297
  const v = verifyInstall(cwd);
283
- rows.push(...await remoteChecks(slug, token, [...v.secrets.keys()]));
298
+ rows.push(...await remoteChecks(slug, token, [...v.secrets.keys()], workflowsWithoutPermissions(cwd)));
284
299
  } else if (slug) {
285
300
  rows.push({ name: "저장소 설정 점검", purpose: "권한·머지 방식·Secret", status: "INFO",
286
301
  value: "건너뜀 (토큰 없음)",
@@ -6,7 +6,7 @@ import { join, basename } from "node:path";
6
6
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
7
7
  import { PATHS } from "../paths.js";
8
8
  import { exists, copyFileSync, listYamlFiles } from "../fsutil.js";
9
- import { isUnchanged, substituteEnv } from "../wizard-env.js";
9
+ import { isUnchanged, substituteEnv, resolveGlobalTokens } from "../wizard-env.js";
10
10
  import { isUserModified, readBaseline, writeBaseline, sha256 } from "../baseline.js";
11
11
  import { substituteBranches } from "../branch-sub.js";
12
12
  import { saveIncoming, lineDiffCounts } from "../incoming.js";
@@ -221,6 +221,11 @@ export function copyWorkflows(context, tempDir, targetRoot = ".", hooks = {}) {
221
221
  const dst = join(workflowsDir, filename);
222
222
  if (existsSync(dst)) continue; // 이미 존재하면 스킵
223
223
  copyFileSync(join(secretDir, filename), dst);
224
+ // PROJECT_NAME 이 고정값이면 모든 프로젝트가 서버의 같은 폴더에 Secret 을 덮어쓴다 (#729) —
225
+ // 다른 배포 워크플로처럼 레포 이름으로 채운다. 이름을 알 수 없으면 기존 기본값을 쓴다.
226
+ const raw = readFileSync(dst, "utf8");
227
+ const filled = resolveGlobalTokens(raw, repoName || "my-project");
228
+ if (filled !== raw) writeFileSync(dst, filled);
224
229
  counters.optionalCopied++;
225
230
  counters.copied++;
226
231
  counters.copiedFiles.push(filename);