seokang-sk 0.7.1 → 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 +3 -1
- package/docs/policies/context-and-track.md +1 -1
- package/docs/policies/core-safety.md +2 -0
- package/docs/setup/fork-contributor-guide.md +1 -1
- package/docs/setup/sk-cli-guide.md +3 -0
- package/package.json +1 -1
- package/scripts/check_automation_safety.mjs +5 -1
- package/scripts/check_docs_integrity.mjs +1 -1
- package/scripts/check_fork_contribution.mjs +4 -1
- package/scripts/check_git_pr_merge_integration.mjs +65 -1
- package/scripts/check_sk_cli_smoke.mjs +2 -0
- package/scripts/git_pr_merge.mjs +171 -35
- package/scripts/sk.mjs +44 -0
package/README.MD
CHANGED
|
@@ -418,7 +418,9 @@ 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
|
-
PR 처리는 예약 Inbox 자동화 없이 `$sk-git`에서 끝냅니다. OWNER가 canonical product repository에서 plain `$sk-git`을 호출하면 현재 저장소의 허용된 contributor PR을 자동 발견해 remote diff를
|
|
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로 추가하지 않습니다.
|
|
422
424
|
|
|
423
425
|
모든 배포 스크립트는 build/upload/restart/publish 전에 DB execution boundary를 확인합니다. OWNER/NON_OWNER/root/seokang 같은 사용자 유형은 판정하지 않으며, local config의 SSH/npm/API/CDN credential로 실제 명령을 실행한 결과만 사용합니다.
|
|
424
426
|
|
|
@@ -40,7 +40,7 @@
|
|
|
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
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 안전조건을 재검증한다.
|
|
@@ -100,6 +100,8 @@
|
|
|
100
100
|
- OWNER + canonical origin: `main`/`master`에 직접 push 가능
|
|
101
101
|
- NON_OWNER + 개인 fork origin + `selink-lab` upstream: 개인 fork 작업 branch push 후 upstream PR
|
|
102
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를 추가하지 않는다.
|
|
103
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는 하지 않는다.
|
|
104
106
|
- `$sk-git`과 `sk guard git`은 로컬 `gh` session으로 OWNER를 재판정하지 않는다. canonical `main`/`master` push는 GitHub ACL이 OWNER credential만 승인하고, NON_OWNER는 개인 fork PR로 요청한다.
|
|
105
107
|
- 사용자가 `$sk-git`을 호출한 것은 현재 변경을 분석하고 허용된 경로로 add → commit → push하라는 명시적 실행 승인이다. OWNER canonical origin의 `main`/`master` push가 local guard에서 ALLOW이고 secret/stage hard-fail을 모두 통과하면, agent는 같은 push를 위해 두 번째 자연어 승인 문구를 요구하지 않는다.
|
|
@@ -145,7 +145,7 @@ git switch -c feature/<다음-작업명>
|
|
|
145
145
|
|
|
146
146
|
충돌이 있으면 `main`에 임의 merge commit을 만들거나 force push하지 말고 SeoKang에게 전달한다.
|
|
147
147
|
|
|
148
|
-
`$sk-git`은
|
|
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
149
|
|
|
150
150
|
## 7-1. 공용 스킬 업데이트
|
|
151
151
|
|
|
@@ -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`으로 보고한다.
|
package/package.json
CHANGED
|
@@ -82,10 +82,14 @@ const STATIC_CHECKS = [
|
|
|
82
82
|
"'--match-head-commit'",
|
|
83
83
|
"'--merge'",
|
|
84
84
|
"'--inspect-open-current'",
|
|
85
|
+
"'--preflight-current'",
|
|
85
86
|
'listOpenPullRequests',
|
|
86
87
|
'명시적인 --merge',
|
|
87
88
|
'ownerIds',
|
|
88
|
-
'
|
|
89
|
+
'ownerLogins',
|
|
90
|
+
'resolveCurrentRepository',
|
|
91
|
+
'GIT_CANONICAL_REDIRECT_UNTRUSTED',
|
|
92
|
+
"command: 'sk git preflight'",
|
|
89
93
|
'HEAD_SHA_CHANGED',
|
|
90
94
|
'MAIN_SHA_CHANGED',
|
|
91
95
|
'POST_MERGE_STATE_UNVERIFIED',
|
|
@@ -139,7 +139,7 @@ const REQUIRED_POLICY_FILES = [
|
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
141
|
label: 'git owner merge and contributor sync',
|
|
142
|
-
fragments: ['NON_OWNER Upstream Sync', 'git_fork_sync.mjs', 'OWNER Current-Repository PR Merge', 'git_pr_merge.mjs', '
|
|
142
|
+
fragments: ['Executable Git Workflow Preflight Receipt', 'sk git preflight', 'receipt', 'NON_OWNER Upstream Sync', 'git_fork_sync.mjs', 'OWNER Current-Repository PR Merge', 'git_pr_merge.mjs', 'plain `$sk-git`', '--match-head-commit'],
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
label: 'git secret hard-fail',
|
|
@@ -88,7 +88,8 @@ requireFragments('sk-git', gitSkill, [
|
|
|
88
88
|
'seokang-codex-setting',
|
|
89
89
|
'git_fork_sync.mjs',
|
|
90
90
|
'git_pr_merge.mjs',
|
|
91
|
-
'
|
|
91
|
+
'sk git preflight',
|
|
92
|
+
'receipt',
|
|
92
93
|
'plain `$sk-git`',
|
|
93
94
|
'OWNER Current-Repository PR Merge',
|
|
94
95
|
]);
|
|
@@ -104,6 +105,8 @@ requireFragments('core-safety', coreSafety, [
|
|
|
104
105
|
'Codex, 로컬 테스트, CI, deploy, start/restart script',
|
|
105
106
|
'Repository Access Matrix',
|
|
106
107
|
'Git Secret Hard-Fail',
|
|
108
|
+
'sk git preflight',
|
|
109
|
+
'PR scan receipt',
|
|
107
110
|
]);
|
|
108
111
|
|
|
109
112
|
const guide = await read('docs/setup/fork-contributor-guide.md');
|
|
@@ -9,14 +9,22 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
|
|
10
10
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
11
|
const HELPER = path.join(REPO_ROOT, 'scripts', 'git_pr_merge.mjs');
|
|
12
|
+
const SK_SCRIPT = path.join(REPO_ROOT, 'scripts', 'sk.mjs');
|
|
12
13
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'git-pr-merge-'));
|
|
13
14
|
|
|
14
15
|
const fakeGit = `#!/usr/bin/env node
|
|
16
|
+
const { appendFileSync } = require('node:fs');
|
|
15
17
|
const args = process.argv.slice(2);
|
|
18
|
+
if (process.env.FAKE_GIT_TRACE) appendFileSync(process.env.FAKE_GIT_TRACE, args.join(' ') + '\\n');
|
|
16
19
|
if (args.join(' ') === 'remote get-url origin') {
|
|
17
20
|
process.stdout.write(process.env.FAKE_ORIGIN || 'git@github.com:selink-lab/slk-api.git');
|
|
18
21
|
process.exit(0);
|
|
19
22
|
}
|
|
23
|
+
if (args.join(' ') === 'remote get-url upstream' && process.env.FAKE_UPSTREAM) {
|
|
24
|
+
process.stdout.write(process.env.FAKE_UPSTREAM);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
if (args.join(' ') === 'status --porcelain') process.exit(0);
|
|
20
28
|
process.stderr.write('unexpected fake git args: ' + args.join(' '));
|
|
21
29
|
process.exit(2);
|
|
22
30
|
`;
|
|
@@ -25,6 +33,7 @@ const fakeGh = `#!/usr/bin/env node
|
|
|
25
33
|
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
34
|
const args = process.argv.slice(2);
|
|
27
35
|
const output = (value) => process.stdout.write(JSON.stringify(value));
|
|
36
|
+
if (process.env.FAKE_GH_TRACE) appendFileSync(process.env.FAKE_GH_TRACE, args.join(' ') + '\\n');
|
|
28
37
|
if (args[0] === 'api') {
|
|
29
38
|
const endpoint = args[1];
|
|
30
39
|
if (endpoint === 'user') {
|
|
@@ -34,6 +43,9 @@ if (args[0] === 'api') {
|
|
|
34
43
|
}
|
|
35
44
|
output({ id: Number(process.env.FAKE_USER_ID || '48157145'), login: process.env.FAKE_LOGIN || 'Seokang' });
|
|
36
45
|
}
|
|
46
|
+
else if (/^repos\\/[^/]+\\/[^/]+$/.test(endpoint)) {
|
|
47
|
+
output({ full_name: process.env.FAKE_CANONICAL_REPOSITORY || endpoint.slice('repos/'.length), default_branch: 'main' });
|
|
48
|
+
}
|
|
37
49
|
else if (endpoint.endsWith('/protection')) {
|
|
38
50
|
if (process.env.FAKE_PROTECTION_UNSAFE) {
|
|
39
51
|
output({ required_status_checks: { strict: false, contexts: [], checks: [] }, enforce_admins: { enabled: false } });
|
|
@@ -104,6 +116,14 @@ function inspect(extraEnv = {}) {
|
|
|
104
116
|
});
|
|
105
117
|
}
|
|
106
118
|
|
|
119
|
+
function preflight(extraEnv = {}) {
|
|
120
|
+
return spawnSync(process.execPath, [SK_SCRIPT, 'git', 'preflight', '--cwd', tempRoot, '--json'], {
|
|
121
|
+
cwd: REPO_ROOT,
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
env: { ...process.env, PATH: `${tempRoot}:${process.env.PATH}`, SK_DISABLE_FORWARD: '1', SK_REPO_ROOT: REPO_ROOT, ...extraEnv },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
107
127
|
try {
|
|
108
128
|
const ghPath = path.join(tempRoot, 'gh');
|
|
109
129
|
const gitPath = path.join(tempRoot, 'git');
|
|
@@ -125,6 +145,50 @@ try {
|
|
|
125
145
|
assert.deepEqual(openResult.pullRequests.map((pr) => pr.target), ['selink-lab/slk-api#1']);
|
|
126
146
|
assert.deepEqual(openResult.pullRequests[0].files, ['docs/guide.md']);
|
|
127
147
|
assert.equal(openResult.ignored[0].reason, 'AUTHOR_NOT_ALLOWED');
|
|
148
|
+
assert.equal(openResult.receipt.pr_scan, 'executed');
|
|
149
|
+
assert.equal(openResult.receipt.open_count, 2);
|
|
150
|
+
|
|
151
|
+
const gitTrace = path.join(tempRoot, 'git-preflight.trace');
|
|
152
|
+
const ghTrace = path.join(tempRoot, 'gh-preflight.trace');
|
|
153
|
+
const cleanPreflight = preflight({ FAKE_GH_LOG: mergeLog, FAKE_GIT_TRACE: gitTrace, FAKE_GH_TRACE: ghTrace });
|
|
154
|
+
assert.equal(cleanPreflight.status, 0, cleanPreflight.stderr || cleanPreflight.stdout);
|
|
155
|
+
const cleanReceipt = JSON.parse(cleanPreflight.stdout).receipt;
|
|
156
|
+
assert.equal(cleanReceipt.executed, true);
|
|
157
|
+
assert.equal(cleanReceipt.route, 'owner-pr');
|
|
158
|
+
assert.equal(cleanReceipt.pr_scan, 'executed');
|
|
159
|
+
assert.deepEqual(cleanReceipt.candidate_count, 1);
|
|
160
|
+
assert.match(await readFile(ghTrace, 'utf8'), /pr list --repo selink-lab\/slk-api/);
|
|
161
|
+
assert.doesNotMatch(await readFile(ghTrace, 'utf8'), /pr merge/);
|
|
162
|
+
assert.doesNotMatch(await readFile(gitTrace, 'utf8'), /commit|push/);
|
|
163
|
+
|
|
164
|
+
const redirectedPreflight = preflight({
|
|
165
|
+
FAKE_GH_LOG: mergeLog,
|
|
166
|
+
FAKE_ORIGIN: 'https://github.com/Seokang/slk-api.git',
|
|
167
|
+
FAKE_UPSTREAM: 'git@github.com:selink-lab/slk-api.git',
|
|
168
|
+
FAKE_CANONICAL_REPOSITORY: 'selink-lab/slk-api',
|
|
169
|
+
});
|
|
170
|
+
assert.equal(redirectedPreflight.status, 0, redirectedPreflight.stderr || redirectedPreflight.stdout);
|
|
171
|
+
const redirectedReceipt = JSON.parse(redirectedPreflight.stdout).receipt;
|
|
172
|
+
assert.equal(redirectedReceipt.origin_repository, 'Seokang/slk-api');
|
|
173
|
+
assert.equal(redirectedReceipt.canonical_repository, 'selink-lab/slk-api');
|
|
174
|
+
assert.equal(redirectedReceipt.redirected, true);
|
|
175
|
+
assert.equal(redirectedReceipt.route, 'owner-pr');
|
|
176
|
+
|
|
177
|
+
const contributorPreflight = preflight({
|
|
178
|
+
FAKE_ORIGIN: 'git@github.com:91kwangmin/slk-api.git',
|
|
179
|
+
FAKE_UPSTREAM: 'git@github.com:selink-lab/slk-api.git',
|
|
180
|
+
});
|
|
181
|
+
assert.equal(contributorPreflight.status, 0, contributorPreflight.stderr || contributorPreflight.stdout);
|
|
182
|
+
const contributorReceipt = JSON.parse(contributorPreflight.stdout).receipt;
|
|
183
|
+
assert.equal(contributorReceipt.route, 'contributor-fork');
|
|
184
|
+
assert.equal(contributorReceipt.pr_scan, 'not-applicable');
|
|
185
|
+
|
|
186
|
+
const untrustedRedirect = preflight({
|
|
187
|
+
FAKE_ORIGIN: 'git@github.com:outsider/slk-api.git',
|
|
188
|
+
FAKE_CANONICAL_REPOSITORY: 'outsider/slk-api',
|
|
189
|
+
});
|
|
190
|
+
assert.equal(untrustedRedirect.status, 1, untrustedRedirect.stderr || untrustedRedirect.stdout);
|
|
191
|
+
assert.equal(JSON.parse(untrustedRedirect.stdout).error.code, 'GIT_CANONICAL_REDIRECT_UNTRUSTED');
|
|
128
192
|
|
|
129
193
|
const multipleInspection = inspect({ FAKE_GH_LOG: mergeLog, FAKE_MULTIPLE_PRS: '1' });
|
|
130
194
|
assert.equal(multipleInspection.status, 0, multipleInspection.stderr || multipleInspection.stdout);
|
|
@@ -136,7 +200,7 @@ try {
|
|
|
136
200
|
|
|
137
201
|
const nonOwner = run({ FAKE_GH_LOG: mergeLog, FAKE_USER_ID: '999', FAKE_LOGIN: 'friend' });
|
|
138
202
|
assert.equal(nonOwner.status, 1, nonOwner.stderr || nonOwner.stdout);
|
|
139
|
-
assert.equal(JSON.parse(nonOwner.stdout).error.code, '
|
|
203
|
+
assert.equal(JSON.parse(nonOwner.stdout).error.code, 'OWNER_REQUIRED');
|
|
140
204
|
|
|
141
205
|
const nonOwnerInspection = inspect({ FAKE_GH_LOG: mergeLog, FAKE_USER_ID: '999', FAKE_LOGIN: 'friend' });
|
|
142
206
|
assert.equal(nonOwnerInspection.status, 1, nonOwnerInspection.stderr || nonOwnerInspection.stdout);
|
|
@@ -160,6 +160,7 @@ console.log(JSON.stringify({ id, login: mode === 'owner-renamed' ? 'renamed-owne
|
|
|
160
160
|
assertIncludes(runSk(['help'], env, 'sk help'), 'sk create', 'sk help');
|
|
161
161
|
assertIncludes(runSk(['help'], env, 'sk help'), 'sk update', 'sk help');
|
|
162
162
|
assertIncludes(runSk(['help'], env, 'sk help'), 'sk style', 'sk help');
|
|
163
|
+
assertIncludes(runSk(['help'], env, 'sk help'), 'sk git preflight', 'sk help');
|
|
163
164
|
assertIncludes(runSk(['help'], env, 'sk help'), 'sk identity status', 'sk help');
|
|
164
165
|
assertIncludes(runSk(['help'], env, 'sk help'), 'sk guard implementation --db-impact', 'sk help');
|
|
165
166
|
assertIncludes(runSk(['help'], env, 'sk help'), 'sk guard db-execution', 'sk help');
|
|
@@ -173,6 +174,7 @@ console.log(JSON.stringify({ id, login: mode === 'owner-renamed' ? 'renamed-owne
|
|
|
173
174
|
assertIncludes(runSk(['help', 'guard'], env, 'sk help guard'), '--isolated', 'sk help guard');
|
|
174
175
|
assertIncludes(runSk(['help', 'guard'], env, 'sk help guard'), '--staged', 'sk help guard');
|
|
175
176
|
assertIncludes(runSk(['help', 'guard'], env, 'sk help guard'), '--outbound', 'sk help guard');
|
|
177
|
+
assertIncludes(runSk(['help', 'git'], env, 'sk help git'), 'receipt', 'sk help git');
|
|
176
178
|
|
|
177
179
|
const secretGuardRoot = path.join(tempRoot, 'secret-guard-project');
|
|
178
180
|
const secretGuardOrigin = path.join(tempRoot, 'secret-guard-origin.git');
|
package/scripts/git_pr_merge.mjs
CHANGED
|
@@ -18,23 +18,25 @@ class GitPrMergeError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function usage() {
|
|
21
|
-
console.log('Usage: node scripts/git_pr_merge.mjs (--inspect-open-current | --merge <owner/repo>#<number>) [--cwd <path>] [--json]');
|
|
21
|
+
console.log('Usage: node scripts/git_pr_merge.mjs (--preflight-current | --inspect-open-current | --merge <owner/repo>#<number>) [--cwd <path>] [--json]');
|
|
22
22
|
console.log('$sk-git에서 현재 canonical repository의 열린 PR을 확인하거나 OWNER가 검토한 PR 하나를 squash merge합니다.');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function parseArgs(argv) {
|
|
26
|
-
const options = { cwd: process.cwd(), json: false, inspectOpenCurrent: false, merge: null };
|
|
26
|
+
const options = { cwd: process.cwd(), json: false, preflightCurrent: false, inspectOpenCurrent: false, merge: null };
|
|
27
27
|
for (let index = 0; index < argv.length; index += 1) {
|
|
28
28
|
const arg = argv[index];
|
|
29
29
|
if (arg === '--help' || arg === '-h') return { ...options, help: true };
|
|
30
30
|
if (arg === '--json') options.json = true;
|
|
31
|
+
else if (arg === '--preflight-current') options.preflightCurrent = true;
|
|
31
32
|
else if (arg === '--inspect-open-current') options.inspectOpenCurrent = true;
|
|
32
33
|
else if (arg === '--merge') options.merge = argv[++index];
|
|
33
34
|
else if (arg === '--cwd') options.cwd = path.resolve(argv[++index]);
|
|
34
35
|
else throw new Error(`지원하지 않는 옵션: ${arg}`);
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const selectedModes = [options.preflightCurrent, options.inspectOpenCurrent, Boolean(options.merge)].filter(Boolean).length;
|
|
38
|
+
if (selectedModes !== 1) {
|
|
39
|
+
throw new Error('--preflight-current, --inspect-open-current, --merge <owner/repo>#<number> 중 하나가 필요합니다.');
|
|
38
40
|
}
|
|
39
41
|
if (options.merge && !/^[-\w]+\/[-\w.]+#\d+$/.test(options.merge)) {
|
|
40
42
|
throw new Error('명시적인 --merge <owner/repo>#<number> 형식이 필요합니다.');
|
|
@@ -73,12 +75,14 @@ async function loadPolicy() {
|
|
|
73
75
|
if (!Array.isArray(repositories) || repositories.length === 0) throw new Error('forkPr repository 정책이 비어 있습니다.');
|
|
74
76
|
if (!Array.isArray(prMerge?.allowedAuthors) || prMerge.allowedAuthors.length === 0) throw new Error('PR merge allowedAuthors 정책이 비어 있습니다.');
|
|
75
77
|
return {
|
|
78
|
+
githubHost: document.githubHost,
|
|
76
79
|
organization: prMerge.organization,
|
|
77
80
|
allowedAuthors: prMerge.allowedAuthors,
|
|
78
81
|
repositories,
|
|
79
82
|
strategy: prMerge.strategy,
|
|
80
83
|
requiredCheckNames: prMerge.requiredCheckNames,
|
|
81
84
|
ownerIds: (document.owners || []).map((owner) => Number(owner.githubUserId)).filter(Number.isInteger),
|
|
85
|
+
ownerLogins: (document.owners || []).map((owner) => String(owner.githubLogin || '').toLowerCase()).filter(Boolean),
|
|
82
86
|
};
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -168,23 +172,73 @@ function parseGitHubRemote(remote) {
|
|
|
168
172
|
return match ? `${match[1]}/${match[2]}` : null;
|
|
169
173
|
}
|
|
170
174
|
|
|
171
|
-
function
|
|
172
|
-
const result = spawnSync('git', ['remote', 'get-url',
|
|
173
|
-
if (result.error || result.status !== 0)
|
|
174
|
-
|
|
175
|
+
function inspectRemote(cwd, name, required = true) {
|
|
176
|
+
const result = spawnSync('git', ['remote', 'get-url', name], { cwd, encoding: 'utf8' });
|
|
177
|
+
if (result.error || result.status !== 0) {
|
|
178
|
+
if (!required) return null;
|
|
179
|
+
throw new GitPrMergeError('GIT_REMOTE_UNAVAILABLE', `${name} remote 확인`);
|
|
180
|
+
}
|
|
181
|
+
const repository = parseGitHubRemote(result.stdout);
|
|
182
|
+
if (!repository) {
|
|
183
|
+
if (!required) return null;
|
|
184
|
+
throw new GitPrMergeError('GIT_REMOTE_UNSUPPORTED', `${name} GitHub repository 확인`);
|
|
185
|
+
}
|
|
186
|
+
return repository;
|
|
175
187
|
}
|
|
176
188
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
function canonicalRepository(policy, repository) {
|
|
190
|
+
return policy.repositories
|
|
191
|
+
.map((name) => `${policy.organization}/${name}`)
|
|
192
|
+
.find((candidate) => candidate.toLowerCase() === String(repository || '').toLowerCase()) || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveCurrentRepository(policy, cwd, currentRepository = inspectRemote(cwd, 'origin')) {
|
|
196
|
+
const direct = canonicalRepository(policy, currentRepository);
|
|
197
|
+
if (direct) {
|
|
198
|
+
return { repository: direct, originRepository: currentRepository, redirected: false };
|
|
182
199
|
}
|
|
183
|
-
|
|
200
|
+
|
|
201
|
+
const metadata = ghJson(['api', `repos/${currentRepository}`], `${currentRepository} canonical repository 확인`);
|
|
202
|
+
const resolved = canonicalRepository(policy, metadata?.full_name);
|
|
203
|
+
if (!resolved) {
|
|
204
|
+
throw new GitPrMergeError('GIT_CANONICAL_REDIRECT_UNTRUSTED', `${currentRepository} canonical repository 확인`);
|
|
205
|
+
}
|
|
206
|
+
return { repository: resolved, originRepository: currentRepository, redirected: resolved.toLowerCase() !== currentRepository.toLowerCase() };
|
|
184
207
|
}
|
|
185
208
|
|
|
186
|
-
function
|
|
187
|
-
const
|
|
209
|
+
function resolvePreflightRoute(policy, cwd) {
|
|
210
|
+
const originRepository = inspectRemote(cwd, 'origin');
|
|
211
|
+
const direct = canonicalRepository(policy, originRepository);
|
|
212
|
+
if (direct) {
|
|
213
|
+
return { route: 'owner-pr', resolution: { repository: direct, originRepository, redirected: false } };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const originOwner = originRepository.split('/')[0]?.toLowerCase();
|
|
217
|
+
if (policy.ownerLogins.includes(originOwner)) {
|
|
218
|
+
return { route: 'owner-pr', resolution: resolveCurrentRepository(policy, cwd, originRepository) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const upstreamRepository = inspectRemote(cwd, 'upstream', false);
|
|
222
|
+
const upstreamCanonical = canonicalRepository(policy, upstreamRepository);
|
|
223
|
+
const originName = originRepository.split('/')[1]?.toLowerCase();
|
|
224
|
+
const upstreamName = upstreamRepository?.split('/')[1]?.toLowerCase();
|
|
225
|
+
if (upstreamCanonical && originName && originName === upstreamName) {
|
|
226
|
+
return { route: 'contributor-fork', originRepository, repository: upstreamCanonical };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { route: 'owner-pr', resolution: resolveCurrentRepository(policy, cwd, originRepository) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function requireOwnerCredential(policy) {
|
|
233
|
+
const identity = ghJson(['api', 'user'], 'GitHub OWNER credential 확인');
|
|
234
|
+
if (!policy.ownerIds.includes(Number(identity?.id))) {
|
|
235
|
+
throw new GitPrMergeError('OWNER_REQUIRED', 'GitHub OWNER credential 확인');
|
|
236
|
+
}
|
|
237
|
+
return identity;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function inspectOpenCurrent(policy, cwd, resolution = resolveCurrentRepository(policy, cwd)) {
|
|
241
|
+
const repository = resolution.repository;
|
|
188
242
|
const repositoryState = inspectRepository(repository, policy.requiredCheckNames);
|
|
189
243
|
const repositoryPolicy = { ...policy, trustedRepository: repository };
|
|
190
244
|
const evaluationOptions = {
|
|
@@ -222,26 +276,85 @@ function inspectOpenCurrent(policy, cwd) {
|
|
|
222
276
|
|
|
223
277
|
return {
|
|
224
278
|
repository,
|
|
279
|
+
originRepository: resolution.originRepository,
|
|
280
|
+
redirected: resolution.redirected,
|
|
225
281
|
defaultBranch: repositoryState.defaultBranch,
|
|
226
282
|
mainSha: repositoryState.mainSha,
|
|
227
283
|
status: pullRequests.length > 0 ? 'review-required' : 'none',
|
|
284
|
+
openCount: openPullRequests.length,
|
|
228
285
|
pullRequests,
|
|
229
286
|
ignored,
|
|
230
287
|
};
|
|
231
288
|
}
|
|
232
289
|
|
|
290
|
+
function withPreflightReceipt(result) {
|
|
291
|
+
return {
|
|
292
|
+
...result,
|
|
293
|
+
receipt: {
|
|
294
|
+
schema_version: 1,
|
|
295
|
+
command: 'sk git preflight',
|
|
296
|
+
executed: true,
|
|
297
|
+
route: 'owner-pr',
|
|
298
|
+
pr_scan: 'executed',
|
|
299
|
+
status: result.status,
|
|
300
|
+
origin_repository: result.originRepository,
|
|
301
|
+
canonical_repository: result.repository,
|
|
302
|
+
redirected: result.redirected,
|
|
303
|
+
open_count: result.openCount,
|
|
304
|
+
candidate_count: result.pullRequests.length,
|
|
305
|
+
ignored_count: result.ignored.length,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function contributorPreflightReceipt(route) {
|
|
311
|
+
return {
|
|
312
|
+
status: 'not-applicable',
|
|
313
|
+
repository: route.repository,
|
|
314
|
+
originRepository: route.originRepository,
|
|
315
|
+
receipt: {
|
|
316
|
+
schema_version: 1,
|
|
317
|
+
command: 'sk git preflight',
|
|
318
|
+
executed: true,
|
|
319
|
+
route: 'contributor-fork',
|
|
320
|
+
pr_scan: 'not-applicable',
|
|
321
|
+
status: 'not-applicable',
|
|
322
|
+
origin_repository: route.originRepository,
|
|
323
|
+
canonical_repository: route.repository,
|
|
324
|
+
redirected: false,
|
|
325
|
+
open_count: null,
|
|
326
|
+
candidate_count: null,
|
|
327
|
+
ignored_count: null,
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function mergeReceipt(status, repository, currentRepository, number) {
|
|
333
|
+
return {
|
|
334
|
+
schema_version: 1,
|
|
335
|
+
command: 'sk git merge',
|
|
336
|
+
executed: true,
|
|
337
|
+
route: 'owner-pr',
|
|
338
|
+
status,
|
|
339
|
+
origin_repository: currentRepository.originRepository,
|
|
340
|
+
canonical_repository: repository,
|
|
341
|
+
redirected: currentRepository.redirected,
|
|
342
|
+
pr_number: Number(number),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
233
346
|
function runExplicitMerge(target, policy, cwd) {
|
|
234
347
|
const [repository, numberText] = target.split('#');
|
|
235
|
-
const
|
|
236
|
-
if (!
|
|
237
|
-
const currentRepository =
|
|
238
|
-
if (currentRepository
|
|
239
|
-
throw new
|
|
348
|
+
const allowedRepository = canonicalRepository(policy, repository);
|
|
349
|
+
if (!allowedRepository) throw new GitPrMergeError('PR_REPOSITORY_NOT_ALLOWED', `${repository} PR merge 대상 확인`);
|
|
350
|
+
const currentRepository = resolveCurrentRepository(policy, cwd);
|
|
351
|
+
if (currentRepository.repository.toLowerCase() !== allowedRepository.toLowerCase()) {
|
|
352
|
+
throw new GitPrMergeError('CURRENT_REPOSITORY_MISMATCH', `${currentRepository.repository} != ${allowedRepository}`);
|
|
240
353
|
}
|
|
241
354
|
|
|
242
|
-
const repositoryState = inspectRepository(
|
|
243
|
-
const pr = inspectPullRequest(
|
|
244
|
-
const repositoryPolicy = { ...policy, trustedRepository:
|
|
355
|
+
const repositoryState = inspectRepository(allowedRepository, policy.requiredCheckNames);
|
|
356
|
+
const pr = inspectPullRequest(allowedRepository, Number(numberText));
|
|
357
|
+
const repositoryPolicy = { ...policy, trustedRepository: allowedRepository };
|
|
245
358
|
const evaluationOptions = {
|
|
246
359
|
allowedRiskLevels: ['low', 'medium', 'high'],
|
|
247
360
|
allowReviewRequired: true,
|
|
@@ -250,11 +363,24 @@ function runExplicitMerge(target, policy, cwd) {
|
|
|
250
363
|
};
|
|
251
364
|
const evaluation = evaluatePullRequest(pr, repositoryPolicy, repositoryState, evaluationOptions);
|
|
252
365
|
if (!evaluation.eligible) {
|
|
253
|
-
return {
|
|
366
|
+
return {
|
|
367
|
+
target: `${allowedRepository}#${numberText}`,
|
|
368
|
+
status: 'held',
|
|
369
|
+
url: pr.url,
|
|
370
|
+
evaluation,
|
|
371
|
+
receipt: mergeReceipt('held', allowedRepository, currentRepository, numberText),
|
|
372
|
+
};
|
|
254
373
|
}
|
|
255
374
|
|
|
256
|
-
const mergeResult = revalidateAndMerge(
|
|
257
|
-
return {
|
|
375
|
+
const mergeResult = revalidateAndMerge(allowedRepository, pr, repositoryPolicy, repositoryState, evaluationOptions);
|
|
376
|
+
return {
|
|
377
|
+
target: `${allowedRepository}#${numberText}`,
|
|
378
|
+
status: mergeResult.status,
|
|
379
|
+
url: pr.url,
|
|
380
|
+
evaluation,
|
|
381
|
+
mergeResult,
|
|
382
|
+
receipt: mergeReceipt(mergeResult.status, allowedRepository, currentRepository, numberText),
|
|
383
|
+
};
|
|
258
384
|
}
|
|
259
385
|
|
|
260
386
|
async function main() {
|
|
@@ -262,17 +388,27 @@ async function main() {
|
|
|
262
388
|
if (options.help) return usage();
|
|
263
389
|
|
|
264
390
|
const policy = await loadPolicy();
|
|
265
|
-
if (!policy.organization || policy.strategy !== 'squash') throw new Error('지원하지 않는 PR merge 정책입니다.');
|
|
266
|
-
const identity = ghJson(['api', 'user'], 'GitHub OWNER credential 확인');
|
|
267
|
-
if (!policy.ownerIds.includes(Number(identity?.id))) throw new Error(`OWNER 계정이 아니므로 PR 병합을 실행하지 않습니다: ${identity?.login || 'unknown'}`);
|
|
391
|
+
if (!policy.githubHost || !policy.organization || policy.strategy !== 'squash') throw new Error('지원하지 않는 PR merge 정책입니다.');
|
|
268
392
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
393
|
+
let result;
|
|
394
|
+
if (options.preflightCurrent) {
|
|
395
|
+
const route = resolvePreflightRoute(policy, options.cwd);
|
|
396
|
+
if (route.route === 'contributor-fork') {
|
|
397
|
+
result = contributorPreflightReceipt(route);
|
|
398
|
+
} else {
|
|
399
|
+
requireOwnerCredential(policy);
|
|
400
|
+
result = withPreflightReceipt(inspectOpenCurrent(policy, options.cwd, route.resolution));
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
requireOwnerCredential(policy);
|
|
404
|
+
result = options.inspectOpenCurrent
|
|
405
|
+
? withPreflightReceipt(inspectOpenCurrent(policy, options.cwd))
|
|
406
|
+
: runExplicitMerge(options.merge, policy, options.cwd);
|
|
407
|
+
}
|
|
272
408
|
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
273
|
-
else if (options.inspectOpenCurrent) console.log(`
|
|
409
|
+
else if (options.preflightCurrent || options.inspectOpenCurrent) console.log(`Git PR preflight: ${result.repository} ${result.status} (${result.pullRequests?.length || 0})`);
|
|
274
410
|
else console.log(`OWNER PR merge: ${result.target} ${result.status} ${result.url}`);
|
|
275
|
-
if (!options.inspectOpenCurrent && result.status !== 'merged') process.exitCode = 2;
|
|
411
|
+
if (!options.preflightCurrent && !options.inspectOpenCurrent && result.status !== 'merged') process.exitCode = 2;
|
|
276
412
|
}
|
|
277
413
|
|
|
278
414
|
main().catch((error) => {
|
package/scripts/sk.mjs
CHANGED
|
@@ -768,6 +768,7 @@ function generalHelpLines() {
|
|
|
768
768
|
{ name: 'sk style', description: '기존 프로젝트 profile audit/apply' },
|
|
769
769
|
{ name: 'sk check', description: '설치 결과와 공용 원본의 차이 점검' },
|
|
770
770
|
{ name: 'sk status', description: '현재 프로젝트 상태와 최근 백업 확인' },
|
|
771
|
+
{ name: 'sk git preflight', description: 'plain $sk-git의 현재 저장소 PR 확인 receipt 생성' },
|
|
771
772
|
{ name: 'sk identity status', description: '선택적 GitHub operator 진단 정보 확인' },
|
|
772
773
|
{ name: 'sk guard implementation --db-impact', description: '호환용 명령: DB 코드 작성은 허용하고 실행 금지를 안내' },
|
|
773
774
|
{ name: 'sk guard db-execution', description: 'local test/CI/build 전 production DB 자동 실행 경계 검사' },
|
|
@@ -974,6 +975,21 @@ function commandHelpLines(command) {
|
|
|
974
975
|
'sk status --json',
|
|
975
976
|
],
|
|
976
977
|
},
|
|
978
|
+
git: {
|
|
979
|
+
title: 'sk git - Git workflow 실행형 preflight',
|
|
980
|
+
usage: 'sk git preflight [--cwd <path>] [--json]',
|
|
981
|
+
summary: [
|
|
982
|
+
'plain $sk-git의 첫 단계에서 현재 저장소의 PR 처리 경로를 확정합니다.',
|
|
983
|
+
'OWNER canonical 저장소는 local 변경 유무와 무관하게 열린 contributor PR을 조회하고 receipt를 반환합니다.',
|
|
984
|
+
'개인 fork는 OWNER PR scan을 실행하지 않고 기존 fork sync/PR 경로를 유지합니다.',
|
|
985
|
+
],
|
|
986
|
+
options: [
|
|
987
|
+
{ flag: 'preflight', description: '현재 저장소의 Git PR 처리 경로와 열린 PR receipt 생성' },
|
|
988
|
+
{ flag: '--cwd <path>', description: '대상 제품 저장소 경로' },
|
|
989
|
+
{ flag: '--json', description: '기계 판독 가능한 receipt JSON 출력' },
|
|
990
|
+
],
|
|
991
|
+
examples: ['sk git preflight --json', 'sk git preflight --cwd /path/to/project --json'],
|
|
992
|
+
},
|
|
977
993
|
identity: {
|
|
978
994
|
title: 'sk identity - 선택적 operator 진단',
|
|
979
995
|
usage: 'sk identity status [--json]',
|
|
@@ -1081,6 +1097,7 @@ function commandHelpLines(command) {
|
|
|
1081
1097
|
' sk help style',
|
|
1082
1098
|
' sk help check',
|
|
1083
1099
|
' sk help status',
|
|
1100
|
+
' sk help git',
|
|
1084
1101
|
' sk help identity',
|
|
1085
1102
|
' sk help guard',
|
|
1086
1103
|
' sk help memory',
|
|
@@ -4408,6 +4425,23 @@ async function runGuard(flags, rest) {
|
|
|
4408
4425
|
if (payload.decision !== ACCESS_DECISION.ALLOW) process.exitCode = 2;
|
|
4409
4426
|
}
|
|
4410
4427
|
|
|
4428
|
+
async function runGitWorkflow(flags, rest) {
|
|
4429
|
+
const subcommand = rest[0] || '';
|
|
4430
|
+
if (subcommand !== 'preflight' || rest.length !== 1) {
|
|
4431
|
+
fail('사용법: sk git preflight [--cwd <path>] [--json]');
|
|
4432
|
+
}
|
|
4433
|
+
const cwd = resolve(String(flags.get('cwd') || process.cwd()));
|
|
4434
|
+
const helperArgs = [
|
|
4435
|
+
join(REPO_ROOT, 'scripts', 'git_pr_merge.mjs'),
|
|
4436
|
+
'--preflight-current',
|
|
4437
|
+
'--cwd', cwd,
|
|
4438
|
+
];
|
|
4439
|
+
if (flags.get('json')) helperArgs.push('--json');
|
|
4440
|
+
const result = spawnSync(process.execPath, helperArgs, { cwd, stdio: 'inherit' });
|
|
4441
|
+
if (result.error) throw new Error(`Git workflow preflight 실행 실패: ${result.error.message}`);
|
|
4442
|
+
if (result.status !== 0) process.exitCode = result.status ?? 1;
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4411
4445
|
async function main() {
|
|
4412
4446
|
const [command, ...args] = process.argv.slice(2);
|
|
4413
4447
|
if (!command || command === '--help' || command === '-h') {
|
|
@@ -4489,6 +4523,16 @@ async function main() {
|
|
|
4489
4523
|
return;
|
|
4490
4524
|
}
|
|
4491
4525
|
|
|
4526
|
+
if (command === 'git') {
|
|
4527
|
+
const { flags, rest } = parseFlags(args);
|
|
4528
|
+
if (flags.get('help')) {
|
|
4529
|
+
printUsage('git');
|
|
4530
|
+
return;
|
|
4531
|
+
}
|
|
4532
|
+
await runGitWorkflow(flags, rest);
|
|
4533
|
+
return;
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4492
4536
|
const { flags, rest } = parseFlags(args);
|
|
4493
4537
|
if (flags.get('help')) {
|
|
4494
4538
|
printUsage(command);
|