seokang-sk 0.7.0 → 0.7.2

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
@@ -262,12 +262,12 @@ Windows Git Bash에서는 사용자 경로에 맞춰 `cd /c/Users/<user>/.agents
262
262
  - `sk status --cwd /path/to/project`: 다른 프로젝트 상태 확인
263
263
  - `sk identity status`: 선택적 GitHub operator 진단 정보 확인
264
264
  - `sk guard implementation --db-impact`: 이전 버전 호환용 안내. DB 코드 작성은 허용하고 실행은 금지
265
- - `sk guard db-execution`: local test/CI/build/deploy 전에 production DB 자동 실행 경로 검사
265
+ - `sk guard db-execution`: local test/CI/일반 build 전에 production DB 자동 실행 경로를 strict 검사. `$sk-deploy`는 내부 deploy-packaging mode로 실행되지 않는 DB test source를 허용하고 실제 test/migration 실행만 차단
266
266
  - `sk guard git --action push --branch <name> --repo <name>`: OWNER main push와 NON_OWNER 작업 branch/PR 경계 확인
267
267
  - `sk guard stage pre-add|pre-commit <path ...>`: intended commit path와 Git index 일치 검사
268
268
  - `sk guard secrets --worktree` / `--staged` / `--outbound --remote origin`: add, commit, push 직전 secret hard-fail
269
269
  - `sk guard deploy --environment <name> --artifact <type> --action <action> --db-impact-status <status>`: 배포 credential 및 DB 무실행 경계 안내
270
- - 구현/commit/작업 branch는 identity 확인이나 cache를 요구하지 않는다. OWNER의 canonical `main`/`master` 직접 push와 배포는 GitHub ACL·OWNER credential을 실제 경계로 사용하고, NON_OWNER는 개인 fork PR까지만 진행한다.
270
+ - 구현/commit/작업 branch는 identity 확인이나 cache를 요구하지 않는다. OWNER의 canonical `main`/`master` 직접 push는 GitHub ACL을 사용하고, 배포는 사용자 유형이 아니라 local credential과 원격 권한으로 실행한다. NON_OWNER Git 작업은 개인 fork PR까지만 진행한다.
271
271
  - [상세 CLI 가이드](docs/setup/sk-cli-guide.md)를 기준 문서로 사용
272
272
 
273
273
  ---
@@ -418,11 +418,15 @@ sk search "reviewer"
418
418
  - OWNER: canonical `main`/`master` 직접 push, contributor PR 검토·merge, production 배포, npm publish, rollback, tag/release
419
419
  - NON_OWNER: canonical `main`/`master` push와 모든 배포 mutation 금지, 개인 fork 작업 branch와 원본 PR까지만 허용
420
420
 
421
- 모든 배포 스크립트는 build/upload/restart/publish 전에 `sk guard deploy`와 DB execution boundary를 확인합니다. 실제 배포는 OWNER만 보유한 SSH/npm/API/CDN credential이 있어야 진행되며 GitHub session은 권한 근거로 쓰지 않습니다.
421
+ PR 처리는 예약 Inbox 자동화 없이 `$sk-git`에서 끝냅니다. OWNER가 canonical product repository에서 plain `$sk-git`을 호출하면 local 변경 유무보다 먼저 `sk git preflight --cwd "$PWD" --json`을 실행하고 현재 저장소의 허용된 contributor PR을 자동 발견해 remote diff를 검토한 뒤 순차 squash merge합니다. 결과에는 `PR 처리: 0건 | merged | held | pending` receipt가 반드시 남습니다. repository 이전 전 `Seokang/<repo>` remote도 GitHub canonical `full_name`이 정확히 `selink-lab/<허용 repo>`일 때만 같은 원본으로 인정합니다. 특정 PR 하나만 처리하려면 `$sk-git PR #<number> 병합`을 사용합니다. 친구는 personal fork에서 commit/push/PR까지만 수행하고, merge 뒤 다음 `$sk-git`/feature/bugfix 시작 시 clean/ff-only 조건에서 원본 최신 main을 local default branch에 반영합니다. `sk update`는 제품 코드 pull이 아니라 공용 skill 업데이트에만 사용합니다.
422
+
423
+ `$sk-git`과 `$sk-deploy`를 함께 호출하면 PR scan과 candidate별 merged/held 결과를 먼저 확정하고 canonical main을 fast-forward한 뒤 배포합니다. `$sk-deploy`만 호출한 경우에는 PR 또는 GitHub 사용자 확인을 배포 gate로 추가하지 않습니다.
424
+
425
+ 모든 배포 스크립트는 build/upload/restart/publish 전에 DB execution boundary를 확인합니다. OWNER/NON_OWNER/root/seokang 같은 사용자 유형은 판정하지 않으며, local config의 SSH/npm/API/CDN credential로 실제 명령을 실행한 결과만 사용합니다.
422
426
 
423
427
  별도 테스트/개발/로컬 DB는 없습니다. DB 코드·SQL·migration 파일은 작성하고 PR로 검토할 수 있지만 Codex, local test, CI, deploy, start/restart script는 migration/DDL/DML/backfill/seed를 실행하지 않습니다. 실제 반영은 OWNER 사용자가 별도 IDE/DB 도구에서 수동 실행합니다.
424
428
 
425
- 이 로컬 guard는 실수 방지층입니다. GitHub Free private 저장소의 실제 차단은 `selink-lab` 원본 Read 권한, 개인 private fork/PR, OWNER만 보유한 SSH/npm/DB/API/CDN credential로 구성합니다. fork PR Actions에는 write token, secrets, variables를 전달하지 않고 OWNER 승인 후 workflow를 실행합니다.
429
+ 이 로컬 guard는 실수 방지층입니다. GitHub Free private 저장소의 실제 Git 차단은 `selink-lab` 원본 Read 권한과 개인 private fork/PR로 구성합니다. 배포는 local config에 credential이 설정된 환경에서만 가능하며 fork PR Actions에는 write token, secrets, variables를 전달하지 않습니다.
426
430
 
427
431
  예시 설정 파일:
428
432
 
@@ -40,10 +40,10 @@
40
40
 
41
41
  #### Canonical origin 시작 순서
42
42
 
43
- 1. `origin=github.com/selink-lab/<repo>`이고 확인한 repository name과 일치하는지 먼저 검증한다. 개인 fork나 다른 host의 origin이면 fetch/merge하지 않는다.
43
+ 1. `origin=github.com/selink-lab/<repo>`이고 확인한 repository name과 일치하는지 먼저 검증한다. repository transfer 전 `github.com/Seokang/<repo>` 주소가 남아 있으면 GitHub API의 canonical `full_name`이 정확히 `selink-lab/<허용 repo>`인지 확인한 경우에만 같은 원본으로 인정하며 local remote URL은 자동 변경하지 않는다. 개인 fork나 다른 host의 origin이면 fetch/merge하지 않는다.
44
44
  2. 새 작업 전에 원본의 열린 PR을 최대 100건 조회한다.
45
45
  - `gh pr list --repo selink-lab/<repo> --state open --limit 100 --json number,title,author,headRefName,baseRefName,url,reviewDecision`
46
- 3. 열린 PR이 현재 요청과 같은 파일/도메인에 영향을 줄 수 있으면 PR diff와 checks를 먼저 읽고 새 작업과의 충돌 여부를 보고한다. 자동 merge/close는 하지 않는다.
46
+ 3. 열린 PR이 현재 요청과 같은 파일/도메인에 영향을 줄 수 있으면 PR diff와 checks를 먼저 읽고 새 작업과의 충돌 여부를 보고한다. 일반 작업 bootstrap은 PR을 merge/close하지 않는다. 단, OWNER가 현재 canonical product repository에서 plain `$sk-git`을 호출하면 현재 저장소의 허용된 contributor PR을 발견·검토·순차 squash merge하고, `$sk-git PR #<number> 병합`은 지정 PR 하나만 처리한다. 두 경로 모두 GitHub Free protection 미지원/check 완전 부재를 검토 위험으로 공개하고 나머지 CI/head/main 안전조건을 재검증한다.
47
47
  4. worktree가 clean하고 현재 branch가 원본 default branch일 때만 `git fetch origin --prune` 후 `git merge --ff-only origin/<default-branch>`로 동기화한다.
48
48
  5. merge 뒤 `git rev-list --left-right --count HEAD...origin/<default-branch>`가 정확히 `0 0`인지 확인한다. local default가 ahead/diverged면 reset/rebase하지 않고 중단한다.
49
49
  6. 다른 작업 branch에 있으면 원격 fetch와 비교까지만 수행하고 임의로 branch를 전환하지 않는다.
@@ -57,6 +57,8 @@
57
57
  5. 동기화된 default branch에서 허용 prefix의 새 작업 branch를 만든다. private fork 작업자는 fork의 default branch나 upstream에 push하지 않는다.
58
58
  6. fast-forward가 불가능하거나 conflict가 예상되면 merge commit, rebase, force push로 임의 해결하지 않고 OWNER에게 전달한다.
59
59
 
60
+ OWNER가 원본 PR을 merge해도 친구의 fork default branch를 강제로 수정하지 않는다. 친구가 다음 `$sk-git`, `$sk-feature`, `$sk-bugfix`를 호출하면 clean default branch 또는 merge 완료된 작업 branch에서만 위 fast-forward 절차로 `upstream/<default-branch>`를 local default branch에 반영한다.
61
+
60
62
  - network/API 오류를 최신 상태로 간주하지 않는다. fetch/PR 조회가 실패해도 local edit/test/commit은 진행하고, remote sync/push/PR만 `pending`으로 남기며 실패 범위와 stale-base 위험을 밝힌다.
61
63
 
62
64
  ## 2. Track Mapping
@@ -85,7 +85,8 @@
85
85
  - Codex, 로컬 테스트, CI, deploy, start/restart script는 schema/data 변경을 절대 실행하지 않는다.
86
86
  - 금지되는 자동 실행에는 migration CLI, seed/backfill, SQL client write 실행, Flyway/Liquibase startup migration, Hibernate DDL create/update, ORM synchronize가 포함된다.
87
87
  - DB 영향 코드 검증은 DB 연결 없이 compile/typecheck, mock 단위 테스트, SQL 정적 lint/comment 검토로 제한한다. Testcontainers나 임시 DB를 새로 가정하지 않는다.
88
- - local test/build를 실행하기 전과 CI workflow의 첫 검증 단계에서 `sk guard db-execution`을 통과해야 한다. CI에 이 단계가 없거나 DB credential marker가 있으면 실행하지 않는다.
88
+ - local test와 test를 포함할 수 있는 일반 build 전, 그리고 CI workflow의 첫 검증 단계에서 strict `sk guard db-execution`을 통과해야 한다. CI에 이 단계가 없거나 DB credential marker가 있으면 실행하지 않는다.
89
+ - `$sk-deploy`의 고정 no-test packaging은 deploy helper가 `check_db_execution_boundary.mjs --mode deploy-packaging`으로 검사한다. 이 모드에서는 DB 연결 test source의 존재만 허용하되 packaging/lifecycle이 test를 실행하거나 migration/DDL/DML/startup schema mutation 경로가 있으면 hard-fail한다.
89
90
  - fork PR workflow에는 production DB connection string, SSH/npm/API/CDN credential, write token, Actions secrets/variables를 전달하지 않는다.
90
91
  - 모든 deploy artifact는 literal key/value local config와 project/package root를 `scripts/check_db_execution_boundary.mjs`로 검사한다. migration framework가 있으면 production startup disable 근거를 확인하지 못한 상태로 배포하지 않는다.
91
92
  - 일반 애플리케이션 query/DML은 OWNER가 PR을 검토하고 배포한 뒤 production runtime에서만 동작한다. 로컬/CI 검증을 위해 production DB에 실행하지 않는다.
@@ -98,10 +99,17 @@
98
99
  - 일반 Git 흐름은 local remote topology와 실제 GitHub repository ACL로 판단한다.
99
100
  - OWNER + canonical origin: `main`/`master`에 직접 push 가능
100
101
  - NON_OWNER + 개인 fork origin + `selink-lab` upstream: 개인 fork 작업 branch push 후 upstream PR
102
+ - PR merge는 OWNER가 현재 canonical repository에서 plain `$sk-git` 또는 `$sk-git PR #<number> 병합`을 호출한 턴에만 실행한다. plain 호출은 현재 저장소의 허용된 contributor PR을 발견·검토·순차 squash merge하고, 번호 지정 호출은 해당 PR 하나만 처리한다. 예약 자동 병합, 다른 저장소 일괄 조회/병합, NON_OWNER merge는 금지하며 GitHub OWNER credential과 ACL을 다시 확인한다.
103
+ - plain `$sk-git`은 local 변경 유무를 보기 전에 `sk git preflight --cwd "$PWD" --json`을 실행하고 PR scan receipt를 남긴다. 이전 개인 주소가 GitHub repository transfer redirect로 정확히 `selink-lab/<허용 repo>`에 resolve될 때만 canonical origin으로 인정하며 local remote URL은 자동 변경하지 않는다.
104
+ - `$sk-git`과 `$sk-deploy`가 같은 요청에 있으면 PR scan과 candidate별 merged/held 결과를 먼저 확정하고 canonical main을 fast-forward한 뒤 deploy를 시작한다. deploy 단독 호출에는 GitHub PR/identity gate를 추가하지 않는다.
105
+ - NON_OWNER의 `$sk-git`은 clean 상태에서만 개인 fork `origin` + `selink-lab` `upstream` topology를 확인하고, 병합 완료된 작업 branch 또는 local default branch를 `upstream/<default>`에 `--ff-only`로 맞춘다. reset/rebase/force push와 fork default push는 하지 않는다.
101
106
  - `$sk-git`과 `sk guard git`은 로컬 `gh` session으로 OWNER를 재판정하지 않는다. canonical `main`/`master` push는 GitHub ACL이 OWNER credential만 승인하고, NON_OWNER는 개인 fork PR로 요청한다.
107
+ - 사용자가 `$sk-git`을 호출한 것은 현재 변경을 분석하고 허용된 경로로 add → commit → push하라는 명시적 실행 승인이다. OWNER canonical origin의 `main`/`master` push가 local guard에서 ALLOW이고 secret/stage hard-fail을 모두 통과하면, agent는 같은 push를 위해 두 번째 자연어 승인 문구를 요구하지 않는다.
108
+ - live Codex runtime의 sandbox/network 정책이 실제 tool approval을 요구하면 agent가 해당 push 명령에 대한 범위가 좁은 approval/escalation 요청을 즉시 호출한다. 사용자에게 “승인한다고 다시 말해 달라”고 요구하거나 GitHub 권한 문제로 바꾸어 설명하지 않는다.
102
109
  - tag/release와 canonical mutation은 GitHub ACL이 최종 승인한다. 배포를 유발하는 tag/release는 OWNER가 main 반영 후 수행한다.
103
110
  - GitHub CLI 인증이나 network가 없으면 local edit/test/commit을 중단하지 않는다. push/PR 조회·생성만 `push pending`으로 보존한다.
104
- - 배포 preflight는 GitHub actor/session을 조회하지 않는다. 실제 차단 경계는 OWNER만 보유한 SSH key, npm publish 권한, production API/CDN/DB credential과 원격 계정 권한이다.
111
+ - 배포 preflight는 GitHub actor/session, OWNER/NON_OWNER, local user, process owner를 조회하거나 분류하지 않는다. local config의 SSH key, npm/API/CDN credential과 원격 시스템의 실제 명령 결과만 사용한다.
112
+ - deploy helper와 `sk guard deploy`는 별도 restart wrapper, sudoers, 사용자 프로필을 실행 권한 gate로 요구하지 않는다.
105
113
  - server/front/npm deploy script는 credential/config 확인 실패 시 upload/restart/publish 전에 종료한다.
106
114
  - GitHub Free private 저장소에서는 원본 저장소의 contributor 권한을 Read로 유지하고 production credential을 제공하지 않는 것이 실제 보호 경계다.
107
115
  - Organization에서 private repository forking을 허용하되 fork PR Actions에는 write token, Actions secrets, variables를 전달하지 않는다. fork PR workflow는 OWNER 검토 후 실행한다.
@@ -53,6 +53,14 @@ Automation은 아래를 고정한 뒤 만든다.
53
53
  - Archive condition: 보고할 내용이 없을 때 자동 archive할 조건을 적는다.
54
54
  - Safety: read-only 점검인지, file edit이 필요한지, worktree 격리가 필요한지 적는다.
55
55
 
56
+ 현재 공용 automation 기준:
57
+
58
+ - `docs/automations/daily-thread-retrospective.md`: 일일 task 회고
59
+ - `docs/automations/notion-outbox-publish.md`: Notion outbox publish
60
+ - `docs/automations/server-log-check.md`: 서버 로그 점검
61
+
62
+ 반복 결과를 한 곳에 계속 쌓아야 하는 heartbeat는 일반 개발 채팅에 붙이지 않고 Automations 프로젝트의 전용 채팅을 먼저 만든 뒤 그 thread를 target으로 고정한다. `Daily Server Log Check`, `Notion Outbox Publish`가 이 방식을 사용한다. PR merge와 contributor upstream 동기화는 예약 자동화가 아니라 `$sk-git`의 역할별 흐름으로 처리한다. OWNER의 canonical plain `$sk-git`은 현재 저장소 PR을 발견·검토·merge하고, NON_OWNER의 `$sk-git`은 merge 후 clean/ff-only 조건에서 upstream main을 반영한다.
63
+
56
64
  기본 프롬프트:
57
65
 
58
66
  ```text
@@ -98,7 +98,7 @@ $sk-git
98
98
  ```
99
99
 
100
100
  DB 관련 코드, repository/query, SQL, migration, verification, rollback 파일까지 작성해 PR로 전달할 수 있다. 단 모든 DB target은 production이므로 Codex/local test/CI에서 실행하지 않는다.
101
- `$sk-deploy`는 OWNER만 가진 SSH/npm/배포 credential이 없어 실제 mutation을 진행할 수 없으므로 배포 요청은 SeoKang에게 전달한다.
101
+ `$sk-deploy`는 사용자 유형을 판정하지 않는다. 친구 PC의 local config에 SSH/npm/배포 credential이 없으므로 실제 mutation은 원격 시스템에서 거부되며, 필요한 배포는 credential이 설정된 환경에서 실행한다.
102
102
 
103
103
  `$sk-git`은 아래를 확인해야 한다.
104
104
 
@@ -110,6 +110,8 @@ DB 관련 코드, repository/query, SQL, migration, verification, rollback 파
110
110
 
111
111
  조건을 통과하면 `$sk-git`은 commit/push 뒤 OPEN PR URL을 반환하거나, PR 이력이 없을 때 제목과 본문을 자동 생성해 `selink-lab` 원본 PR까지 만든다. CLOSED/MERGED PR branch는 재사용하지 않고 새 branch를 요구한다.
112
112
 
113
+ PR 생성 뒤 친구가 GitHub나 `git status`를 반복 확인할 필요는 없다. SeoKang이 canonical product repository에서 plain `$sk-git`을 호출하면 현재 저장소의 허용된 contributor PR을 자동 발견해 remote diff를 검토하고, script가 CI·author·base·conflict·head/main SHA를 재검증한 뒤 순차 squash merge한다. 특정 PR 하나만 처리하려면 `$sk-git PR #<number> 병합`을 사용한다. 친구 계정은 원본 merge 권한이 없고 personal fork topology에서 merge helper도 호출하지 않는다.
114
+
113
115
  일반 작업 branch와 local commit은 identity/cache 없이 진행한다. push나 PR 인증이 실패하면 local commit을 보존한 채 `push pending`으로 보고하며, 인증 복구 후 이어서 처리한다.
114
116
 
115
117
  `$sk-git`은 add 전에 worktree, commit 전에 staged index, push 전에 outbound commit history를 각각 검사한다. 새 `.env*`, deploy local config, private key/keystore/credential 파일 또는 고신뢰 token이 탐지되면 확인 없이 hard-fail하며 push하지 않는다. 탐지된 값은 출력하지 않고 로컬 파일도 삭제하지 않는다. 기존 staged path도 intended commit path와 다르면 commit하지 않는다.
@@ -129,7 +131,9 @@ gh pr create \
129
131
 
130
132
  친구는 PR 작성까지만 수행한다. merge, tag, release, local/preview/staging/production 배포는 하지 않는다.
131
133
 
132
- ## 7. 다음 작업에서 fork 동기화
134
+ ## 7. Git skill과 다음 작업의 fork 동기화
135
+
136
+ SeoKang이 PR을 merge해도 친구의 개인 fork default branch를 강제로 변경하지 않는다. 친구가 merge 뒤 `$sk-git`을 다시 호출하거나 다음 `$sk-feature`/`$sk-bugfix`를 시작하면 Codex가 아래 upstream fast-forward를 수행해 원본 최신 main을 local default branch에 반영한다.
133
137
 
134
138
  ```bash
135
139
  git switch <default-branch>
@@ -141,6 +145,20 @@ git switch -c feature/<다음-작업명>
141
145
 
142
146
  충돌이 있으면 `main`에 임의 merge commit을 만들거나 force push하지 말고 SeoKang에게 전달한다.
143
147
 
148
+ `$sk-git`은 첫 단계에서 `sk git preflight --cwd "$PWD" --json`을 실행한다. 개인 fork에서는 receipt가 `route=contributor-fork`, `pr_scan=not-applicable`로 나오며 OWNER PR merge를 시도하지 않는다. 이어서 `node ~/.agents/skills/scripts/git_fork_sync.mjs --cwd "$PWD" --json`으로 동기화한다. worktree/index가 dirty하거나 local default가 ahead/diverged이면 fetch/switch/merge를 실행하지 않는다. 작업 branch의 PR이 아직 OPEN이면 현재 branch를 유지하고, MERGED일 때만 local default로 이동한다.
149
+
150
+ ## 7-1. 공용 스킬 업데이트
151
+
152
+ SeoKang이 새 workflow를 canonical 저장소에 반영한 뒤 친구는 아래 명령으로 새 규칙과 script를 받는다.
153
+
154
+ ```bash
155
+ sk update --dry-run
156
+ sk update
157
+ sk check
158
+ ```
159
+
160
+ 친구가 수정하는 product repository와 `~/.agents/skills` canonical clone은 별개다. `sk update`가 dirty/ahead/diverged를 보고하면 skills clone을 자동 reset하지 말고 SeoKang에게 전달한다.
161
+
144
162
  ## 8. 허용/차단 기준
145
163
 
146
164
  | 작업 | 친구 계정 |
@@ -153,7 +171,7 @@ git switch -c feature/<다음-작업명>
153
171
  | 원본 main/feature branch 직접 push | GitHub Read 권한으로 차단 |
154
172
  | DB 코드·SQL·migration 작성과 PR | 허용, production DB 실행 금지 |
155
173
  | 실제 DB schema/data 변경 | credential 미지급 + OWNER 수동 실행으로 차단 |
156
- | local/preview/staging/production 배포 | OWNER credential 미지급으로 차단 |
174
+ | local/preview/staging/production 배포 | local credential 미설정이면 실제 원격 명령에서 차단 |
157
175
  | npm/tag/release/rollback | GitHub/npm/배포 ACL과 credential로 차단 |
158
176
 
159
177
  ## 9. 계정 또는 접근 제거 시
@@ -6,7 +6,7 @@
6
6
  - GitHub Team 요금제로 업그레이드하지 않고 Organization의 무료 team 기능을 사용한다.
7
7
  - 친구는 `fork-contributors` team을 통해 승인된 원본 private repository만 Read로 본다.
8
8
  - fork/PR 대상으로 승인된 repository에서만 개인 private fork에 작업 branch를 push하고 원본으로 PR을 만든다.
9
- - SeoKang만 merge, 모든 배포, tag/release, npm publish, rollback을 수행한다.
9
+ - SeoKang만 merge, 모든 배포, tag/release, npm publish, rollback을 수행한다. canonical product repository의 plain `$sk-git`은 현재 저장소의 허용된 contributor PR을 자동 발견·검토·순차 merge한다.
10
10
 
11
11
  ## Organization 설정
12
12
 
@@ -55,7 +55,7 @@ GitHub Free private repository에는 branch ruleset을 강제할 수 없으므
55
55
  - 친구 push 대상은 개인 private fork뿐
56
56
  - 친구 계정의 원본 반영은 개인 fork PR과 OWNER merge만 사용하며, OWNER 자신의 작업은 canonical main에 직접 반영 가능
57
57
  - DB write 및 SSH/npm/API/CDN/production credential은 친구에게 제공하지 않음
58
- - 모든 배포 mutation은 OWNER만 보유한 SSH/npm/API/CDN credential과 원격 ACL로 제한
58
+ - 모든 배포 mutation은 사용자 유형 판정 없이 local config의 SSH/npm/API/CDN credential과 원격 ACL로 실행
59
59
  - DB 코드·SQL 작성은 fork PR로 허용하되 Codex/local test/CI/deploy/startup의 DB schema/data 실행은 금지
60
60
  - 실제 DB 변경은 OWNER가 별도 IDE/DB 도구에서 수동 실행
61
61
 
@@ -80,7 +80,7 @@ sk setup
80
80
  - `sk setup --configure-notion`으로 사용자별 Notion workspace 위치를 로컬 파일에 분리할 수 있다.
81
81
  - `.sk/project-memory.json`, `.sk/notepad.md`를 통해 다음 세션이 이전 맥락을 먼저 읽을 수 있다.
82
82
  - `sk search`로 예전 Codex transcript를 다시 찾을 수 있다.
83
- - `sk guard`로 DB 자동 실행과 Git secret을 차단하고, canonical main push와 실제 배포는 GitHub ACL·OWNER credential로 제한할 수 있다.
83
+ - `sk guard`로 DB 자동 실행과 Git secret을 차단하고, canonical main push는 GitHub ACL로 제한한다. 배포는 사용자 유형이 아니라 설정된 credential과 원격 권한으로 실행한다.
84
84
 
85
85
  ## 3. 처음 한 번만 해야 하는 것
86
86
 
@@ -285,10 +285,13 @@ sk guard db-execution
285
285
  Git write는 identity가 아니라 현재 branch와 remote topology를 검사한다.
286
286
 
287
287
  ```bash
288
+ sk git preflight --cwd "$PWD" --json
288
289
  sk guard git --action push --branch codex/my-feature --repo price-advisor-spinks
289
290
  sk guard git --action tag --branch main --repo seokang-codex-setting
290
291
  ```
291
292
 
293
+ - plain `$sk-git`은 첫 명령의 receipt로 OWNER canonical 저장소의 PR scan 실행 여부와 `none|review-required|pending` 상태를 남긴다. local tracked 변경이 없어도 이 단계는 생략하지 않는다.
294
+ - repository 이전 전 `Seokang/<repo>` origin은 GitHub API가 exact `selink-lab/<허용 repo>` canonical `full_name`을 반환할 때만 OWNER PR 경로로 해석한다. local remote는 자동 수정하지 않는다.
292
295
  - 개인 fork 작업자는 remote URL에서 확인한 `<fork-owner>`의 작업 branch를 `origin`에 push하고 `selink-lab` 원본 `upstream`으로 PR을 만든다.
293
296
  - 일반 작업 branch와 `main`/`master` guard는 GitHub identity/cache를 사용하지 않는다. OWNER는 canonical main에 직접 push하고, NON_OWNER는 개인 fork 작업 branch와 PR까지만 진행하며 실제 구분은 GitHub ACL이 담당한다.
294
297
  - push 인증/network 실패가 나도 local commit은 보존하고 `push pending`으로 보고한다.
@@ -310,7 +313,7 @@ sk guard secrets --outbound --remote origin
310
313
 
311
314
  친구 계정의 최초 설치, remote 구성, Codex 스킬 사용, PR 생성 절차는 [친구 계정 Private Fork 개발 가이드](./fork-contributor-guide.md)를 따른다.
312
315
 
313
- 배포는 live GitHub identity를 검사하지 않는다. 실제 차단은 OWNER만 가진 SSH/npm/production credential과 원격 ACL이 담당한다.
316
+ 배포는 live GitHub identity, local OWNER profile, 실행 사용자, process owner를 검사하거나 분류하지 않는다. 설정된 SSH/npm/production credential과 원격 시스템의 실제 명령 결과만 사용한다. `sk guard deploy`도 사용자 유형을 판정하지 않는 경계 진단이다.
314
317
 
315
318
  ```bash
316
319
  sk guard deploy \
@@ -322,7 +325,7 @@ sk guard deploy \
322
325
  --db-impact-status none
323
326
  ```
324
327
 
325
- - server deploy는 `scripts/check_db_execution_boundary.mjs <project-root>`를 먼저 통과해야 하며 startup migration/ORM schema sync 근거가 불명확하면 중단한다.
328
+ - server/front/npm deploy는 helper가 `scripts/check_db_execution_boundary.mjs --mode deploy-packaging <project-root>`를 먼저 실행한다. DB 연결 test source가 존재해도 고정 packaging command에서 실행되지 않으면 허용하지만, test task 연결·npm lifecycle test·startup migration/ORM schema sync 근거가 발견되면 중단한다.
326
329
  - production, npm publish, rollback을 포함한 모든 배포 credential은 OWNER 환경에만 둔다.
327
330
  - fork PR/CI에는 production DB/SSH/npm/API/CDN secret과 write token을 전달하지 않는다.
328
331
 
@@ -25,7 +25,7 @@
25
25
  ### 보안 최종 점검 필요 여부 (`해당 없음` | `필수`)
26
26
  ### DB impact (`있음` | `없음` | `미확인`)
27
27
  ### DB 실행 경계 (`해당 없음` | `production 무실행 검증` | `OWNER 수동 실행 handoff`)
28
- ### Git/deploy gate (`해당 없음` | `OWNER main 직접 반영` | `NON_OWNER PR` | `OWNER credential`)
28
+ ### Git/deploy gate (`해당 없음` | `OWNER main 직접 반영` | `NON_OWNER PR` | `configured credential`)
29
29
 
30
30
  ## 4. 검증 계획
31
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seokang-sk",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "SeoKang Codex 공용 설정과 스킬을 설치하는 CLI",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/selink-lab/seokang-codex-setting",
@@ -25,8 +25,15 @@
25
25
  "test/"
26
26
  ]
27
27
  },
28
- "deploy": {
29
- "ownerOnly": true
28
+ "prMerge": {
29
+ "organization": "selink-lab",
30
+ "allowedAuthors": [
31
+ "91kwangmin"
32
+ ],
33
+ "strategy": "squash",
34
+ "requiredCheckNames": [
35
+ "pr-safe"
36
+ ]
30
37
  },
31
38
  "repositories": {
32
39
  "noAccess": [
@@ -76,6 +76,47 @@ const STATIC_CHECKS = [
76
76
  'memory.md',
77
77
  ],
78
78
  },
79
+ {
80
+ path: 'scripts/git_pr_merge.mjs',
81
+ fragments: [
82
+ "'--match-head-commit'",
83
+ "'--merge'",
84
+ "'--inspect-open-current'",
85
+ "'--preflight-current'",
86
+ 'listOpenPullRequests',
87
+ '명시적인 --merge',
88
+ 'ownerIds',
89
+ 'ownerLogins',
90
+ 'resolveCurrentRepository',
91
+ 'GIT_CANONICAL_REDIRECT_UNTRUSTED',
92
+ "command: 'sk git preflight'",
93
+ 'HEAD_SHA_CHANGED',
94
+ 'MAIN_SHA_CHANGED',
95
+ 'POST_MERGE_STATE_UNVERIFIED',
96
+ 'POST_MERGE_MAIN_UNCHANGED',
97
+ 'mergeStateStatus',
98
+ 'requiresUpToDateBranch',
99
+ "ghJson(['api', 'user'",
100
+ 'GITHUB_CREDENTIAL_UNAVAILABLE',
101
+ "protectionStatus = 'unavailable-plan'",
102
+ ],
103
+ },
104
+ {
105
+ path: 'scripts/git_pr_merge_core.mjs',
106
+ fragments: [
107
+ 'CHANGED_FILES_TRUNCATED',
108
+ 'CHANGED_FILES_COUNT_MISSING',
109
+ 'TRUSTED_CI_SOURCE_MISMATCH',
110
+ 'STRICT_BASE_NOT_ENFORCED',
111
+ 'TRUSTED_CHECK_NOT_REQUIRED',
112
+ 'ADMIN_BYPASS_NOT_BLOCKED',
113
+ 'MERGE_STATE_NOT_CLEAN',
114
+ 'PR_NOT_OPEN',
115
+ 'HEAD_REPOSITORY_NOT_ALLOWED',
116
+ 'MAIN_SHA_MISSING',
117
+ 'BRANCH_PROTECTION_UNAVAILABLE_PLAN',
118
+ ],
119
+ },
79
120
  ];
80
121
 
81
122
  function printUsage() {
@@ -110,6 +151,10 @@ async function main() {
110
151
  const findings = [
111
152
  ...run(process.execPath, ['--check', path.join(REPO_ROOT, 'scripts', 'daily_thread_retrospective.mjs')], 'daily_thread_retrospective syntax'),
112
153
  ...run(process.execPath, ['--check', path.join(REPO_ROOT, 'scripts', 'notion_outbox.mjs')], 'notion_outbox syntax'),
154
+ ...run(process.execPath, ['--check', path.join(REPO_ROOT, 'scripts', 'git_pr_merge_core.mjs')], 'git_pr_merge_core syntax'),
155
+ ...run(process.execPath, ['--check', path.join(REPO_ROOT, 'scripts', 'git_pr_merge.mjs')], 'git_pr_merge syntax'),
156
+ ...run(process.execPath, [path.join(REPO_ROOT, 'scripts', 'check_git_pr_merge.mjs')], 'git_pr_merge harness'),
157
+ ...run(process.execPath, [path.join(REPO_ROOT, 'scripts', 'check_git_pr_merge_integration.mjs')], 'git_pr_merge fake-gh integration'),
113
158
  ...run('bash', ['-n', path.join(REPO_ROOT, 'scripts', 'daily_server_log_check.sh')], 'daily_server_log_check syntax'),
114
159
  ];
115
160
 
@@ -41,11 +41,17 @@ const RULES = [
41
41
  function parseArgs(argv) {
42
42
  const targets = [];
43
43
  let json = false;
44
- for (const arg of argv) {
44
+ let mode = 'strict';
45
+ for (let index = 0; index < argv.length; index += 1) {
46
+ const arg = argv[index];
45
47
  if (arg === '--json') json = true;
48
+ else if (arg === '--mode') mode = argv[++index];
46
49
  else targets.push(path.resolve(arg));
47
50
  }
48
- return { targets: targets.length > 0 ? targets : [process.cwd()], json };
51
+ if (!['strict', 'deploy-packaging'].includes(mode)) {
52
+ throw new Error(`지원하지 않는 DB execution boundary mode: ${mode || '(empty)'}`);
53
+ }
54
+ return { targets: targets.length > 0 ? targets : [process.cwd()], json, mode };
49
55
  }
50
56
 
51
57
  function shouldScan(filePath) {
@@ -119,7 +125,100 @@ function workflowHasUnguardedExecution(content) {
119
125
  return false;
120
126
  }
121
127
 
122
- const { targets, json } = parseArgs(process.argv.slice(2));
128
+ function commandRunsTests(command, baseDirectory, contents, visitedShellFiles = new Set()) {
129
+ if (/\b(?:npm|pnpm|yarn|bun)\s+(?:(?:run|run-script)\s+)?(?:test|test:[\w-]+|integration(?:-?test)?|e2e)\b|\b(?:jest|vitest|mocha)\b/i.test(command)) {
130
+ return true;
131
+ }
132
+
133
+ const shellReferences = [];
134
+ for (const match of command.matchAll(/(?:^|[\s;&|])(?:bash|sh|zsh)\s+(["']?)([^\s"';&|]+\.sh)\1/gi)) shellReferences.push(match[2]);
135
+ for (const match of command.matchAll(/(?:^|[\s;&|])((?:\.\/|\.\.\/)[^\s;&|]+\.sh)\b/gi)) shellReferences.push(match[1]);
136
+ for (const reference of shellReferences) {
137
+ const shellPath = path.resolve(baseDirectory, reference);
138
+ if (visitedShellFiles.has(shellPath)) continue;
139
+ const shellContent = contents.get(shellPath);
140
+ if (typeof shellContent !== 'string') continue;
141
+ visitedShellFiles.add(shellPath);
142
+ if (commandRunsTests(shellContent, path.dirname(shellPath), contents, visitedShellFiles)) return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function packageDeployLifecycleRunsTests(content, filePath, contents) {
148
+ let packageDocument;
149
+ try {
150
+ packageDocument = JSON.parse(content);
151
+ } catch {
152
+ return false;
153
+ }
154
+ const scripts = packageDocument?.scripts;
155
+ if (!scripts || typeof scripts !== 'object') return false;
156
+ const entrypoints = [
157
+ 'prebuild-prod', 'build-prod', 'postbuild-prod',
158
+ 'prepack', 'prepare', 'prepublish', 'prepublishOnly', 'publish', 'postpublish', 'postpack',
159
+ ];
160
+ const visited = new Set();
161
+ const queue = entrypoints.filter((name) => typeof scripts[name] === 'string');
162
+ while (queue.length > 0) {
163
+ const name = queue.shift();
164
+ if (visited.has(name)) continue;
165
+ visited.add(name);
166
+ const command = scripts[name];
167
+ if (commandRunsTests(command, path.dirname(filePath), contents)) {
168
+ return true;
169
+ }
170
+ for (const match of command.matchAll(/\b(?:npm|pnpm|yarn|bun)\s+(?:run|run-script)\s+([\w:-]+)/gi)) {
171
+ if (typeof scripts[match[1]] === 'string') queue.push(match[1]);
172
+ }
173
+ }
174
+ return false;
175
+ }
176
+
177
+ function gradleDependencyTargets(expression) {
178
+ const ignored = new Set(['dependsOn', 'finalizedBy', 'tasks', 'task', 'named', 'register', 'getByName', 'get']);
179
+ const targets = new Set();
180
+ for (const match of expression.matchAll(/['"]([A-Za-z][\w-]*)['"]/g)) targets.add(match[1]);
181
+ for (const match of expression.matchAll(/\b([A-Za-z][\w-]*)\b/g)) {
182
+ if (!ignored.has(match[1])) targets.add(match[1]);
183
+ }
184
+ return [...targets];
185
+ }
186
+
187
+ function gradleDeployPackagingRunsTests(content) {
188
+ const graph = new Map();
189
+ const addEdges = (source, expression) => {
190
+ if (!graph.has(source)) graph.set(source, new Set());
191
+ for (const target of gradleDependencyTargets(expression)) {
192
+ if (target !== source) graph.get(source).add(target);
193
+ }
194
+ };
195
+
196
+ for (const match of content.matchAll(/\b([A-Za-z][\w-]*)\s*\.\s*(?:dependsOn|finalizedBy)\s*\(?([^\n)]*)/g)) {
197
+ addEdges(match[1], match[2]);
198
+ }
199
+ const blockPatterns = [
200
+ /tasks\.(?:named|register)\s*\(\s*['"]([A-Za-z][\w-]*)['"][^)]*\)\s*\{([\s\S]{0,2000}?)\n?\}/g,
201
+ /(?:^|\n)\s*([A-Za-z][\w-]*)\s*\{([\s\S]{0,2000}?)\n?\}/g,
202
+ ];
203
+ for (const pattern of blockPatterns) {
204
+ for (const block of content.matchAll(pattern)) {
205
+ for (const edge of block[2].matchAll(/(?:dependsOn|finalizedBy)\s*\(?([^\n)]*)/g)) addEdges(block[1], edge[1]);
206
+ }
207
+ }
208
+
209
+ const queue = ['assemble', 'bootJar'];
210
+ const visited = new Set();
211
+ while (queue.length > 0) {
212
+ const task = queue.shift();
213
+ if (visited.has(task)) continue;
214
+ visited.add(task);
215
+ if (/^(?:test|check|integration[-_]?test)$/i.test(task) || /(?:Test|Tests|Check)$/.test(task)) return true;
216
+ for (const dependency of graph.get(task) || []) queue.push(dependency);
217
+ }
218
+ return false;
219
+ }
220
+
221
+ const { targets, json, mode } = parseArgs(process.argv.slice(2));
123
222
  const files = [];
124
223
  for (const target of targets) await collect(target, files);
125
224
 
@@ -188,18 +287,36 @@ for (const proof of [
188
287
  }
189
288
  }
190
289
 
191
- const uniqueViolations = [...new Map(violations.map((entry) => [`${entry.file}:${entry.rule}`, entry])).values()];
290
+ if (mode === 'deploy-packaging') {
291
+ for (const [filePath, content] of contents) {
292
+ const name = path.basename(filePath);
293
+ if (name === 'package.json' && packageDeployLifecycleRunsTests(content, filePath, contents)) {
294
+ violations.push({ file: relativeDisplay(filePath), rule: 'DEPLOY_DATABASE_TEST_EXECUTION' });
295
+ }
296
+ if (/^build\.gradle(?:\.kts)?$/i.test(name) && gradleDeployPackagingRunsTests(content)) {
297
+ violations.push({ file: relativeDisplay(filePath), rule: 'DEPLOY_DATABASE_TEST_EXECUTION' });
298
+ }
299
+ }
300
+ }
301
+
302
+ const uniqueFindings = [...new Map(violations.map((entry) => [`${entry.file}:${entry.rule}`, entry])).values()];
303
+ const advisoryRules = mode === 'deploy-packaging' ? new Set(['TEST_DATABASE_CONNECTION']) : new Set();
304
+ const advisories = uniqueFindings.filter((entry) => advisoryRules.has(entry.rule));
305
+ const uniqueViolations = uniqueFindings.filter((entry) => !advisoryRules.has(entry.rule));
192
306
  const payload = {
193
307
  decision: uniqueViolations.length === 0 ? 'ALLOW' : 'DENY',
308
+ mode,
194
309
  scanned_files: files.length,
195
310
  db_execution_allowed: false,
311
+ advisories,
196
312
  violations: uniqueViolations,
197
313
  };
198
314
 
199
315
  if (json) {
200
316
  console.log(JSON.stringify(payload, null, 2));
201
317
  } else if (payload.decision === 'ALLOW') {
202
- console.log(`DB execution boundary: PASS (${files.length} files, mutation execution 없음)`);
318
+ const advisoryText = advisories.length > 0 ? `, 실행되지 않는 DB test source ${advisories.length}건` : '';
319
+ console.log(`DB execution boundary: PASS (${files.length} files, mutation execution 없음${advisoryText})`);
203
320
  } else {
204
321
  console.error('DB execution boundary: DENY');
205
322
  for (const violation of uniqueViolations) console.error(`- ${violation.file} [${violation.rule}]`);
@@ -14,8 +14,8 @@ function fail(message) {
14
14
  process.exit(1);
15
15
  }
16
16
 
17
- function run(target) {
18
- return spawnSync(process.execPath, [CHECKER, target, '--json'], { encoding: 'utf8' });
17
+ function run(target, mode = 'strict') {
18
+ return spawnSync(process.execPath, [CHECKER, '--mode', mode, target, '--json'], { encoding: 'utf8' });
19
19
  }
20
20
 
21
21
  const tempRoot = await mkdtemp(path.join(tmpdir(), 'sk-db-execution-boundary-'));
@@ -94,6 +94,76 @@ jobs:
94
94
  if (connectedTest.status !== 2 || !connectedTest.stdout.includes('TEST_DATABASE_CONNECTION')) {
95
95
  fail(`DB 연결 가능 test fixture가 차단되지 않았습니다: ${connectedTest.stdout}${connectedTest.stderr}`);
96
96
  }
97
+ const connectedTestPackaging = run(connectedTestRoot, 'deploy-packaging');
98
+ if (connectedTestPackaging.status !== 0 || !connectedTestPackaging.stdout.includes('TEST_DATABASE_CONNECTION')) {
99
+ fail(`실행되지 않는 DB 연결 test source가 deploy packaging을 차단했습니다: ${connectedTestPackaging.stdout}${connectedTestPackaging.stderr}`);
100
+ }
101
+
102
+ await writeFile(
103
+ path.join(connectedTestRoot, 'build.gradle'),
104
+ 'plugins { id "java" }\nassemble.dependsOn test\n',
105
+ );
106
+ const connectedTestExecution = run(connectedTestRoot, 'deploy-packaging');
107
+ if (connectedTestExecution.status !== 2 || !connectedTestExecution.stdout.includes('DEPLOY_DATABASE_TEST_EXECUTION')) {
108
+ fail(`deploy packaging에 연결된 DB test 실행 경로가 차단되지 않았습니다: ${connectedTestExecution.stdout}${connectedTestExecution.stderr}`);
109
+ }
110
+
111
+ const npmLifecycleRoot = path.join(tempRoot, 'npm-lifecycle-test');
112
+ await mkdir(path.join(npmLifecycleRoot, 'tests'), { recursive: true });
113
+ await writeFile(path.join(npmLifecycleRoot, 'tests', 'db.test.js'), 'const url = process.env.DATABASE_URL;\n');
114
+ await writeFile(path.join(npmLifecycleRoot, 'package.json'), JSON.stringify({ scripts: { prepack: 'npm test', test: 'vitest' } }));
115
+ const npmLifecycle = run(npmLifecycleRoot, 'deploy-packaging');
116
+ if (npmLifecycle.status !== 2 || !npmLifecycle.stdout.includes('DEPLOY_DATABASE_TEST_EXECUTION')) {
117
+ fail(`npm deploy lifecycle의 DB test 실행 경로가 차단되지 않았습니다: ${npmLifecycle.stdout}${npmLifecycle.stderr}`);
118
+ }
119
+
120
+ const unknownTestLifecycleRoot = path.join(tempRoot, 'npm-unknown-test-lifecycle');
121
+ await mkdir(unknownTestLifecycleRoot, { recursive: true });
122
+ await writeFile(
123
+ path.join(unknownTestLifecycleRoot, 'package.json'),
124
+ JSON.stringify({ scripts: { prepack: 'npm test', test: 'node ./custom-check.mjs' } }),
125
+ );
126
+ await writeFile(path.join(unknownTestLifecycleRoot, 'custom-check.mjs'), 'console.log("custom check");\n');
127
+ const unknownTestLifecycle = run(unknownTestLifecycleRoot, 'deploy-packaging');
128
+ if (unknownTestLifecycle.status !== 2 || !unknownTestLifecycle.stdout.includes('DEPLOY_DATABASE_TEST_EXECUTION')) {
129
+ fail(`DB test source 탐지 여부와 무관한 npm packaging 검증 실행 경로가 차단되지 않았습니다: ${unknownTestLifecycle.stdout}${unknownTestLifecycle.stderr}`);
130
+ }
131
+
132
+ const shellLifecycleRoot = path.join(tempRoot, 'npm-shell-lifecycle');
133
+ await mkdir(path.join(shellLifecycleRoot, 'scripts'), { recursive: true });
134
+ await writeFile(path.join(shellLifecycleRoot, 'package.json'), JSON.stringify({ scripts: { prepack: 'bash scripts/verify.sh' } }));
135
+ await writeFile(path.join(shellLifecycleRoot, 'scripts', 'verify.sh'), '#!/bin/sh\nnpm test\n');
136
+ const shellLifecycle = run(shellLifecycleRoot, 'deploy-packaging');
137
+ if (shellLifecycle.status !== 2 || !shellLifecycle.stdout.includes('DEPLOY_DATABASE_TEST_EXECUTION')) {
138
+ fail(`이름이 중립적인 shell script를 통한 npm test 실행 경로가 차단되지 않았습니다: ${shellLifecycle.stdout}${shellLifecycle.stderr}`);
139
+ }
140
+
141
+ const gradleFinalizerRoot = path.join(tempRoot, 'gradle-finalizer-test');
142
+ await mkdir(gradleFinalizerRoot, { recursive: true });
143
+ await writeFile(path.join(gradleFinalizerRoot, 'build.gradle'), 'plugins { id "java" }\nassemble.finalizedBy integrationTest\n');
144
+ const gradleFinalizer = run(gradleFinalizerRoot, 'deploy-packaging');
145
+ if (gradleFinalizer.status !== 2 || !gradleFinalizer.stdout.includes('DEPLOY_DATABASE_TEST_EXECUTION')) {
146
+ fail(`Gradle packaging finalizer의 test 실행 경로가 차단되지 않았습니다: ${gradleFinalizer.stdout}${gradleFinalizer.stderr}`);
147
+ }
148
+
149
+ const gradleClosureRoot = path.join(tempRoot, 'gradle-closure-test');
150
+ await mkdir(gradleClosureRoot, { recursive: true });
151
+ await writeFile(path.join(gradleClosureRoot, 'build.gradle'), 'plugins { id "java" }\nassemble {\n dependsOn test\n}\n');
152
+ const gradleClosure = run(gradleClosureRoot, 'deploy-packaging');
153
+ if (gradleClosure.status !== 2 || !gradleClosure.stdout.includes('DEPLOY_DATABASE_TEST_EXECUTION')) {
154
+ fail(`Gradle closure packaging의 test 실행 경로가 차단되지 않았습니다: ${gradleClosure.stdout}${gradleClosure.stderr}`);
155
+ }
156
+
157
+ const gradleIndirectRoot = path.join(tempRoot, 'gradle-indirect-test');
158
+ await mkdir(gradleIndirectRoot, { recursive: true });
159
+ await writeFile(
160
+ path.join(gradleIndirectRoot, 'build.gradle'),
161
+ 'plugins { id "java" }\nassemble.dependsOn verifyProd\nverifyProd.dependsOn test\n',
162
+ );
163
+ const gradleIndirect = run(gradleIndirectRoot, 'deploy-packaging');
164
+ if (gradleIndirect.status !== 2 || !gradleIndirect.stdout.includes('DEPLOY_DATABASE_TEST_EXECUTION')) {
165
+ fail(`Gradle packaging의 간접 test dependency가 차단되지 않았습니다: ${gradleIndirect.stdout}${gradleIndirect.stderr}`);
166
+ }
97
167
 
98
168
  const testcontainersRoot = path.join(tempRoot, 'testcontainers');
99
169
  await mkdir(testcontainersRoot, { recursive: true });