diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index dbd43187c2..4be3e85c79 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -3,3 +3,4 @@ self-hosted-runner: labels: - cwlab-ci-isolated - cwlab-control + - k-csap-isolated diff --git a/.github/workflows/k-csap-skills-quality.yml b/.github/workflows/k-csap-skills-quality.yml new file mode 100644 index 0000000000..a0915b5056 --- /dev/null +++ b/.github/workflows/k-csap-skills-quality.yml @@ -0,0 +1,176 @@ +name: K-CSAP Skills Quality + +on: + workflow_call: + +permissions: + contents: read + +# Coalesce superseded heads of the same PR before runner assignment. Non-PR runs +# use the unique run id so protected-branch runs never cancel each other. +concurrency: + group: k-csap-skills-quality-${{ github.repository }}-${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: true + +jobs: + quality: + name: k-csap-skills-quality + # This job gate is evaluated before runner assignment. A skipped job is not a quality verdict. + if: >- + github.repository == 'ContextualWisdomLab/k-csap-skills' && + ((github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.state == 'open' && + github.event.pull_request.head.sha != '' && + github.event.pull_request.number > 0 && + github.ref == format('refs/pull/{0}/merge', github.event.pull_request.number)) || + ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/master')) + runs-on: [self-hosted, Linux, X64, k-csap-isolated] + timeout-minutes: 20 + steps: + - name: Admit only trusted K-CSAP source + id: admission + env: + TARGET_REPOSITORY: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || '' }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} + EVENT_SHA: ${{ github.sha }} + EVENT_REF: ${{ github.ref }} + PR_STATE: ${{ github.event.pull_request.state || '' }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + set -euo pipefail + if [ "$TARGET_REPOSITORY" != "ContextualWisdomLab/k-csap-skills" ]; then + echo "::error::K-CSAP quality rejected an unexpected caller repository." + exit 1 + fi + case "$EVENT_NAME" in + push|workflow_dispatch) + if [ "$EVENT_REF" != 'refs/heads/master' ]; then + echo "::error::K-CSAP quality only accepts master branch pushes or dispatches." + exit 1 + fi + EXPECTED_SHA="$EVENT_SHA" + ;; + pull_request) + if [ "$HEAD_REPOSITORY" != "$TARGET_REPOSITORY" ] || [ "$PR_STATE" != "open" ]; then + echo "::error::K-CSAP quality accepts only open same-repository pull requests." + exit 1 + fi + if ! [[ "$EVENT_REF" =~ ^refs/pull/[1-9][0-9]*/merge$ ]]; then + echo "::error::K-CSAP quality rejected a malformed pull request ref." + exit 1 + fi + EXPECTED_SHA="$PR_HEAD_SHA" + ;; + *) + echo "::error::K-CSAP quality rejected an unsupported event." + exit 1 + ;; + esac + if ! [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::K-CSAP quality rejected a malformed source SHA." + exit 1 + fi + echo "expected_sha=$EXPECTED_SHA" >>"$GITHUB_OUTPUT" + echo "admitted=true" >>"$GITHUB_OUTPUT" + + - name: Prepare run-owned source directory + id: prepare + env: + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + set -euo pipefail + if ! [[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]] || ! [[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Invalid run identity." + exit 1 + fi + if [ -L "${GITHUB_WORKSPACE:?}" ] || [ ! -d "$GITHUB_WORKSPACE" ]; then + echo "::error::Workspace root is not a regular directory." + exit 1 + fi + source_path="k-csap-quality.${RUN_ID}.${RUN_ATTEMPT}" + if [ -e "$GITHUB_WORKSPACE/$source_path" ] || [ -L "$GITHUB_WORKSPACE/$source_path" ]; then + echo "::error::Source path already exists; refusing stale source." + exit 1 + fi + umask 077 + mkdir -- "$GITHUB_WORKSPACE/$source_path" + echo "source_path=$source_path" >>"$GITHUB_OUTPUT" + + # PR exact head is checked; merge-candidate integration is a separate gate. + - name: Checkout exact caller source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.admission.outputs.expected_sha }} + path: ${{ steps.prepare.outputs.source_path }} + persist-credentials: false + clean: true + + - name: Prepare private setup action storage + working-directory: ${{ github.workspace }}/${{ steps.prepare.outputs.source_path }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + set -euo pipefail + test ! -e .ci-tools && test ! -L .ci-tools + umask 077 + mkdir -p .ci-tools/tmp .ci-tools/tool-cache + + - name: Install pinned uv action + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + env: + RUNNER_TOOL_CACHE: ${{ github.workspace }}/${{ steps.prepare.outputs.source_path }}/.ci-tools/tool-cache + RUNNER_TEMP: ${{ github.workspace }}/${{ steps.prepare.outputs.source_path }}/.ci-tools/tmp + with: + version: '0.9.7' + checksum: b26fcc8dfa1c39b5a5613445af3be3eefda45d9a39359bee271eafe34913583e + enable-cache: false + restore-cache: false + save-cache: false + cache-local-path: ${{ github.workspace }}/${{ steps.prepare.outputs.source_path }}/.ci-tools/cache + tool-dir: ${{ github.workspace }}/${{ steps.prepare.outputs.source_path }}/.ci-tools/uv-tools + tool-bin-dir: ${{ github.workspace }}/${{ steps.prepare.outputs.source_path }}/.ci-tools/bin + + - name: Verify K-CSAP quality in a job-private environment + working-directory: ${{ github.workspace }}/${{ steps.prepare.outputs.source_path }} + env: + EXPECTED_SHA: ${{ steps.admission.outputs.expected_sha }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + set -euo pipefail + test -d .ci-tools/tmp && test ! -L .ci-tools/tmp + job_root="$(mktemp -d "$PWD/.ci-tools/tmp/k-csap-quality.XXXXXX")" + cleanup() { rm -rf -- "$job_root"; } + trap cleanup EXIT + unset PYTHONHOME PYTHONPATH + export UV_CACHE_DIR="$job_root/uv-cache" + export UV_PYTHON_INSTALL_DIR="$job_root/python" + export UV_PROJECT_ENVIRONMENT="$job_root/venv" + export UV_NO_CONFIG=1 + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + uv python install 3.11.14 + uv sync --locked --dev --python 3.11.14 + uv run --python 3.11.14 ruff check . + uv run --python 3.11.14 pytest -q + uv run --python 3.11.14 python scripts/validate.py + # A clean checkout has no working-tree diff; check committed content. + git diff --check "$(git hash-object -t tree /dev/null)" HEAD + + - name: Clean run-owned source after any step result + if: always() + env: + SOURCE_PATH: ${{ steps.prepare.outputs.source_path }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + set -euo pipefail + if [ -z "$SOURCE_PATH" ]; then exit 0; fi + if ! [[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]] || ! [[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then exit 1; fi + if [ "$SOURCE_PATH" != "k-csap-quality.${RUN_ID}.${RUN_ATTEMPT}" ]; then exit 1; fi + if [ -L "${GITHUB_WORKSPACE:?}" ] || [ ! -d "$GITHUB_WORKSPACE" ]; then exit 1; fi + rm -rf -- "${GITHUB_WORKSPACE:?}/$SOURCE_PATH" diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000000..fdcd70af0e --- /dev/null +++ b/GOAL.md @@ -0,0 +1,84 @@ +# Goal — K-CSAP 중앙 self-hosted 실행과 조건부 병합 + +갱신: 2026-10-13 KST. 이 worktree의 단일 작성자는 goosefish 부모다. + +## 최신 직접 권한과 실행 분해 + +사용자는 인프라 장애에 대응하기 위한 bypass merge를 직접 허용했다. 아래 이전의 일반 병합 전용·일괄 HOLD 조건은 이 권한 범위에서 대체한다. 소스 전달과 운영 활성화를 분리한다. 확인된 코드·보안 결함은 수리하고, 미실행 CI/App 검사는 PASS나 APPROVED로 만들지 않는다. 사람 팀원을 추가로 요구하지 않는다. 미래 head 실행을 그 head의 게시 선행조건으로 두지 않는다. + +| 조각 | 담당 | 다음 실제 동작 | 완료 증거 | 진짜 선행 조건 | +|---|---|---|---|---| +| 현재 PR2615 전달 가능성 | 부모 + 읽기전용 평가자 | live head/base/checks/threads와 retained source review를 대조하고 우회할 인프라 check를 특정 | exact-head 병합 사전판정, 실제 merge와 readback 또는 실제 거절 | 미해결 substantive 소스 결함이 없음 | +| 전체 gate 실패 분리 | 별도 읽기전용 평가자 | 기존 9개 실패와 coverage 미달을 환경·상속 소스·미검증으로 분류하고 기존 수리 찾아 결합 범위 제시 | 현재 bytes에 근거한 최소 수리 경로 | label/runner/App 대기 불필요 | +| canonical label | 기존 shared owner + 부모 | 기존 한 줄 patch 수락과 producer config 연결, 기본 actionlint 실행 | canonical invocation 직접 exit | shared 파일 쓰기 경계만 직렬화 | +| central exact-head runtime 연결 | 부모 producer + oscar runtime | 실제 consumer 인자·승인 계약에 맞춘 adapter 구현·시험 | runnable adapter와 승인된 Linux lifecycle 증거 | 실행 시 runner custody·credential 분리 | +| literal auto/App 복구 | Remora/#2560 | 기존 경로의 실제 inference와 독립 App 게시 복구 | 실제 verdict/review object | 키·비공개 자료 보호 | + +인프라 복구는 source delivery와 병렬로 진행한다. bypass는 exact PR/head/base와 실제 인프라 실패에 한정한다. 보호 설정 삭제·가짜 status·자기 승인·force push·키/ACL 확대는 하지 않는다. 로컬 untracked CLI의 검증은 현재 원격 PR의 검증과 구별한다. 이전 head와 일정은 아래 역사적 기록이며 live 조회로 대체한다. + + +## 완료 결과 + +중앙 `ContextualWisdomLab/.github` PR #2615를 검증과 독립 App 승인 후 일반 병합으로 `main`에 전달한다. 제품 `k-csap-skills`에는 검증된 중앙 commit을 호출하는 얇은 caller만 남긴다. 실제 self-hosted job과 개인 LiteLLM `auto` 리뷰를 실행하고, 작성자와 다른 Noema/OpenCode App의 현재 head 승인을 확인한다. + +사용자는 commit/push/조건부 merge와 Startup failure 경로의 중앙 self-hosted 전환을 승인했다. 전환 불가 hosted 절차의 비활성화도 승인했다. 이는 검증·개인정보·키 관리·보호 규칙 완화나 미실행 증거의 통과 승격 승인이 아니다. 별도의 인간 개발자를 전제로 하지 않는다. + +## 2026-10-10 기준 직접 확인 (역사 기록; 현재 값은 위 표와 live 조회로 대체) + +- PR #2615 head: `aacc387349c62b54ed96a9bd96b17c022296897b`; OPEN/Draft/BLOCKED, formal review 0. +- 중앙 protected base: `7554587c2e3106a388998bcad048a3d7121de25e`. +- 2026-10-10 기준 공유 #2565 head: `ef0c9d4c668fac7f01609b8fbd61a4126b103f5c`; 당시 아직 protected main에 전달되지 않았다. 이후 #2565는 main에 병합됐다. +- callee workflow SHA-256: `2b14845ebf761652ca78ecdaac86c6e851ea41dc573312f41ade2695b0a719b7`. +- test SHA-256: `36bbf733ea0bec7e0cd3b03cb07d6c466cf19fffb578f16114e820a9d69c3737`. +- fresh source review `dbc0d58d24a098c68f33268fcd8dc1a6de00f57bfe729aa0dd0272fd4e29ff62`: 조건부 소스 수용. actual admission 35개와 SHA 소비자 4개 통과, frozen test 28개 통과. 실행 도구 대체는 합성 fixture이며 제품 runtime 실행이 아니다. +- setup-uv review `2159eec4f6343b82250124b78af57ed8925deaa8a63dc007a6f7e15a9670da83`: 실제 pinned dist macOS arm64/Node24 설치·checksum 거부·post cleanup 확인. Linux X64/Node20 및 Python runtime 무결성은 미검증이다. +- 제품 repo runner id 2 `k-csap-isolated-linux`는 online/idle이며 labels `[self-hosted, Linux, X64, k-csap-isolated]`다. online/label은 격리·실행 증거가 아니다. Group 13은 사용하지 않는다. +- 제품은 비공개다. 원문·추출본·private 증적·키를 공개 중앙 저장소로 전송하지 않는다. + +## 소유권과 기존 경로 + +| 영역 | 단일 담당 | 이 Goal의 경계 | +|---|---|---| +| PR #2615 producer, 전용 tests/hash lock, 이 Goal, doctoring 문서 | goosefish 부모 | exact reviewed bytes만 수정·출판 | +| 제품 thin caller와 runner 활성화 | oscar 및 기존 운영 담당 | 승인된 새 caller/격리 packet을 받아 연결; 경쟁 편집·runner 이동 금지 | +| acceptance 조정 | salmon, 독립 admission oracle | actual packet을 확인하며 보고를 승인으로 승격하지 않음 | +| shared routing/Noema/OpenCode/canonical actionlint | #2565/Remora 담당 | accepted exact-hunk 인계 전 shared 파일 편집 금지 | +| 서비스 키·relay·effective auto route | 기존 #2560 담당 | Secret 값 읽기·중복 발급·덮어쓰기 금지 | +| 제품 dynamic Code Quality 설정 | kelp | GET not-configured 확인 완료, 이 session 제품 설정 write 0 | + +## 실행 계약 + +- `workflow_call`만 사용하고 executable inputs/inherited secrets는 없다. +- caller 신뢰 gate를 runner 배정 전 job-level `if`에 둔다. 첫 step는 checkout 전 방어층이며 host 격리를 대신하지 않는다. skip은 quality 통과가 아니다. +- same-repo open PR과 positive-number `refs/pull/N/merge`만 허용한다. PR head와 event SHA/state는 분리한다. missing head/state fallback은 금지한다. +- PR exact head, master push/manual event SHA를 admission에서 확정한다. checkout과 HEAD 검증은 동일 `expected_sha` 출력만 소비한다. merge candidate 검증은 별도다. +- 일회용 source child와 private uv tool/cache/temp/venv, archive checksum 및 정상 종료 cleanup을 유지한다. hostile same-UID 동시 작업·잔류 프로세스·강제 종료에 대한 격리는 별도 운영 gate다. +- Python 3.11.14와 uv 0.9.7로 locked ruff/pytest/validate/diff-check를 실행한다. 로컬 contract 환경 Python 3.11.16을 workflow pin 실행으로 주장하지 않는다. +- canonical actionlint의 `k-csap-isolated` vocabulary와 전용 test lock 설치 linkage를 수락해야 한다. scratch vocabulary 통과는 canonical gate 통과가 아니다. +- 모델은 literal `auto`, endpoint는 `https://litellm.poinnetworks.net`다. `orchestrator/free` 실행은 대체 수락하지 않는다. 비용·private retention 사실을 임의 attestation으로 만들지 않는다. + +## 병합 전 수락 단계 + +1. 실제 source bytes와 로컬 독립 review의 hash binding을 유지한다. unchanged suite 반복 대신 다음 미충족 gate로 진행한다. +2. exact-head 운영 packet에서 Linux/Node runtime, toolchain provenance, 격리·동시 작업·network·cleanup을 확인한다. approved runtime이 없으면 운영 담당과 좁게 수리한다. +3. 공유 auto route의 base chain을 default `main`까지 추적한다. feature-stack merge는 배포가 아니다. canonical lint label과 test dependency linkage도 해당 소유자와 결합한다. +4. 실제 self-hosted run/job ID와 runner_name, 실행된 source SHA 및 required step exits를 확인한다. +5. 실제 개인 gateway 인증 추론과 substantive verdict, author-distinct canonical App의 현재 head/base review를 확인한다. Draft skip/무료 요약/인프라 오류는 approval이 아니다. +6. 리뷰 요청 단계와 병합 수락 단계를 분리한다. Ready는 승인·검증 통과가 아니다. 소스 검토와 live head/base/필수 규칙/미해결 finding 확인 후, 실행 가능한 승인된 reviewer 경로가 있으면 Ready만 전환할 수 있다. 실제 callee 증거는 oscar가 승인된 임시 caller에서 immutable 후보 SHA를 사용해 병합 전에 확보한다. legacy/다른 모델 실행은 요구된 `auto` 증거로 세지 않는다. +7. 병합 직전 live head/checks/required rules/unresolved findings를 fail-closed로 판정한다. 실패·skip·부재·다른 head 증거가 있으면 merge하지 않는다. 모든 조건 충족 후 일반 merge를 수행하고 state/mergedAt/mergeCommit/default branch를 읽어 확인한다. 최종 제품 caller는 immutable protected producer pin으로 전환하고 실제 caller-context 실행을 확인한다. hosted fallback 없이 교체한다. + +## 현재 수락 상태 + +현재 사용자 지시에 따라 병합 작업까지 진행한다. PR #2615는 `OPEN/DRAFT/BLOCKED`, head `aacc387349c62b54ed96a9bd96b17c022296897b`, source review는 `CONDITIONAL_SOURCE_ACCEPTANCE`다. 이는 merge gate 충족이 아니다. source owner scope는 #2615의 여섯 파일이며 shared #2565, #2560, Remora, kelp 설정 파일은 소유하지 않는다. + +## 다음 실제 작업 + +1. 읽기전용 운영 보고의 최종 해시를 검증했다. online/container 관측은 배타 lease나 process settlement/cleanup 통과가 아니다. oscar의 기존 운영 수리와 caller 후보를 재사용한다. +2. permissive umask에서 source와 `.ci-tools`가 0777로 만들어지는 실제 shell RED 2개를 확인했다. 각 생성 단계에 `umask 077`을 적용하고 계약 테스트 30개 통과를 확인했다. 이는 다른 UID의 접근 제한이며 같은 UID의 적대적 writer 격리 증거가 아니다. 독립 delta 리뷰 후 게시한다. +3. canonical CI lock의 PyYAML 누락은 실제 collection RED exit 2로 확인했다. dedicated PyYAML 6.0.3 hash block을 추가한 scratch 제안은 28개 통과다. shared 소유자에게 comment `6098771884`로 전달했다. 2026-10-11 remote head `b78007bfc976faaa4d416d2b6578b114e2725a82`의 canonical lock에 PyYAML pin이 추가된 사실을 확인했다. 해당 pin의 설치 검증 및 보호된 main 통합은 별도다. canonical lint의 `k-csap-isolated`는 이 head에서도 없다. +4. bootstrap plan review `5eaa615dc1f6ac143f526329ae46d68f95284452114cada4a83c77e3391586b1`의 CHANGES_REQUESTED를 채택했다. Ready와 merge를 분리하고 임시 exact-SHA caller로 실행 순환을 해소한다. 실제 `auto`/App 및 운영 수락 조건은 유지한다. +5. 기존 caller 후보는 PR32 base 위의 oscar 소유 2파일이며 아직 미게시다. source가 바뀌면 기존 후보 pin의 증거를 새 SHA로 이전하지 않는다. 독립 리뷰와 운영 승인 후 소유자가 pin을 갱신하고 실행한다. + +## Hosted 절차 중단 상태 + +중앙 동적 Code Quality는 supported PATCH로 `not-configured`를 설정하고 GET으로 확인했다. generic workflow disable은 422여서 registry active identity와 feature 활성화를 구분한다. advanced CodeQL security workflow는 보존했다. 제품 설정은 kelp receipt `92e725eaa5422cbc0d3356a74afc749f75f4779854bcfda270572ea24e734f94`에서 not-configured이며 중복 write는 하지 않았다. 과거 billing failure와 artifact quota 실패는 역사 증거로 유지한다. disabled/unavailable 절차를 성공·보안 면제·merge 우회로 간주하지 않는다. diff --git a/docs/doctoring/k-csap-skills-central-quality.md b/docs/doctoring/k-csap-skills-central-quality.md new file mode 100644 index 0000000000..f33f0e9b69 --- /dev/null +++ b/docs/doctoring/k-csap-skills-central-quality.md @@ -0,0 +1,66 @@ +# K-CSAP 중앙 품질 실행 상태 + +기준일: 2026-10-09 KST. 최종 갱신: 2026-10-13 KST. 관련 issue: 중앙 #2612. 이 문서는 실행 성공 또는 출시 승인이 아니다. + +## 2026-10-13 갱신 + +- canonical `.github/actionlint.yaml`에 `k-csap-isolated`를 등록했다. 기본 actionlint 호출 exit 0이다. +- 리뷰 지적에 따라 workflow-scope `concurrency`(같은 PR의 이전 head 취소, 비PR은 run id 분리)를 추가했다. +- `git diff --check`는 깨끗한 checkout에서 빈 결과만 내므로 빈 tree 대비 HEAD 전체를 검사하도록 바꿨다. 실제 git 저장소로 커밋된 공백의 거부를 시험했다. +- job 전용 임시 디렉터리를 공유 `RUNNER_TEMP`가 아닌 run-owned `.ci-tools/tmp` 아래에 만든다. +- 아래의 workflow SHA-256 `9c4cc914…`, `2b14845e…` 등은 당시 기록이다. 현재 head의 workflow 바이트는 이후 수정되었으므로 그 리뷰를 현재 head의 승인으로 재사용하지 않는다. +- 실제 Linux runner 실행·App 승인은 여전히 미검증이다. + +## 현재 단계 + +2026-10-10 admission 수리본의 독립 fresh review는 `CONDITIONAL_SOURCE_ACCEPTANCE`다. 부모가 report SHA-256 `dbc0d58d24a098c68f33268fcd8dc1a6de00f57bfe729aa0dd0272fd4e29ff62` 및 workflow `2b14845ebf761652ca78ecdaac86c6e851ea41dc573312f41ade2695b0a719b7`, test `36bbf733ea0bec7e0cd3b03cb07d6c466cf19fffb578f16114e820a9d69c3737`를 직접 대조했다. 실제 admission shell 35개와 SHA 소비자 4개 사례가 기대대로 동작했고 고정본 계약 테스트 28개가 통과했다. PR head/event SHA 및 state를 분리하며 admission 출력 SHA를 checkout/HEAD 검증이 함께 소비한다. Flatback의 이전 head 독립 재현은 2 FAIL/3 PASS로 보존한다. 이전 아래 해시는 역사 증거이며 새 수리본의 승인으로 재사용하지 않는다. + +setup-uv 사설 저장소/checksum 수정은 실제 pinned dist의 macOS arm64 Node24 실행, 잘못된 checksum 거부, cache-disabled post 실행으로 검토했다. report SHA-256 `2159eec4f6343b82250124b78af57ed8925deaa8a63dc007a6f7e15a9670da83`를 대조했으며 admission 변경과 해당 setup/cleanup 블록의 바이트가 동일함을 fresh reviewer가 확인했다. Linux X64/Node20 실제 Actions, Python runtime 무결성, same-UID 공격 및 강제 종료 격리는 미검증이다. job expression 검증은 로컬 합성 입력이며 원격 runner 배정 증거가 아니다. scratch custom-label actionlint는 canonical CI vocabulary 수락을 대체하지 않는다. 새 소스 출판은 허용되지만 Draft 해제·운영 활성화·병합은 아직 수락하지 않았다. + +초안의 독립 리뷰가 FAIL이다. 원본 workflow SHA-256은 `1743d6c788606bdbd6caa2006a2de47a22029ed5696d2a9a705085542224fa45`, 테스트는 `29e2d825a666332c6bf8a3272359d72c743856606f5d940c3f6920869e354db6`이다. 리뷰 report SHA-256은 `a67ed03a6136c0bdc98b48201b68720489f10a68c10a7eaa8eaa5e72f5546fd9`이다. 초안의 9개 계약 테스트와 actionlint 통과는 이 FAIL을 대체하지 않는다. + +독립 재리뷰는 동일 workflow/test 해시에서 `PASS_WITH_ACTIVATION_LIMITATIONS`를 반환했다. 부모가 report SHA-256 `923b518cf61b072d3fb8ca7e03e5a1825faaca3c248942dd3048d2fbc2a2a378` 및 두 파일 해시를 직접 대조했다. 리뷰의 focused pytest 21개, custom-label actionlint exit 0, 별도 harmless cleanup probe 11/11은 로컬 계약 증거다. 초기 FAIL report는 역사 기록으로 보존한다. setup-uv 도구 상태는 private cleanup 범위 밖이며 외부 action 내부 cleanup, immutable download 무결성, 실제 원격 실행/App 승인은 미검증이다. + +현재 수리본은 job-level `if`를 runner 배정 전 gate로 추가하고, repo-scoped `k-csap-isolated` label을 사용한다. 기존 step admission은 checkout 전 방어층으로 유지했다. exact head, protected master ref, 일회용 source path, Python 3.11.14 및 uv/private venv/cache를 좁은 테스트로 검증했다. 초안 FAIL을 역사 기록으로 유지하며 수리본의 독립 재리뷰는 로컬 소스 한정 조건부 통과다. 수리 과정의 위임 writer 2회는 provider transport 실패로 결과 없이 종료했으며 부모가 테스트 RED를 확인하고 직접 수리했다. 수정본 로컬 `pytest` 21개 통과, scratch custom-label actionlint와 `git diff --check` exit 0; workflow SHA-256 `9c4cc914b57f919da06f434527670d34597ac4a8c1fc4c24008ce36440fdc5c9`, 최종 test SHA-256 `64ae572dfee0063e85ec4850969c02018105aceeb237d20abb1dedc7fe68d2c9`, 최종 staged patch SHA-256 `03a8a6260ae18aa887aab3151dcf8848c0fbe2f3c28cd47b6808699bf579968e`다. 이는 실 runner에서의 실행/격리를 입증하지 않는다. + +## 리뷰 사실관계 구분 + +리뷰는 “checkout도 admission 전에 실행된다”고 기록했으나 frozen YAML은 첫 step admission, 다음 step checkout 순서다. 이 서술은 현재 파일로 지지되지 않는다. 반면 job runner 배정은 첫 step보다 앞서므로 **step admission만으로 배정 이전 신뢰 경계가 생기지 않는다는 지적은 유효하다.** 원본 FAIL을 삭제하거나 PASS로 덮어쓰지 않는다. 완료한 재리뷰 `923b518c…`에서 이 두 결론을 구분했다. + +PR exact head 검증은 기존 PR #30의 exact-head 증거 계약을 이어받은 의도다. GitHub merge candidate를 검증했다는 주장은 하지 않는다. push/manual 호출은 protected master로 제한하는 수리 계약이다. + +## 원격 activation + +직접 API 조회 결과: + +- 조직 group 13 `CWL CI isolated`는 `visibility=selected`, `restricted_to_workflows=false`이다. +- 허용 repository 목록에 K-CSAP이 없다. 소속 runner `orgmetra-ci-01`, `keyverse-ci-01`, `calendarweave-ci-01`은 모두 offline이다. +- 제품 repo-scoped `k-csap-isolated-linux`는 online/idle이다. 조직 group 13의 runner가 아니다. group 구성원이란 이유 없이 옮기거나 relabel하지 않는다. +- 중앙 global PR #2565의 head는 `ef65b2fdc2036ccaca8d19c1db88dc8075ae9164`, OPEN이다. 기존 owner source는 수정하지 않는다. + +중앙 reusable job은 caller에 제공된 repo-scoped runner도 사용할 수 있다는 소유권 교정을 반영했다. group 13 ACL 추가는 이 경로의 선행조건이 아니다. 현재 labels가 맞는 runner의 등록 상태만 확인했고 실제 reusable job 배정과 host isolation은 미검증이다. 동일 UID의 적대적 동시 writer, 잔류 프로세스, 강제 종료와 tool download 무결성은 activation gate에 남는다. 사용자 요구에 따라 hosted fallback은 추가하지 않는다. 실제 execution evidence가 없으면 unavailable로 남긴다. + +## AI 리뷰 및 키 경계 + +중앙 #2560 key/relay custody와 Remora shared review owner를 유지한다. `LLM_GATEWAY_API_KEY` Secret metadata updated `2026-10-09T05:07:41Z`와 `LLM_GATEWAY_MODEL=auto`를 확인했다. 비밀 값은 읽지 않았고 중복 발급/덮어쓰기는 하지 않았다. 사용자가 지정한 `https://litellm.poinnetworks.net`의 실제 authenticated auto inference, private-data route 적격성, author-distinct App의 exact-head APPROVED는 아직 이 lane에서 검증하지 않았다. + +## 재현 가능한 테스트 환경 + +새 전용 테스트 lock `requirements-k-csap-quality-tests-hashes.txt`는 `requirements-k-csap-quality-tests.in`에서 uv 0.12.5의 실제 compile로 생성했다. 공유 manifest/lock은 수정하지 않았다. private scratch CPython 3.11.16 환경에서 hash-required binary-only sync로 pytest 9.1.1, PyYAML 6.0.3 및 4개 전이 의존성을 설치하고 21개 계약 테스트를 실행해 통과했다. YAML 텍스트에서 기대값을 합성하는 대안 parser는 폐기했으며 실제 PyYAML로 구조를 읽는다. `subprocess.run(check=False)`를 명시하여 실패 exit 검증을 유지했다. 최종 delta 재리뷰 report SHA-256 `0d36a3d66c8d6ae435128cfaeda8d0926b3fa0721a50ddc3c7b489e4d157095d`가 이 테스트/lock/문서 범위를 조건부 통과로 바인딩했다. runtime workflow의 Python pin은 3.11.14로 유지되어, 이 실행은 workflow 원격 runtime 실행 증거가 아니다. + +## 다음 gate + +1. 로컬 소스와 최종 delta 독립 검토 완료. 운영 제한은 미수락이다. +2. 중앙 Draft PR #2615를 출판했다. 초도 head는 `9996a21600b3751491842de20ca4c87e1cc257a8`이며 아직 병합하지 않았다. +3. 실제 required checks와 formal App 검토를 확인하고 제한을 해결한 뒤 일반 병합으로 callee를 보호 브랜치에 출판한다. 소비자 caller에 가짜 SHA나 mutable branch를 넣지 않는다. +4. caller·runner ACL·capacity를 승인된 경계에서 연결하고 run ID와 job.runner_name을 검증한다. +5. 실제 Noema/OpenCode auto inference 및 조건부 App 승인을 별도 확인한다. 단독 개발자에게 가상의 다른 인간 리뷰어를 요구하지 않는다. +6. 초도 head에서 확인한 두 실패는 별개다. CodeQL `Analyze (javascript-typescript)`는 billing account lock 때문에 시작하지 않았다. Noema는 Draft라 model inference를 skip한 뒤 Artifact storage quota로 upload에 실패했다. GraphQL annotations로 원인을 확인했으며 REST log 조회 quota 403은 별도 조회 실패다. `coverage-evidence`/`coverage-source-tree` SUCCESS와 `opencode-review` SKIPPED는 실 모델 승인 증거가 아니다. 이 실패 이력은 현재 head의 검증 결과로 승계하지 않는다. + +## 2026-10-11 다음 병합 준비 단계 + +- bootstrap 독립 plan review는 `CHANGES_REQUESTED`, 보고서 해시 `5eaa615dc1f6ac143f526329ae46d68f95284452114cada4a83c77e3391586b1`다. Ready는 리뷰 요청이며 merge 승인과 분리한다. 승인된 reviewer 경로가 준비되면 소스 및 live preflight 후 Ready만 전환할 수 있다. 실제 callee 실행은 oscar가 승인된 임시 exact-SHA caller로 병합 전에 확인한다. 실행·literal auto·App 승인·필수 check가 미충족이면 병합하지 않는다. +- 실제 inline shell을 permissive `umask 000` 아래 실행하면 source와 `.ci-tools`가 mode0777로 생성되는 RED 2개를 확인했다. 각 생성 단계의 `umask 077` 수리 후 30개 계약 테스트가 통과했다. 다른 UID 접근 제한만 확인하며 같은 UID의 비협조 writer 격리나 forced-kill cleanup을 증명하지 않는다. +- 이전 scratch Python 3.11 환경과 actionlint config가 없어 최초 호출은 setup 실패했다. 새 검증은 기존 hash-locked Python 3.14.7 scratch 환경을 사용했다. workflow의 Python 3.11.14 runtime 통과가 아니다. +- canonical PyYAML 누락 제안은 #2565 comment `6098771884`로 전달했다. 최신 remote shared head `b78007bfc976faaa4d416d2b6578b114e2725a82`의 canonical lock에 PyYAML pin이 있음을 확인했다. 보호된 main 배포와 실제 설치는 별도다. 그 head의 canonical lint label에는 `k-csap-isolated`가 없다. diagnostic label config의 성공은 canonical CI 수락이 아니다. +- oscar의 PR32 caller 후보 packet을 읽었다. 미게시 2파일 변경이며 base는 `9b0b1a202ea23b96ba2d3fe71ac1d86b0ca6bc36`, 기존 producer pin은 `c0d6235bd996159fa8c2bb2d176dce2d1983b38b`다. 후보의 로컬 테스트는 소유자 보고이며 Actions 실행이 아니다. 새 producer bytes를 게시하면 caller 소유자가 pin을 다시 검토해야 한다. diff --git a/requirements-k-csap-quality-tests-hashes.txt b/requirements-k-csap-quality-tests-hashes.txt new file mode 100644 index 0000000000..d04a9d179c --- /dev/null +++ b/requirements-k-csap-quality-tests-hashes.txt @@ -0,0 +1,89 @@ +iniconfig==2.3.1 \ + --hash=sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960 \ + --hash=sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 diff --git a/requirements-k-csap-quality-tests.in b/requirements-k-csap-quality-tests.in new file mode 100644 index 0000000000..4cc8ae66e5 --- /dev/null +++ b/requirements-k-csap-quality-tests.in @@ -0,0 +1,3 @@ +# 중앙 K-CSAP workflow 계약 테스트 전용. 런타임 제품 의존성 아님. +pytest==9.1.1 +PyYAML==6.0.3 diff --git a/tests/test_k_csap_actionlint_config.py b/tests/test_k_csap_actionlint_config.py new file mode 100644 index 0000000000..7b467096ab --- /dev/null +++ b/tests/test_k_csap_actionlint_config.py @@ -0,0 +1,18 @@ +"""K-CSAP canonical actionlint 설정의 자동 발견과 라벨 범위를 검증한다.""" + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def test_canonical_config_registers_only_reviewed_custom_labels(): + """공유 라벨을 보존하고 K-CSAP 라벨만 추가하며 wildcard는 거부한다.""" + config = ROOT / ".github/actionlint.yaml" + assert config.is_file(), "canonical actionlint config missing" + assert yaml.safe_load(config.read_text(encoding="utf-8")) == { + "self-hosted-runner": { + "labels": ["cwlab-ci-isolated", "cwlab-control", "k-csap-isolated"] + } + } diff --git a/tests/test_k_csap_skills_quality_workflow_contract.py b/tests/test_k_csap_skills_quality_workflow_contract.py new file mode 100644 index 0000000000..a97bd2c62e --- /dev/null +++ b/tests/test_k_csap_skills_quality_workflow_contract.py @@ -0,0 +1,362 @@ +"""중앙 K-CSAP 품질 workflow 계약. 합성 메타데이터 테스트 전용.""" +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CENTRAL = ROOT / ".github/workflows/k-csap-skills-quality.yml" + + +def workflow(): + assert CENTRAL.is_file() + return yaml.safe_load(CENTRAL.read_text(encoding="utf-8")) + + +def test_reusable_only_and_no_hosted_fallback(): + obj = workflow() + assert obj.get("on", obj.get(True)) == {"workflow_call": None} + assert obj["permissions"] == {"contents": "read"} + assert set(obj["jobs"]) == {"quality"} + job = obj["jobs"]["quality"] + assert job["name"] == "k-csap-skills-quality" + assert job["runs-on"] == ["self-hosted", "Linux", "X64", "k-csap-isolated"] + source = CENTRAL.read_text(encoding="utf-8") + assert "ubuntu-latest" not in source and "ubuntu-24.04" not in source + assert "secrets:" not in source and "continue-on-error" not in source + assert "concurrency" not in job + assert job["timeout-minutes"] <= 20 + + +def test_workflow_scope_concurrency_supersedes_same_pr_only(): + """AGENTS.md: PR 번호로 runner 배정 전 병합하고, 비PR 실행은 서로 취소하지 않는다.""" + concurrency = workflow()["concurrency"] + group = concurrency["group"] + assert concurrency["cancel-in-progress"] is True + assert "github.repository" in group + assert "github.event.pull_request.number" in group + assert "github.run_id" in group + assert "sha" not in group + + +def test_runner_assignment_admits_only_trusted_caller_before_lease(): + job = workflow()["jobs"]["quality"] + gate = job["if"] + assert "github.repository == 'ContextualWisdomLab/k-csap-skills'" in gate + assert "github.event.pull_request.head.repo.full_name == github.repository" in gate + assert "github.event.pull_request.state == 'open'" in gate + assert "github.ref == 'refs/heads/master'" in gate + assert "github.event_name == 'workflow_dispatch'" in gate + assert "github.event_name == 'push'" in gate + + +def test_admission_before_checkout_and_no_credential_persistence(): + steps = workflow()["jobs"]["quality"]["steps"] + assert steps[0]["id"] == "admission" + text = steps[0]["run"] + assert 'ContextualWisdomLab/k-csap-skills' in text + assert 'pull_request' in text and 'push' in text and 'workflow_dispatch' in text + assert 'exit 1' in text + checkout = next(step for step in steps if step.get("uses", "").startswith("actions/checkout@")) + assert checkout["with"]["persist-credentials"] is False + assert checkout["with"]["path"].startswith("${{") + assert "ref" in checkout["with"] + assert steps[0]["env"]["TARGET_REPOSITORY"].startswith("${{") + assert steps[0]["env"]["HEAD_REPOSITORY"].startswith("${{") + + +@pytest.mark.parametrize( + ("head_sha", "ref", "state", "expected"), + [ + ("a" * 40, "refs/pull/17/merge", "open", 0), + ("", "refs/pull/17/merge", "open", 1), + ("a" * 40, "refs/heads/master", "open", 1), + ("a" * 40, "refs/pull/17/merge", "", 1), + ("a" * 40, "refs/pull/0/merge", "open", 1), + ], +) +def test_pr_admission_never_falls_back_to_event_sha(tmp_path, head_sha, ref, state, expected): + import os + import subprocess + + step = workflow()["jobs"]["quality"]["steps"][0] + values = { + "${{ github.repository }}": "ContextualWisdomLab/k-csap-skills", + "${{ github.event_name }}": "pull_request", + "${{ github.event.pull_request.head.repo.full_name || '' }}": "ContextualWisdomLab/k-csap-skills", + "${{ github.event.pull_request.head.sha || github.sha }}": head_sha or "b" * 40, + "${{ github.event.pull_request.head.sha || '' }}": head_sha, + "${{ github.sha }}": "b" * 40, + "${{ github.ref }}": ref, + "${{ github.event.pull_request.state || 'open' }}": state or "open", + "${{ github.event.pull_request.state || '' }}": state, + } + env = {"PATH": os.environ["PATH"], **{key: values[value] for key, value in step["env"].items()}, + "GITHUB_OUTPUT": str(tmp_path / "output")} + result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", step["run"]], + env=env, capture_output=True, text=True, check=False) + assert result.returncode == expected, result.stdout + result.stderr + output = tmp_path / "output" + if expected == 0: + assert output.read_text() == f"expected_sha={head_sha}\nadmitted=true\n" + else: + assert not output.exists() + + +def test_source_consumers_use_admitted_sha(): + steps = workflow()["jobs"]["quality"]["steps"] + checkout = next(step for step in steps if step.get("uses", "").startswith("actions/checkout@")) + verify = next(step for step in steps if step.get("name", "").startswith("Verify K-CSAP")) + assert checkout["with"]["ref"] == "${{ steps.admission.outputs.expected_sha }}" + assert verify["env"]["EXPECTED_SHA"] == "${{ steps.admission.outputs.expected_sha }}" + gate = workflow()["jobs"]["quality"]["if"] + assert "github.event.pull_request.head.sha != ''" in gate + assert "format('refs/pull/{0}/merge', github.event.pull_request.number)" in gate + + +def test_setup_uv_is_checksum_pinned_and_job_owned(): + steps = workflow()["jobs"]["quality"]["steps"] + setup = next(step for step in steps if step.get("uses", "").startswith("astral-sh/setup-uv@")) + assert setup["with"]["checksum"] == ( + "b26fcc8dfa1c39b5a5613445af3be3eefda45d9a39359bee271eafe34913583e" + ) + assert setup["env"]["RUNNER_TOOL_CACHE"] == ( + "${{ github.workspace }}/${{ steps.prepare.outputs.source_path }}/.ci-tools/tool-cache" + ) + assert setup["env"]["RUNNER_TEMP"] == ( + "${{ github.workspace }}/${{ steps.prepare.outputs.source_path }}/.ci-tools/tmp" + ) + assert setup["with"]["cache-local-path"].endswith("/.ci-tools/cache") + assert setup["with"]["enable-cache"] is False + assert setup["with"]["save-cache"] is False + assert setup["with"]["restore-cache"] is False + assert setup["with"]["tool-bin-dir"].endswith("/.ci-tools/bin") + assert setup["with"]["tool-dir"].endswith("/.ci-tools/uv-tools") + + +def test_locked_quality_steps_and_scope_are_executable(): + steps = workflow()["jobs"]["quality"]["steps"] + source = "\n".join(step.get("run", "") for step in steps) + for required in ( + "uv python install 3.11.14", "uv sync --locked --dev --python 3.11.14", + "uv run --python 3.11.14 ruff check .", "uv run --python 3.11.14 pytest -q", + "uv run --python 3.11.14 python scripts/validate.py", "git diff --check", + ): + assert required in source + assert "set -euo pipefail" in source + assert "git rev-parse HEAD" in source + assert "trap " in source + assert "UV_PYTHON_INSTALL_DIR" in source + assert "UV_CACHE_DIR" in source + + +def test_checkout_preparation_and_cleanup_are_owned(): + steps = workflow()["jobs"]["quality"]["steps"] + checkout = next(step for step in steps if step.get("uses", "").startswith("actions/checkout@")) + assert checkout["with"]["path"] == "${{ steps.prepare.outputs.source_path }}" + assert steps[0]["id"] == "admission" + assert steps[1]["id"] == "prepare" + cleanup = steps[-1] + assert cleanup["if"] == "always()" + assert "source_path" in cleanup["env"] or "SOURCE_PATH" in cleanup["env"] + assert "rm -rf" in cleanup["run"] + + +@pytest.mark.parametrize("failed_at", ["success", "install", "test"]) +def test_quality_shell_keeps_original_status_and_removes_private_runtime(tmp_path, failed_at): + import os + import subprocess + + steps = workflow()["jobs"]["quality"]["steps"] + verify = next(step for step in steps if step.get("name", "").startswith("Verify K-CSAP")) + script = verify["run"] + work = tmp_path / "source" + work.mkdir() + (work / ".git").mkdir() + (work / ".ci-tools/tmp").mkdir(parents=True) + sentinel = tmp_path / "untouched" + sentinel.write_text("keep") + bindir = tmp_path / "bin" + bindir.mkdir() + marker = tmp_path / "marker" + for name in ("uv", "git"): + tool = bindir / name + tool.write_text( + "#!/bin/sh\n" + "if [ \"$1\" = rev-parse ]; then printf '%s\\n' \"$EXPECTED_SHA\"; exit 0; fi\n" + "if [ \"$1\" = diff ] || [ \"$1\" = hash-object ]; then exit 0; fi\n" + "if [ \"$1\" = python ] && [ \"$2\" = install ]; then : >\"$MARKER\"; fi\n" + "if [ \"$FAIL_AT\" = install ] && [ \"$1\" = python ]; then exit 37; fi\n" + "if [ \"$FAIL_AT\" = test ] && [ \"$1\" = run ] && " + "[ \"$4\" = pytest ]; then exit 43; fi\n" + "exit 0\n" + ) + tool.chmod(0o755) + env = {**os.environ, "PATH": f"{bindir}:{os.environ['PATH']}", + "RUNNER_TEMP": str(tmp_path), "EXPECTED_SHA": "a" * 40, + "MARKER": str(marker), "FAIL_AT": failed_at} + proc = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script], + cwd=work, env=env, capture_output=True, text=True, check=False) + assert proc.returncode == {"success": 0, "install": 37, "test": 43}[failed_at], proc.stderr + assert sentinel.read_text() == "keep" + assert list(tmp_path.glob("k-csap-quality.*")) == [] + assert list((work / ".ci-tools/tmp").glob("k-csap-quality.*")) == [] + assert marker.exists() + + +@pytest.mark.parametrize("dirty", [False, True]) +def test_quality_shell_checks_committed_whitespace_in_run_owned_temp(tmp_path, dirty): + """깨끗한 checkout에서도 커밋된 공백 오류를 거부하고 공유 RUNNER_TEMP를 쓰지 않는다.""" + import os + import subprocess + + verify = next( + step for step in workflow()["jobs"]["quality"]["steps"] + if step.get("name", "").startswith("Verify K-CSAP") + ) + work = tmp_path / "source" + work.mkdir() + git = ["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid"] + subprocess.run([*git, "init", "-q"], cwd=work, check=True) + (work / "a.txt").write_text("x \n" if dirty else "x\n") + subprocess.run([*git, "add", "a.txt"], cwd=work, check=True) + subprocess.run([*git, "commit", "-qm", "c"], cwd=work, check=True) + sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=work, text=True).strip() + (work / ".ci-tools/tmp").mkdir(parents=True) + shared = tmp_path / "shared-runner-temp" + shared.mkdir() + bindir = tmp_path / "bin" + bindir.mkdir() + seen = tmp_path / "seen" + uv = bindir / "uv" + uv.write_text('#!/bin/sh\nprintf "%s\\n" "$UV_PROJECT_ENVIRONMENT" >>"$SEEN"\nexit 0\n') + uv.chmod(0o755) + env = {**os.environ, "PATH": f"{bindir}:{os.environ['PATH']}", + "RUNNER_TEMP": str(shared), "EXPECTED_SHA": sha, "SEEN": str(seen)} + proc = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", verify["run"]], + cwd=work, env=env, capture_output=True, text=True, check=False) + assert (proc.returncode != 0) is dirty, proc.stdout + proc.stderr + if dirty: + assert "trailing whitespace" in proc.stdout + proc.stderr + venvs = seen.read_text().split() + assert venvs and all( + Path(v).resolve().is_relative_to((work / ".ci-tools/tmp").resolve()) for v in venvs + ) + assert list(shared.iterdir()) == [] + assert list((work / ".ci-tools/tmp").iterdir()) == [] + + +@pytest.mark.parametrize( + ("repository", "event", "head_repository", "expected_exit"), + [ + ("ContextualWisdomLab/k-csap-skills", "push", "", 0), + ("ContextualWisdomLab/k-csap-skills", "workflow_dispatch", "", 0), + ("ContextualWisdomLab/k-csap-skills", "pull_request", "ContextualWisdomLab/k-csap-skills", 0), + ("ContextualWisdomLab/k-csap-skills", "pull_request", "outside/fork", 1), + ("ContextualWisdomLab/other", "push", "", 1), + ("ContextualWisdomLab/k-csap-skills", "schedule", "", 1), + ], +) +def test_actual_admission_shell_rejects_untrusted_before_checkout( + repository, event, head_repository, expected_exit, tmp_path +): + import os + import subprocess + + script = workflow()["jobs"]["quality"]["steps"][0]["run"] + env = {**os.environ, "TARGET_REPOSITORY": repository, "EVENT_NAME": event, + "HEAD_REPOSITORY": head_repository, "PR_HEAD_SHA": "a" * 40, + "EVENT_SHA": "b" * 40, "PR_STATE": "open", + "EVENT_REF": "refs/pull/17/merge" if event == "pull_request" else "refs/heads/master", + "GITHUB_OUTPUT": str(tmp_path / "output")} + proc = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script], + env=env, capture_output=True, text=True, check=False) + assert proc.returncode == expected_exit, proc.stderr + assert "Traceback" not in proc.stderr + + +@pytest.mark.parametrize("stale", [False, True]) +def test_real_prepare_and_cleanup_preserve_stale_and_sibling_source(tmp_path, stale): + import os + import subprocess + + steps = workflow()["jobs"]["quality"]["steps"] + owned = tmp_path / "k-csap-quality.83.2" + sentinel = tmp_path / "other-job" + sentinel.write_text("unchanged") + if stale: + owned.mkdir() + (owned / "stale").write_text("untrusted") + output = tmp_path / "output" + env = {**os.environ, "RUN_ID": "83", "RUN_ATTEMPT": "2", + "GITHUB_WORKSPACE": str(tmp_path), "GITHUB_OUTPUT": str(output)} + prepared = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", steps[1]["run"]], + env=env, capture_output=True, text=True, check=False) + assert prepared.returncode == (1 if stale else 0), prepared.stderr + if stale: + assert (owned / "stale").read_text() == "untrusted" + source_path = "" + else: + assert output.read_text().strip() == "source_path=k-csap-quality.83.2" + assert owned.is_dir() + source_path = owned.name + cleared = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", steps[-1]["run"]], + env={**env, "SOURCE_PATH": source_path}, + capture_output=True, text=True, check=False) + assert cleared.returncode == 0, cleared.stderr + assert owned.exists() is stale + assert sentinel.read_text() == "unchanged" + + +@pytest.mark.parametrize("storage", ["source", "setup-tools"]) +def test_owned_directories_are_private_with_permissive_parent_umask(tmp_path, storage): + import os + import stat + import subprocess + + steps = workflow()["jobs"]["quality"]["steps"] + output = tmp_path / "output" + env = {**os.environ, "RUN_ID": "83", "RUN_ATTEMPT": "2", + "GITHUB_WORKSPACE": str(tmp_path), "GITHUB_OUTPUT": str(output)} + if storage == "source": + step = steps[1] + directory = tmp_path / "k-csap-quality.83.2" + else: + step = next(item for item in steps + if item.get("name") == "Prepare private setup action storage") + directory = tmp_path / ".ci-tools" + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", "umask 000; " + step["run"]], + cwd=tmp_path, env=env, capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stderr + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + for child in directory.rglob("*"): + if child.is_dir(): + assert stat.S_IMODE(child.stat().st_mode) == 0o700 + + +@pytest.mark.parametrize( + ("event", "head", "ref", "state", "sha", "expected"), + [ + ("push", "", "refs/heads/master", "open", "a" * 40, 0), + ("push", "", "refs/heads/dev", "open", "a" * 40, 1), + ("workflow_dispatch", "", "refs/tags/v1", "open", "a" * 40, 1), + ("pull_request", "ContextualWisdomLab/k-csap-skills", "refs/pull/7/merge", "closed", "a" * 40, 1), + ("pull_request", "ContextualWisdomLab/k-csap-skills", "refs/pull/7/merge", "open", "INVALID", 1), + ], +) +def test_admission_rejects_non_master_closed_pr_and_invalid_sha( + event, head, ref, state, sha, expected, tmp_path +): + import os + import subprocess + + script = workflow()["jobs"]["quality"]["steps"][0]["run"] + env = {**os.environ, "TARGET_REPOSITORY": "ContextualWisdomLab/k-csap-skills", + "EVENT_NAME": event, "HEAD_REPOSITORY": head, "EVENT_REF": ref, + "PR_STATE": state, "PR_HEAD_SHA": sha, "EVENT_SHA": sha, + "GITHUB_OUTPUT": str(tmp_path / "output")} + proc = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script], + env=env, capture_output=True, text=True, check=False) + assert proc.returncode == expected, proc.stderr