From 29ff89882faf88f2687597a115e4c153f02464bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 10 Oct 2026 22:12:12 +0900 Subject: [PATCH 1/3] ci: add reviewed self-hosted procurement reusable workflow --- .../workflows/procurement-workbench-ci.yml | 258 +++++++++++++++ GOAL.md | 63 ++++ .../procurement-workbench-central-ci.md | 45 +++ .../procurement-workbench-github-contexts.md | 28 ++ tests/test_procurement_workbench_ci.py | 279 ++++++++++++++++ ...test_procurement_workbench_ci_lifecycle.py | 297 ++++++++++++++++++ 6 files changed, 970 insertions(+) create mode 100644 .github/workflows/procurement-workbench-ci.yml create mode 100644 GOAL.md create mode 100644 docs/doctoring/procurement-workbench-central-ci.md create mode 100644 docs/doctoring/procurement-workbench-github-contexts.md create mode 100644 tests/test_procurement_workbench_ci.py create mode 100644 tests/test_procurement_workbench_ci_lifecycle.py diff --git a/.github/workflows/procurement-workbench-ci.yml b/.github/workflows/procurement-workbench-ci.yml new file mode 100644 index 0000000000..9d477798f3 --- /dev/null +++ b/.github/workflows/procurement-workbench-ci.yml @@ -0,0 +1,258 @@ +name: Procurement Workbench CI + +on: + workflow_call: + +permissions: + contents: read + +jobs: + quality: + name: quality / Python ${{ matrix.python-version }} + runs-on: + group: CWL procurement CI + labels: [self-hosted, linux, x64] + timeout-minutes: 15 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + python-version: ['3.12', '3.14'] + steps: + - name: Admit immutable central definition + env: + WORKFLOW_JOB_CONTEXT: "${{ toJSON(job) }}" + run: | + set -euo pipefail + python3 -I - <<'PY' + import json, os, re, sys + try: + context = json.loads(os.environ['WORKFLOW_JOB_CONTEXT']) + except (ValueError, KeyError): + sys.exit('procurement-ci: malformed central context') + if type(context) is not dict: + sys.exit('procurement-ci: malformed central context') + repository = context.get('workflow_repository') + sha = context.get('workflow_sha') + file = context.get('workflow_file_path') + ref = context.get('workflow_ref') + if repository != 'ContextualWisdomLab/.github' or file != '.github/workflows/procurement-workbench-ci.yml': + sys.exit('procurement-ci: wrong central definition') + if type(sha) is not str or not re.fullmatch('[0-9a-f]{40}', sha) or sha == '0' * 40: + sys.exit('procurement-ci: malformed central sha') + if ref != f'{repository}/{file}@{sha}': + sys.exit('procurement-ci: central ref is not immutable') + PY + + - name: Admit fixed procurement source + id: admit + env: + CALLER_REPOSITORY: "${{ github.repository }}" + EVENT_NAME: "${{ github.event_name }}" + EVENT_REF: "${{ github.ref }}" + PR_HEAD_SHA: "${{ github.event.pull_request.head.sha }}" + PUSH_SHA: "${{ github.sha }}" + HEAD_REPOSITORY: "${{ github.event.pull_request.head.repo.full_name }}" + BASE_REPOSITORY: "${{ github.event.pull_request.base.repo.full_name }}" + PR_NUMBER: "${{ github.event.pull_request.number }}" + PUSH_DELETED: "${{ github.event.deleted }}" + run: | + set -euo pipefail + expected_repository="ContextualWisdomLab/procurement-workbench" + if [ "$CALLER_REPOSITORY" != "$expected_repository" ]; then + echo "procurement-ci: unsupported caller repository" >&2 + exit 1 + fi + case "$EVENT_NAME" in + "pull_request") + if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || [ "$EVENT_REF" != "refs/pull/$PR_NUMBER/merge" ]; then + echo "procurement-ci: malformed pull request ref" >&2 + exit 1 + fi + if [ "$HEAD_REPOSITORY" != "$expected_repository" ] || [ "$BASE_REPOSITORY" != "$expected_repository" ]; then + echo "procurement-ci: fork admission refused" >&2 + exit 1 + fi + SOURCE_SHA="$PR_HEAD_SHA" + ;; + "push") + if [ "$EVENT_REF" != "refs/heads/master" ] || [ "$PUSH_DELETED" != "false" ]; then + echo "procurement-ci: unsupported push ref or deleted push" >&2 + exit 1 + fi + SOURCE_SHA="$PUSH_SHA" + ;; + *) echo "procurement-ci: unsupported event" >&2; exit 1 ;; + esac + if ! [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || [ "$SOURCE_SHA" = "0000000000000000000000000000000000000000" ]; then + echo "procurement-ci: malformed source sha" >&2 + exit 1 + fi + printf 'source_sha=%s\n' "$SOURCE_SHA" >> "$GITHUB_OUTPUT" + + - name: Prepare isolated job workspace + id: prepare + env: + WORKSPACE_BASE: ${{ github.workspace }} + TEMP_BASE: ${{ runner.temp }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + PYTHON_VERSION: ${{ matrix.python-version }} + run: | + set -euo pipefail + python3 -I - <<'PY' + import os, pathlib, re, shutil, sys + def real_directory(value): + path = pathlib.Path(value) + if not path.is_absolute() or any(p.is_symlink() for p in (path, *path.parents)) or not path.is_dir() or path != path.resolve(): + sys.exit('procurement-ci: unsafe workspace base') + return path + workspace = real_directory(os.environ['WORKSPACE_BASE']) + temp = real_directory(os.environ['TEMP_BASE']) + run_id, attempt, version = (os.environ[k] for k in ('RUN_ID', 'RUN_ATTEMPT', 'PYTHON_VERSION')) + if not re.fullmatch(r'[1-9][0-9]*', run_id) or not re.fullmatch(r'[1-9][0-9]*', attempt) or version not in ('3.12', '3.14'): + sys.exit('procurement-ci: invalid job identity') + relative = f'procurement-source-{run_id}-{attempt}-{version}' + source = workspace / relative + private = temp / f'procurement-private-{run_id}-{attempt}-{version}' + created = [] + try: + for path in (source, private): + path.mkdir(mode=0o700) + created.append(path) + (private / 'home').mkdir(mode=0o700) + values = {'source_relative': relative, 'source_absolute': str(source), 'job_root': str(private)} + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output: + for key, value in values.items(): + output.write(f'{key}={value}\n') + except BaseException: + for path in reversed(created): + shutil.rmtree(path) + raise + PY + + - name: Checkout admitted product source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + env: + GIT_CONFIG_NOSYSTEM: '1' + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: core.hooksPath + GIT_CONFIG_VALUE_0: /dev/null + HOME: ${{ steps.prepare.outputs.job_root }}/home + with: + persist-credentials: false + ref: ${{ steps.admit.outputs.source_sha }} + clean: true + path: ${{ steps.prepare.outputs.source_relative }} + + - name: Verify admitted checkout + working-directory: ${{ steps.prepare.outputs.source_absolute }} + env: + EXPECTED_SHA: ${{ steps.admit.outputs.source_sha }} + GIT_CONFIG_NOSYSTEM: '1' + GIT_CONFIG_GLOBAL: /dev/null + run: | + set -euo pipefail + actual_sha="$(git -c core.hooksPath=/dev/null -c core.fsmonitor=false rev-parse HEAD)" + [ "$actual_sha" = "$EXPECTED_SHA" ] || { echo 'procurement-ci: checkout sha mismatch' >&2; exit 1; } + + - name: Setup uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + with: + cache-local-path: ${{ steps.prepare.outputs.job_root }}/uv-cache + enable-cache: false + save-cache: false + python-version: ${{ matrix.python-version }} + working-directory: ${{ steps.prepare.outputs.source_absolute }} + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24' + + - name: Verify frozen lockfile + working-directory: ${{ steps.prepare.outputs.source_absolute }} + env: + HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv + UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache + run: | + set -euo pipefail + unset PYTHONHOME PYTHONPATH + uv lock --check + + - name: Install frozen dependencies + working-directory: ${{ steps.prepare.outputs.source_absolute }} + env: + HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv + UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache + run: | + set -euo pipefail + unset PYTHONHOME PYTHONPATH + uv sync --frozen --group dev + + - name: Run full pytest suite + working-directory: ${{ steps.prepare.outputs.source_absolute }} + env: + HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv + UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache + PROCUREMENT_TEST_TEMP: ${{ steps.prepare.outputs.job_root }}/pytest + run: | + set -euo pipefail + unset PYTHONHOME PYTHONPATH + uv run pytest -q --basetemp "$PROCUREMENT_TEST_TEMP" + + - name: Run Ruff + working-directory: ${{ steps.prepare.outputs.source_absolute }} + env: + HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv + UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache + run: | + set -euo pipefail + unset PYTHONHOME PYTHONPATH + uv run ruff check . + + - name: Check UI JavaScript syntax + working-directory: ${{ steps.prepare.outputs.source_absolute }} + run: | + set -euo pipefail + node --check procurement/ui.js + + - name: Cleanup job-private workspace + if: ${{ always() && steps.prepare.outcome == 'success' }} + env: + WORKSPACE_BASE: ${{ github.workspace }} + TEMP_BASE: ${{ runner.temp }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + PYTHON_VERSION: ${{ matrix.python-version }} + CLEANUP_SOURCE: ${{ steps.prepare.outputs.source_absolute }} + CLEANUP_PRIVATE: ${{ steps.prepare.outputs.job_root }} + run: | + set -euo pipefail + python3 -I - <<'PY' + import os, pathlib, re, shutil, sys + def real_directory(value): + path = pathlib.Path(value) + if not path.is_absolute() or any(p.is_symlink() for p in (path, *path.parents)) or not path.is_dir() or path != path.resolve(): + sys.exit('procurement-ci: unsafe cleanup base') + return path + workspace = real_directory(os.environ['WORKSPACE_BASE']) + temp = real_directory(os.environ['TEMP_BASE']) + run_id, attempt, version = (os.environ[k] for k in ('RUN_ID', 'RUN_ATTEMPT', 'PYTHON_VERSION')) + if not re.fullmatch(r'[1-9][0-9]*', run_id) or not re.fullmatch(r'[1-9][0-9]*', attempt) or version not in ('3.12', '3.14'): + sys.exit('procurement-ci: invalid cleanup identity') + expected = (workspace / f'procurement-source-{run_id}-{attempt}-{version}', temp / f'procurement-private-{run_id}-{attempt}-{version}') + supplied = (os.environ['CLEANUP_SOURCE'], os.environ['CLEANUP_PRIVATE']) + for path, value in zip(expected, supplied): + if value != str(path): + sys.exit('procurement-ci: refusing unexpected cleanup path') + if path.is_symlink() or not path.is_dir(): + sys.exit('procurement-ci: missing or linked cleanup root') + for path in expected: + shutil.rmtree(path) + PY diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000000..30f22af3ef --- /dev/null +++ b/GOAL.md @@ -0,0 +1,63 @@ +# Procurement central self-hosted CI + +상태: 구현 중 / ACTIVATION HOLD + +## 결과 + +`ContextualWisdomLab/procurement-workbench`의 비밀 없는 품질 검사를 중앙 `ContextualWisdomLab/.github` reusable workflow로 옮긴다. 모든 제품 코드 실행은 승인된 전용 self-hosted Linux x64 runner에서만 한다. GitHub-hosted fallback은 두지 않는다. + +현재 제품 품질 의미를 유지한다. + +- Python 3.12와 3.14, `fail-fast: false`, `max-parallel: 1`. +- `uv lock --check`. +- `uv sync --frozen --group dev`. +- 전체 `uv run pytest -q`. +- `uv run ruff check .`. +- `node --check procurement/ui.js`. + +## 근거 + +- 중앙 base: `7554587c2e3106a388998bcad048a3d7121de25e`. +- 제품 workflow: `procurement-workbench/.github/workflows/ci.yml`. +- 제품 PR #4가 caller 파일을 소유한다. 중앙 workflow가 검토·게시되고 runner가 실제 준비되기 전에는 제품 caller를 바꾸지 않는다. +- 중앙 전역 runner 정책은 PR #2565가 소유한다. 이 lane은 그 파일을 수정하지 않는다. +- AI review와 personal LiteLLM `auto` routing은 중앙 issue #2560 소유다. 이 lane은 키를 발급하거나 review workflow를 변경하지 않는다. + +## 실패 상태 + +다음 상태는 성공이 아니다. + +- runner group 또는 repository access 미설정. +- runner offline, missing 또는 capability 부족. +- admission 거절. +- matrix leg missing, skipped, cancelled 또는 failed. +- hosted-only producer를 끈 뒤 해당 증거가 unavailable인 상태. +- 로컬 테스트·actionlint만 통과한 상태. +- Noema/OpenCode check가 skipped인 상태. +- GitHub App의 실제 조건부 `APPROVED`가 없는 상태. + +## 소유 파일 + +- `.github/workflows/procurement-workbench-ci.yml` +- `tests/test_procurement_workbench_ci.py` +- `tests/test_procurement_workbench_ci_lifecycle.py` +- `docs/doctoring/procurement-workbench-central-ci.md` +- `docs/doctoring/procurement-workbench-github-contexts.md` +- 이 `GOAL.md` + +그 밖의 중앙 workflow, runner policy, actionlint shared config, 제품 caller, branch protection, ruleset, secret, variable, runner ACL은 변경하지 않는다. + +## 완료 조건 + +1. 중앙 workflow와 계약 테스트가 TDD 및 로컬 검증을 통과한다. +2. 독립 리뷰가 정확한 파일 hash에 대해 `APPROVE`한다. +3. 중앙 workflow가 immutable commit SHA로 게시된다. +4. runner operator가 procurement repository access와 전용 격리 runner를 준비하고 canary로 group/name/labels/cleanup을 증명한다. +5. 제품 owner가 thin caller를 immutable 중앙 SHA로 전환한다. +6. 각 Python matrix leg와 모든 품질 명령이 같은 제품 head에서 실제 self-hosted runner로 통과한다. +7. 보호 owner가 실제 check name을 mapping하고 hosted-only 절차를 비활성화한다. 제거된 증거는 unavailable로 남고 병합을 우회하지 않는다. +8. 별도 중앙 #2560 lane에서 `https://litellm.poinnetworks.net`의 제한된 service key, literal model `auto`, 실제 inference, 저자와 다른 Noema/OpenCode GitHub App review와 조건부 `APPROVED`를 입증한다. + +## 다음 실행 조각 + +중앙 source-only workflow와 정책 테스트를 작성한다. 원격 활성화와 제품 caller 변경은 위 선행 조건 전까지 HOLD다. diff --git a/docs/doctoring/procurement-workbench-central-ci.md b/docs/doctoring/procurement-workbench-central-ci.md new file mode 100644 index 0000000000..43d9273197 --- /dev/null +++ b/docs/doctoring/procurement-workbench-central-ci.md @@ -0,0 +1,45 @@ +# 구매 시황 중앙 CI: current source candidate + +상태: 로컬 구현 / 독립 재검토 대기 / ACTIVATION HOLD. + +## PRD와 실패 계약 + +| ID | 요구 | 실패 상태 | +|---|---|---| +| CI-01 | 중앙 reusable `workflow_call` 전용, contents read | 임의 입력·secrets·hosted fallback 수용은 실패 | +| CI-02 | 고정 제품 repository, same-repo PR 또는 master push | 누락 PR head/base/number/SHA, fork, deleted push, malformed ref는 nonzero | +| CI-03 | Python 3.12/3.14, lock/frozen install/full pytest/Ruff/Node | missing/skipped/cancelled/failed leg는 acceptance 실패 | +| CI-04 | defining workflow 신원과 제품 head 분리 | mutable central ref, 없는 defining identity, head 대신 merge SHA는 거절 | +| CI-05 | 승인된 runner group/labels/격리 | provisioning 또는 canary 없으면 UNAVAILABLE | +| CI-06 | personal LiteLLM `auto` + 저자와 다른 독립 App | 실제 inference·substantive verdict·APPROVED가 없으면 미완료 | +| CI-07 | hosted-only 절차 retirement | 사라진 증거는 unavailable, 성공/skip으로 대체 불가 | + +## 현재 실행 가능한 로컬 계약 + +- 준비 단계는 원래 `github.workspace` 바로 아래 새 checkout 디렉터리와 `runner.temp` 바로 아래 새 private 디렉터리를 만든다. 디렉터리가 이미 있으면 실패한다. 이전 Git hooks/config를 실행하거나 지우지 않는다. +- `GITHUB_WORKSPACE`/`RUNNER_*`를 덮어쓰지 않는다. 경로는 `steps.prepare.outputs`로 전달한다. checkout 상대 경로는 workspace 안에 있고, 품질 shell마다 `working-directory`를 명시한다. +- setup-uv의 실제 cache-local-path는 준비 단계가 만든 private root 아래다. cache restore/save를 끈다. HOME·venv·pytest·cache는 같은 실행별 private root 아래다. +- Cleanup은 실제 준비 단계 출력과 run/attempt/matrix identity를 대조한다. 다른 경로·traversal·symlink는 거절하며 foreign sentinel을 보존한다. output 쓰기 실패 시 준비 단계가 만든 디렉터리만 rollback한다. +- PR 원본 필드를 별도 전달한다. `|| github.repository`/`|| github.sha` fallback이 없다. PR head와 push/merge SHA를 다른 fixture로 실행해 검증한다. +- 다섯 품질 shell의 실제 body를 합성 uv/node 실행 파일로 실행한다. 각각 exit0 및 exit37 전달을 확인한다. 이 테스트는 실제 제품 검사/Actions 실행이 아니다. +- 중앙 정의는 GitHub 공식 `toJSON(job)`을 환경값으로 받아 documented workflow identity 필드를 명시적으로 검증한다. 누락·잘못된 타입·mutable ref는 실패한다. caller identity로 fallback하지 않는다. actionlint ignore는 사용하지 않는다. + +## 소유 및 승인 경계 + +- 제품 caller: `ContextualWisdomLab/procurement-workbench` PR #4. 현재 파일 미수정. +- shared runner policy: 중앙 #2565. 기존 파일 미수정. +- LiteLLM service-key/transport/auto/App custody: 중앙 #2560. 키 중복 발급·Secret 덮어쓰기 없음. +- runner/isolation: `ContextualWisdomLab/linux-cluster-ops#326`, `ContextualWisdomLab/quarantine-sandbox-runtime#136/#137`. +- owned source: workflow, 두 procurement 계약/lifecycle test 파일, GOAL.md, 이 기록과 context 기록. + +## 과거 실패와 이번 수리 + +R1에서 stale Git hooks, 공유 HOME cache, cleanup traversal을 지적했다. R2는 실제 prepare가 `/source/private`를 export하고 cleanup이 `/source.private`를 기대하는 연결 결함, checkout의 workspace 외부 경로, PR fallback을 재현했다. R2의 20개 테스트 통과는 이를 검증하지 못했다. 해당 보고서/해시는 scratch에 보존한다. + +R3는 준비→출력→checkout path admission→effective cache→cleanup을 한 lifecycle로 연결한다. frozen R2 prepare0/cleanup1을 parent가 재현했다. pinned checkout input helper의 실제 코드에 R3 경로를 전달한 별도 scratch probe는 exit0을 반환했다. Portable repository tests는 개인 scratch 절대 경로에 의존하지 않는다. + +## 미완료 acceptance + +로컬 테스트·actionlint·독립 source verdict, 실제 runner/group/ACL/isolation/cleanup canary, immutable 중앙 게시와 protected integration, 제품 caller 및 check mapping, matrix 전체 실제 실행, hosted-only retirement, literal `auto` inference와 독립 App `APPROVED`는 서로 다른 gate다. source-only 통과로 나머지를 완료라 하지 않는다. Issue #5 STARTUP_FAILURE 원인은 별도 미확정이다. + +Host 격리는 shell 디렉터리 검증만으로 증명되지 않는다. 악성 제품 code의 동시 경로 교체·호스트 조작·강제 종료 cleanup은 provisioning 격리/ephemeral reset 책임이다. Matrix skipped/missing은 원격 receipt 및 protection mapping에서 거절해야 한다. diff --git a/docs/doctoring/procurement-workbench-github-contexts.md b/docs/doctoring/procurement-workbench-github-contexts.md new file mode 100644 index 0000000000..938b43ae50 --- /dev/null +++ b/docs/doctoring/procurement-workbench-github-contexts.md @@ -0,0 +1,28 @@ +# GitHub context and actionlint evidence + +Collected: 2026-10-09 KST. + +Official GitHub Actions contexts documentation fetched from: +`https://docs.github.com/en/actions/reference/workflows-and-actions/contexts` + +The cached copy is local scratch evidence: +`/Users/seonghobae/.hermes/cache/scratch/github-contexts-doc.html` + +Observed official entries: + +- `job.workflow_ref`: documented as the full ref of the workflow file that defines the current job. For reusable workflows this refers to the reusable workflow file. +- `job.workflow_sha`: documented as the commit SHA of the workflow file that defines the current job. +- `job.workflow_repository`: documented as the owner/repo of the repository containing the workflow file that defines the current job. +- `job.workflow_file_path`: documented as the file path of the workflow file that defines the current job. + +Installed validator: + +- `actionlint -version`: `1.7.12`, built with Go 1.26.1 for darwin/arm64. +- It rejects these documented `job.workflow_*` context fields as undefined. + +Decision and current state (2026-10-10 KST): + +- Historical direct `job.workflow_*` expressions failed actionlint1.7.12; original diagnostics remain in earlier reviews. No ignore or suppression was added. +- Current candidate uses the supported `toJSON(job)` expression. Inline isolated Python reads the same documented defining-workflow fields and rejects missing/nonobject/invalid SHA/wrong repository/file/mutable ref metadata. It never substitutes caller `github.workflow_sha`. +- Actual positive and malformed-context tests validate the parser. Current actionlint1.7.12 returns exit0 on this workflow. This proves syntax/schema acceptance of serialization, not GitHub runtime availability of those fields. +- Actual Actions canary must prove serialized context includes the defining repository/SHA/ref/file and immutable caller pin. Missing fields fail before checkout; activation remains HOLD until runtime evidence exists. diff --git a/tests/test_procurement_workbench_ci.py b/tests/test_procurement_workbench_ci.py new file mode 100644 index 0000000000..e4a54b1f63 --- /dev/null +++ b/tests/test_procurement_workbench_ci.py @@ -0,0 +1,279 @@ +"""Source contracts for central procurement CI; runtime acceptance stays separate.""" + +import os +import re +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/procurement-workbench-ci.yml" + + +class UniqueLoader(yaml.BaseLoader): + """Preserve GitHub `on` and refuse duplicate YAML mapping keys.""" + + +def unique_mapping(loader, node): + """Reject duplicate or non-scalar keys before dictionary collapse.""" + result = {} + for key_node, value_node in node.value: + assert isinstance(key_node, yaml.ScalarNode), "non-scalar YAML key" + key = loader.construct_object(key_node) + assert key not in result, "duplicate YAML key" + result[key] = loader.construct_object(value_node) + return result + + +UniqueLoader.add_constructor("tag:yaml.org,2002:map", unique_mapping) + + +def workflow(): + """Read the candidate without changing event/Boolean scalar spellings.""" + return yaml.load(WORKFLOW.read_text(), Loader=UniqueLoader) + + +def job(): + """Return the unconditionally executed matrix job.""" + return workflow()["jobs"]["quality"] + + +def test_duplicate_yaml_keys_are_rejected(): + """A contradictory selector must not silently overwrite its predecessor.""" + with pytest.raises(AssertionError, match="duplicate YAML key"): + yaml.load("jobs: {}\njobs: {}\n", Loader=UniqueLoader) + + +def test_owned_documents_exist_and_keep_activation_separate(): + """Source/test acceptance does not claim operational success.""" + paths = [ + "GOAL.md", + "docs/doctoring/procurement-workbench-central-ci.md", + "docs/doctoring/procurement-workbench-github-contexts.md", + ] + prose = "".join((ROOT / p).read_text() for p in paths) + for phrase in ( + "ACTIVATION HOLD", + "#2560", + "#2565", + "litellm.poinnetworks.net", + "auto", + "APPROVED", + "unavailable", + ): + assert phrase in prose + + +def test_reusable_only_read_only_input_free(): + """No caller-controlled commands, refs, secrets or runner choices.""" + w = workflow() + assert w["on"] == {"workflow_call": ""} + assert w["permissions"] == {"contents": "read"} + assert set(w["jobs"]) == {"quality"} + assert "concurrency" not in w + for step in job()["steps"]: + assert "continue-on-error" not in step + assert "secrets" not in step + assert "if" not in job() + + +def test_fixed_self_hosted_matrix_preserves_all_gates(): + """Both interpreters execute all existing product quality commands.""" + j = job() + assert j["runs-on"] == { + "group": "CWL procurement CI", + "labels": ["self-hosted", "linux", "x64"], + } + assert j["timeout-minutes"] == "15" + assert j["strategy"] == { + "fail-fast": "false", + "max-parallel": "1", + "matrix": {"python-version": ["3.12", "3.14"]}, + } + names = [ + "Verify frozen lockfile", + "Install frozen dependencies", + "Run full pytest suite", + "Run Ruff", + "Check UI JavaScript syntax", + ] + commands = [ + "uv lock --check", + "uv sync --frozen --group dev", + 'uv run pytest -q --basetemp "$PROCUREMENT_TEST_TEMP"', + "uv run ruff check .", + "node --check procurement/ui.js", + ] + steps = j["steps"] + indices = [] + for name, command in zip(names, commands): + index = next(i for i, s in enumerate(steps) if s["name"] == name) + indices.append(index) + s = steps[index] + assert command in s["run"] + assert "if" not in s + assert s["working-directory"] == "${{ steps.prepare.outputs.source_absolute }}" + assert indices == sorted(indices) + + +def test_identity_admission_precedes_all_actions(): + """Neither product nor central identity is controlled by caller inputs.""" + steps = job()["steps"] + assert [s["name"] for s in steps[:4]] == [ + "Admit immutable central definition", + "Admit fixed procurement source", + "Prepare isolated job workspace", + "Checkout admitted product source", + ] + assert next(i for i, s in enumerate(steps) if "uses" in s) == 3 + assert steps[0]["env"] == {"WORKFLOW_JOB_CONTEXT": "${{ toJSON(job) }}"} + + +@pytest.mark.parametrize( + "mutation", + [ + {}, + {"WORKFLOW_REPOSITORY": "attacker/repo"}, + {"WORKFLOW_SHA": ""}, + {"WORKFLOW_SHA": "main"}, + { + "WORKFLOW_REF": "ContextualWisdomLab/.github/.github/workflows/procurement-workbench-ci.yml@main" + }, + {"WORKFLOW_FILE_PATH": ".github/workflows/noema-review.yml"}, + ], +) +def test_actual_central_admission(tmp_path, mutation): + """Execute fixed central identity with positive and negative controls.""" + repository = "ContextualWisdomLab/.github" + file = ".github/workflows/procurement-workbench-ci.yml" + sha = "a" * 40 + import json + + context = { + "workflow_repository": repository, + "workflow_sha": sha, + "workflow_ref": f"{repository}/{file}@{sha}", + "workflow_file_path": file, + } + context.update({key.lower(): value for key, value in mutation.items()}) + env = {**os.environ, "WORKFLOW_JOB_CONTEXT": json.dumps(context)} + result = subprocess.run( + ["/bin/bash", "-euo", "pipefail", "-c", job()["steps"][0]["run"]], + env=env, + cwd=tmp_path, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == (1 if mutation else 0), result.stderr + + +@pytest.mark.parametrize( + "event,mutations,expected", + [ + ("pull_request", {}, 0), + ("push", {}, 0), + ("pull_request", {"HEAD_REPOSITORY": "attacker/fork"}, 1), + ("pull_request", {"BASE_REPOSITORY": "attacker/fork"}, 1), + ("pull_request", {"EVENT_REF": "refs/heads/attacker"}, 1), + ("pull_request", {"PR_NUMBER": "8"}, 1), + ("pull_request", {"PR_HEAD_SHA": ""}, 1), + ("pull_request", {"PR_HEAD_SHA": "0" * 40}, 1), + ("push", {"PUSH_DELETED": "true"}, 1), + ("push", {"PUSH_SHA": "bad"}, 1), + ("push", {"EVENT_REF": "refs/heads/feature"}, 1), + ("workflow_dispatch", {}, 1), + ], +) +def test_actual_product_admission_selects_event_specific_sha( + tmp_path, event, mutations, expected +): + """PR head is distinct from push/merge SHA; missing fields cannot fallback.""" + repository = "ContextualWisdomLab/procurement-workbench" + output = tmp_path / "output" + env = { + **os.environ, + "CALLER_REPOSITORY": repository, + "EVENT_NAME": event, + "EVENT_REF": "refs/pull/7/merge" + if event == "pull_request" + else "refs/heads/master", + "PR_NUMBER": "7", + "HEAD_REPOSITORY": repository, + "BASE_REPOSITORY": repository, + "PR_HEAD_SHA": "a" * 40, + "PUSH_SHA": "b" * 40, + "PUSH_DELETED": "false", + "GITHUB_OUTPUT": str(output), + **mutations, + } + result = subprocess.run( + ["/bin/bash", "-euo", "pipefail", "-c", job()["steps"][1]["run"]], + env=env, + cwd=tmp_path, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == expected, result.stderr + if not expected: + sha = "a" * 40 if event == "pull_request" else "b" * 40 + assert output.read_text() == f"source_sha={sha}\n" + else: + assert not output.exists() + + +def test_central_identity_uses_serialized_documented_job_context(): + """Parse documented metadata without suppressing validator schema diagnostics.""" + assert job()["steps"][0]["env"] == {"WORKFLOW_JOB_CONTEXT": "${{ toJSON(job) }}"} + + +@pytest.mark.parametrize( + "payload", ["{}", "[]", "null", "{bad json", '{"workflow_sha":17}'] +) +def test_missing_or_malformed_serialized_context_rejects(tmp_path, payload): + """The compatible serialization route must not default to caller identity.""" + result = subprocess.run( + ["/bin/bash", "-euo", "pipefail", "-c", job()["steps"][0]["run"]], + env={**os.environ, "WORKFLOW_JOB_CONTEXT": payload}, + cwd=tmp_path, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 1 + assert "procurement-ci:" in result.stderr + + +def test_action_pins_cache_and_output_wiring(): + """Checkout, actual cache location and cleanup consume the same producer.""" + steps = job()["steps"] + uses = [s["uses"] for s in steps if "uses" in s] + assert uses == [ + "actions/checkout@11d5960a326750d5838078e36cf38b85af677262", + "astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e", + "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", + ] + assert all(re.fullmatch(r".+@[a-f0-9]{40}", s) for s in uses) + checkout = steps[3]["with"] + assert checkout == { + "persist-credentials": "false", + "clean": "true", + "ref": "${{ steps.admit.outputs.source_sha }}", + "path": "${{ steps.prepare.outputs.source_relative }}", + } + prepare = steps[2] + assert prepare["id"] == "prepare" + assert "GITHUB_ENV" not in prepare["run"] + setup = next(s for s in steps if s["name"] == "Setup uv")["with"] + assert setup["cache-local-path"] == "${{ steps.prepare.outputs.job_root }}/uv-cache" + assert setup["enable-cache"] == setup["save-cache"] == "false" + cleanup = steps[-1] + assert cleanup["if"] == "${{ always() && steps.prepare.outcome == 'success' }}" + assert ( + cleanup["env"]["CLEANUP_SOURCE"] + == "${{ steps.prepare.outputs.source_absolute }}" + ) + assert cleanup["env"]["CLEANUP_PRIVATE"] == "${{ steps.prepare.outputs.job_root }}" diff --git a/tests/test_procurement_workbench_ci_lifecycle.py b/tests/test_procurement_workbench_ci_lifecycle.py new file mode 100644 index 0000000000..5d27277ceb --- /dev/null +++ b/tests/test_procurement_workbench_ci_lifecycle.py @@ -0,0 +1,297 @@ +"""Execute real workflow lifecycle shells, not independently invented path fixtures.""" + +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/procurement-workbench-ci.yml" + + +def steps(): + """Parse the candidate without coercing its GitHub event key.""" + return yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader)["jobs"]["quality"][ + "steps" + ] + + +def step(name): + """Find a named actual executable step.""" + return next(s for s in steps() if s["name"] == name) + + +def run(body, env, cwd): + """Use a real shell with harmless synthetic service boundaries.""" + return subprocess.run( + ["/bin/bash", "-euo", "pipefail", "-c", body], + env=env, + cwd=cwd, + capture_output=True, + text=True, + timeout=10, + ) + + +def prepare(tmp_path): + """Invoke actual preparation and consume its exact output records.""" + base = tmp_path.resolve() + workspace = base / "workspace" + temp = base / "runner-temp" + workspace.mkdir() + temp.mkdir() + output = base / "output" + env = { + **os.environ, + "WORKSPACE_BASE": str(workspace), + "TEMP_BASE": str(temp), + "RUN_ID": "41", + "RUN_ATTEMPT": "2", + "PYTHON_VERSION": "3.14", + "GITHUB_OUTPUT": str(output), + "RUNNER_TEMP": str(temp), + "GITHUB_RUN_ID": "41", + "GITHUB_RUN_ATTEMPT": "2", + "MATRIX_PYTHON_VERSION": "3.14", + "GITHUB_ENV": str(base / "legacy-env"), + } + result = run(step("Prepare isolated job workspace")["run"], env, base) + assert result.returncode == 0, result.stderr + assert output.is_file(), ( + "preparation must publish step outputs, not reserved GITHUB_WORKSPACE overrides" + ) + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + return env, values, workspace, temp + + +def test_prepare_checkout_cleanup_are_one_contiguous_lifecycle(tmp_path): + """The actual producer paths must qualify for checkout and then be removed.""" + env, values, workspace, temp = prepare(tmp_path) + source = Path(values["source_absolute"]) + private = Path(values["job_root"]) + assert source == workspace / values["source_relative"] + assert source.parent == workspace + assert source.is_dir() and private.is_dir() + assert private.parent == temp + assert "GITHUB_WORKSPACE" not in values + assert ( + step("Checkout admitted product source")["with"]["path"] + == "${{ steps.prepare.outputs.source_relative }}" + ) + setup = step("Setup uv")["with"] + assert setup["cache-local-path"] == "${{ steps.prepare.outputs.job_root }}/uv-cache" + assert setup["enable-cache"] == "false" and setup["save-cache"] == "false" + (private / "uv-cache").mkdir() + (private / "uv-cache" / "sentinel").write_text("current-cache") + victim = temp / "victim" + victim.mkdir() + (victim / "keep").write_text("keep") + cleanup_env = { + **env, + "CLEANUP_SOURCE": str(source), + "CLEANUP_PRIVATE": str(private), + } + cleanup = step("Cleanup job-private workspace")["run"] + traversal = run( + cleanup, + {**cleanup_env, "CLEANUP_PRIVATE": str(private / ".." / "victim")}, + tmp_path, + ) + assert traversal.returncode != 0 + assert "refusing unexpected cleanup path" in traversal.stderr + assert (victim / "keep").read_text() == "keep" + good = run(cleanup, cleanup_env, tmp_path) + assert good.returncode == 0, good.stderr + assert not source.exists() and not private.exists() + assert (victim / "keep").read_text() == "keep" + + +def test_fresh_checkout_never_reads_stale_workspace_git(tmp_path): + """A stale outer repository remains untouched; checkout sees an empty child.""" + env, values, workspace, temp = prepare(tmp_path) + stale = workspace / ".git" / "hooks" + stale.mkdir(parents=True) + marker = temp / "hook-ran" + hook = stale / "post-checkout" + hook.write_text(f'#!/bin/sh\ntouch "{marker}"\n') + hook.chmod(0o755) + source = Path(values["source_absolute"]) + assert list(source.iterdir()) == [] + git_env = {**env, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null"} + git_env.pop("GIT_DIR", None) + git_env.pop("GIT_WORK_TREE", None) + for command in ( + ["git", "init", "-q", str(source)], + [ + "git", + "-C", + str(source), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "-qm", + "synthetic", + ], + [ + "git", + "-C", + str(source), + "-c", + "core.hooksPath=/dev/null", + "checkout", + "--detach", + ], + ): + result = subprocess.run( + command, env=git_env, capture_output=True, text=True, timeout=10 + ) + assert result.returncode == 0, result.stderr + assert hook.exists() and not marker.exists() + + +@pytest.mark.parametrize( + "name", + [ + "Verify frozen lockfile", + "Install frozen dependencies", + "Run full pytest suite", + "Run Ruff", + "Check UI JavaScript syntax", + ], +) +@pytest.mark.parametrize("exit_code", [0, 37]) +def test_quality_shell_preserves_each_command_exit(tmp_path, name, exit_code): + """Success and failure both use the actual wrappers with synthetic executables.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + witness = tmp_path / "called" + for tool in ("uv", "node"): + script = bin_dir / tool + script.write_text( + f'#!/bin/sh\nprintf called >> "{witness}"\nexit {exit_code}\n' + ) + script.chmod(0o755) + env = { + **os.environ, + "PATH": str(bin_dir), + "PROCUREMENT_TEST_TEMP": str(tmp_path / "pytest"), + } + result = run(step(name)["run"], env, tmp_path) + assert witness.read_text() == "called" + assert result.returncode == exit_code + + +def test_checkout_path_is_direct_child_of_runner_workspace(tmp_path): + """Portable path contract; pinned action boundary probe is retained in scratch.""" + env, values, workspace, temp = prepare(tmp_path) + source = Path(values["source_absolute"]) + assert source.parent == workspace + assert source.name == values["source_relative"] + assert ".." not in values["source_relative"] + + +@pytest.mark.parametrize("linked", ["source", "private"]) +def test_cleanup_rejects_replaced_roots_without_deleting_foreign_data(tmp_path, linked): + """After preparation, a linked root must cause a nonpassing cleanup.""" + env, values, workspace, temp = prepare(tmp_path) + foreign = tmp_path / "foreign" + foreign.mkdir() + (foreign / "keep").write_text("keep") + target = Path(values["source_absolute" if linked == "source" else "job_root"]) + if linked == "private": + (target / "home").rmdir() + target.rmdir() + target.symlink_to(foreign, target_is_directory=True) + result = run( + step("Cleanup job-private workspace")["run"], + { + **env, + "CLEANUP_SOURCE": values["source_absolute"], + "CLEANUP_PRIVATE": values["job_root"], + }, + tmp_path, + ) + assert result.returncode == 1 + assert "missing or linked cleanup root" in result.stderr + assert (foreign / "keep").read_text() == "keep" + + +def test_preparation_export_failure_rolls_back_only_new_owned_directories(tmp_path): + """A failed output write leaves no orphan and preserves foreign controls.""" + workspace = tmp_path.resolve() / "workspace" + temp = tmp_path.resolve() / "temp" + workspace.mkdir() + temp.mkdir() + sentinel = temp / "sentinel" + sentinel.write_text("keep") + env = { + **os.environ, + "WORKSPACE_BASE": str(workspace), + "TEMP_BASE": str(temp), + "RUN_ID": "71", + "RUN_ATTEMPT": "1", + "PYTHON_VERSION": "3.12", + "GITHUB_OUTPUT": str(temp), + } + result = run(step("Prepare isolated job workspace")["run"], env, tmp_path) + assert result.returncode != 0 + assert "IsADirectoryError" in result.stderr + assert list(workspace.iterdir()) == [] + assert list(temp.iterdir()) == [sentinel] + assert sentinel.read_text() == "keep" + + +def test_existing_run_directory_rejects_without_deleting_it(tmp_path): + """A retained run root is never silently reused or removed on admission.""" + env, values, workspace, temp = prepare(tmp_path) + source = Path(values["source_absolute"]) + sentinel = source / "old" + sentinel.write_text("old") + result = run(step("Prepare isolated job workspace")["run"], env, tmp_path) + assert result.returncode != 0 + assert "FileExistsError" in result.stderr + assert sentinel.read_text() == "old" + assert Path(values["job_root"]).is_dir() + + +def test_pr_raw_metadata_cannot_fall_back_to_merge_identity(): + """Missing PR fields must remain empty until the event-specific guard.""" + env = step("Admit fixed procurement source")["env"] + assert env["PR_HEAD_SHA"] == "${{ github.event.pull_request.head.sha }}" + assert ( + env["HEAD_REPOSITORY"] == "${{ github.event.pull_request.head.repo.full_name }}" + ) + assert ( + env["BASE_REPOSITORY"] == "${{ github.event.pull_request.base.repo.full_name }}" + ) + assert all("||" not in v for v in env.values()) + + +@pytest.mark.parametrize( + "field", ["PR_HEAD_SHA", "HEAD_REPOSITORY", "BASE_REPOSITORY", "PR_NUMBER"] +) +def test_missing_pr_field_is_rejected_despite_valid_push_sha(tmp_path, field): + """A merge/push SHA cannot replace an absent PR head field.""" + repository = "ContextualWisdomLab/procurement-workbench" + env = { + **os.environ, + "CALLER_REPOSITORY": repository, + "EVENT_NAME": "pull_request", + "EVENT_REF": "refs/pull/7/merge", + "PR_HEAD_SHA": "a" * 40, + "PUSH_SHA": "b" * 40, + "HEAD_REPOSITORY": repository, + "BASE_REPOSITORY": repository, + "PR_NUMBER": "7", + "PUSH_DELETED": "false", + "GITHUB_OUTPUT": str(tmp_path / "output"), + } + env[field] = "" + result = run(step("Admit fixed procurement source")["run"], env, tmp_path) + assert result.returncode == 1 + assert not (tmp_path / "output").exists() From aaccb7ad31a88d985baeb87ede5c533e70a1a604 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 12 Oct 2026 14:50:50 +0900 Subject: [PATCH 2/3] docs(ci): clarify procurement product caller ownership --- GOAL.md | 6 +++--- docs/doctoring/procurement-workbench-central-ci.md | 8 ++++++-- tests/test_procurement_workbench_ci.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/GOAL.md b/GOAL.md index 30f22af3ef..2088a26884 100644 --- a/GOAL.md +++ b/GOAL.md @@ -1,6 +1,6 @@ # Procurement central self-hosted CI -상태: 구현 중 / ACTIVATION HOLD +상태: 중앙 PR #2620 소스 게시·로컬 검증·독립 소스 검토 완료 / ACTIVATION HOLD. ## 결과 @@ -19,7 +19,7 @@ - 중앙 base: `7554587c2e3106a388998bcad048a3d7121de25e`. - 제품 workflow: `procurement-workbench/.github/workflows/ci.yml`. -- 제품 PR #4가 caller 파일을 소유한다. 중앙 workflow가 검토·게시되고 runner가 실제 준비되기 전에는 제품 caller를 바꾸지 않는다. +- 제품 [ContextualWisdomLab/procurement-workbench#4](https://github.com/ContextualWisdomLab/procurement-workbench/pull/4)가 caller 파일을 소유한다. 중앙 workflow가 검토·게시되고 runner가 실제 준비되기 전에는 제품 caller를 바꾸지 않는다. - 중앙 전역 runner 정책은 PR #2565가 소유한다. 이 lane은 그 파일을 수정하지 않는다. - AI review와 personal LiteLLM `auto` routing은 중앙 issue #2560 소유다. 이 lane은 키를 발급하거나 review workflow를 변경하지 않는다. @@ -60,4 +60,4 @@ ## 다음 실행 조각 -중앙 source-only workflow와 정책 테스트를 작성한다. 원격 활성화와 제품 caller 변경은 위 선행 조건 전까지 HOLD다. +현재 소스는 게시·검토됐다. 다음 단계는 runner 격리 증거, 실제 중앙 품질 실행, 기존 #2560 경로의 literal `auto`와 정식 App 판정을 확보하는 것이다. 제품 caller 활성화와 정상 병합은 그 조건을 충족한 뒤 진행한다. 완료한 소스 suite를 반복하는 것으로 운영 증거를 대신하지 않는다. diff --git a/docs/doctoring/procurement-workbench-central-ci.md b/docs/doctoring/procurement-workbench-central-ci.md index 43d9273197..552d337942 100644 --- a/docs/doctoring/procurement-workbench-central-ci.md +++ b/docs/doctoring/procurement-workbench-central-ci.md @@ -1,6 +1,6 @@ # 구매 시황 중앙 CI: current source candidate -상태: 로컬 구현 / 독립 재검토 대기 / ACTIVATION HOLD. +상태: 중앙 PR #2620 소스 게시·독립 소스 검토 완료 / 실제 실행·정식 App 검토 미완료 / ACTIVATION HOLD. ## PRD와 실패 계약 @@ -26,7 +26,7 @@ ## 소유 및 승인 경계 -- 제품 caller: `ContextualWisdomLab/procurement-workbench` PR #4. 현재 파일 미수정. +- 제품 caller: [ContextualWisdomLab/procurement-workbench#4](https://github.com/ContextualWisdomLab/procurement-workbench/pull/4). 제품 caller 활성화는 별도 단계다. - shared runner policy: 중앙 #2565. 기존 파일 미수정. - LiteLLM service-key/transport/auto/App custody: 중앙 #2560. 키 중복 발급·Secret 덮어쓰기 없음. - runner/isolation: `ContextualWisdomLab/linux-cluster-ops#326`, `ContextualWisdomLab/quarantine-sandbox-runtime#136/#137`. @@ -42,4 +42,8 @@ R3는 준비→출력→checkout path admission→effective cache→cleanup을 로컬 테스트·actionlint·독립 source verdict, 실제 runner/group/ACL/isolation/cleanup canary, immutable 중앙 게시와 protected integration, 제품 caller 및 check mapping, matrix 전체 실제 실행, hosted-only retirement, literal `auto` inference와 독립 App `APPROVED`는 서로 다른 gate다. source-only 통과로 나머지를 완료라 하지 않는다. Issue #5 STARTUP_FAILURE 원인은 별도 미확정이다. +## 원격 리뷰의 운영 격리 지적 + +Devin의 setup-uv/setup-node 호스트 접근 지적은 미해결 운영 경계로 유지한다. 불변 action pin과 private 디렉터리는 코드 신원·생명주기 제어일 뿐 호스트 confinement 증거가 아니다. 승인된 runner의 격리·egress·동시 writer 통제·reset 증거를 실제 canary로 확인하기 전에는 제품 caller를 활성화하지 않는다. 문서 링크 수리나 로컬 테스트 통과로 이 지적을 resolved로 표시하지 않는다. + Host 격리는 shell 디렉터리 검증만으로 증명되지 않는다. 악성 제품 code의 동시 경로 교체·호스트 조작·강제 종료 cleanup은 provisioning 격리/ephemeral reset 책임이다. Matrix skipped/missing은 원격 receipt 및 protection mapping에서 거절해야 한다. diff --git a/tests/test_procurement_workbench_ci.py b/tests/test_procurement_workbench_ci.py index e4a54b1f63..407185eff3 100644 --- a/tests/test_procurement_workbench_ci.py +++ b/tests/test_procurement_workbench_ci.py @@ -66,6 +66,16 @@ def test_owned_documents_exist_and_keep_activation_separate(): assert phrase in prose +@pytest.mark.parametrize( + "path", + ["GOAL.md", "docs/doctoring/procurement-workbench-central-ci.md"], +) +def test_product_caller_reference_identifies_the_remote_repository(path): + """A central document must link to the product PR, not central PR4.""" + prose = (ROOT / path).read_text() + assert "https://github.com/ContextualWisdomLab/procurement-workbench/pull/4" in prose + + def test_reusable_only_read_only_input_free(): """No caller-controlled commands, refs, secrets or runner choices.""" w = workflow() From 79b806183bdc2e92e1d3cffe69783b316adc9790 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 12 Oct 2026 19:49:54 +0900 Subject: [PATCH 3/3] fix(ci): verify preinstalled procurement toolchain safely --- .../workflows/procurement-workbench-ci.yml | 93 +++++++++--- GOAL.md | 2 +- .../procurement-workbench-central-ci.md | 4 +- tests/test_procurement_workbench_ci.py | 64 +++++++-- ...test_procurement_workbench_ci_lifecycle.py | 135 +++++++++++++++++- 5 files changed, 259 insertions(+), 39 deletions(-) diff --git a/.github/workflows/procurement-workbench-ci.yml b/.github/workflows/procurement-workbench-ci.yml index 9d477798f3..501c8642d4 100644 --- a/.github/workflows/procurement-workbench-ci.yml +++ b/.github/workflows/procurement-workbench-ci.yml @@ -157,70 +157,127 @@ jobs: actual_sha="$(git -c core.hooksPath=/dev/null -c core.fsmonitor=false rev-parse HEAD)" [ "$actual_sha" = "$EXPECTED_SHA" ] || { echo 'procurement-ci: checkout sha mismatch' >&2; exit 1; } - - name: Setup uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e - with: - cache-local-path: ${{ steps.prepare.outputs.job_root }}/uv-cache - enable-cache: false - save-cache: false - python-version: ${{ matrix.python-version }} - working-directory: ${{ steps.prepare.outputs.source_absolute }} - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '24' + - name: Verify preinstalled runner toolchain + id: toolchain + env: + PYTHON_VERSION: ${{ matrix.python-version }} + run: | + set -euo pipefail + unset PYTHONHOME PYTHONPATH + uv_bin="$(command -v uv)" + python312_bin="$(command -v python3.12)" + python314_bin="$(command -v python3.14)" + node_bin="$(command -v node)" + for tool_path in "$uv_bin" "$python312_bin" "$python314_bin" "$node_bin"; do + if [[ "$tool_path" == *$'\n'* || "$tool_path" == *$'\r'* ]]; then + echo 'procurement-ci: unsafe tool path' >&2 + exit 1 + fi + done + test -x "$uv_bin" && test -x "$python312_bin" && test -x "$python314_bin" && test -x "$node_bin" + case "$uv_bin" in /*) ;; *) echo 'procurement-ci: uv is not absolute' >&2; exit 1 ;; esac + case "$python312_bin" in /*) ;; *) echo 'procurement-ci: python3.12 is not absolute' >&2; exit 1 ;; esac + case "$python314_bin" in /*) ;; *) echo 'procurement-ci: python3.14 is not absolute' >&2; exit 1 ;; esac + case "$node_bin" in /*) ;; *) echo 'procurement-ci: node is not absolute' >&2; exit 1 ;; esac + uv_version="$("$uv_bin" --version)" + python312_version="$("$python312_bin" --version 2>&1)" + python314_version="$("$python314_bin" --version 2>&1)" + node_version="$("$node_bin" --version)" + if [[ "$uv_version$python312_version$python314_version$node_version" == *$'\n'* ]]; then + echo 'procurement-ci: multiline tool version' >&2 + exit 1 + fi + if ! [[ "$uv_version" =~ ^uv[[:space:]][0-9]+\.[0-9]+\.[0-9]+(\ \(.*\))?$ ]]; then + echo 'procurement-ci: unsupported uv version' >&2; exit 1 + fi + if ! [[ "$python312_version" =~ ^Python[[:space:]]3\.12\.[0-9]+$ ]]; then + echo 'procurement-ci: unsupported python3.12 version' >&2; exit 1 + fi + if ! [[ "$python314_version" =~ ^Python[[:space:]]3\.14\.[0-9]+$ ]]; then + echo 'procurement-ci: unsupported python3.14 version' >&2; exit 1 + fi + if ! [[ "$node_version" =~ ^v24\.[0-9]+\.[0-9]+$ ]]; then + echo 'procurement-ci: unsupported node version' >&2; exit 1 + fi + case "$PYTHON_VERSION" in + 3.12) python_bin="$python312_bin" ;; + 3.14) python_bin="$python314_bin" ;; + *) echo 'procurement-ci: unsupported toolchain matrix' >&2; exit 1 ;; + esac + { + printf 'uv_bin=%s\n' "$uv_bin" + printf 'python_bin=%s\n' "$python_bin" + printf 'node_bin=%s\n' "$node_bin" + } >> "$GITHUB_OUTPUT" - name: Verify frozen lockfile working-directory: ${{ steps.prepare.outputs.source_absolute }} env: HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_BIN: ${{ steps.toolchain.outputs.uv_bin }} + PYTHON_BIN: ${{ steps.toolchain.outputs.python_bin }} + UV_NO_MANAGED_PYTHON: '1' + UV_PYTHON_DOWNLOADS: never UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache run: | set -euo pipefail unset PYTHONHOME PYTHONPATH - uv lock --check + "$UV_BIN" lock --check --python "$PYTHON_BIN" - name: Install frozen dependencies working-directory: ${{ steps.prepare.outputs.source_absolute }} env: HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_BIN: ${{ steps.toolchain.outputs.uv_bin }} + PYTHON_BIN: ${{ steps.toolchain.outputs.python_bin }} + UV_NO_MANAGED_PYTHON: '1' + UV_PYTHON_DOWNLOADS: never UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache run: | set -euo pipefail unset PYTHONHOME PYTHONPATH - uv sync --frozen --group dev + "$UV_BIN" sync --frozen --group dev --python "$PYTHON_BIN" - name: Run full pytest suite working-directory: ${{ steps.prepare.outputs.source_absolute }} env: HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_BIN: ${{ steps.toolchain.outputs.uv_bin }} + PYTHON_BIN: ${{ steps.toolchain.outputs.python_bin }} + UV_NO_MANAGED_PYTHON: '1' + UV_PYTHON_DOWNLOADS: never UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache PROCUREMENT_TEST_TEMP: ${{ steps.prepare.outputs.job_root }}/pytest run: | set -euo pipefail unset PYTHONHOME PYTHONPATH - uv run pytest -q --basetemp "$PROCUREMENT_TEST_TEMP" + "$UV_BIN" run --frozen --no-managed-python --python "$PYTHON_BIN" pytest -q --basetemp "$PROCUREMENT_TEST_TEMP" - name: Run Ruff working-directory: ${{ steps.prepare.outputs.source_absolute }} env: HOME: ${{ steps.prepare.outputs.job_root }}/home + UV_BIN: ${{ steps.toolchain.outputs.uv_bin }} + PYTHON_BIN: ${{ steps.toolchain.outputs.python_bin }} + UV_NO_MANAGED_PYTHON: '1' + UV_PYTHON_DOWNLOADS: never UV_PROJECT_ENVIRONMENT: ${{ steps.prepare.outputs.job_root }}/venv UV_CACHE_DIR: ${{ steps.prepare.outputs.job_root }}/uv-cache run: | set -euo pipefail unset PYTHONHOME PYTHONPATH - uv run ruff check . + "$UV_BIN" run --frozen --no-managed-python --python "$PYTHON_BIN" ruff check . - name: Check UI JavaScript syntax working-directory: ${{ steps.prepare.outputs.source_absolute }} + env: + NODE_BIN: ${{ steps.toolchain.outputs.node_bin }} run: | set -euo pipefail - node --check procurement/ui.js + "$NODE_BIN" --check procurement/ui.js - name: Cleanup job-private workspace if: ${{ always() && steps.prepare.outcome == 'success' }} diff --git a/GOAL.md b/GOAL.md index 2088a26884..cb401e86d5 100644 --- a/GOAL.md +++ b/GOAL.md @@ -60,4 +60,4 @@ ## 다음 실행 조각 -현재 소스는 게시·검토됐다. 다음 단계는 runner 격리 증거, 실제 중앙 품질 실행, 기존 #2560 경로의 literal `auto`와 정식 App 판정을 확보하는 것이다. 제품 caller 활성화와 정상 병합은 그 조건을 충족한 뒤 진행한다. 완료한 소스 suite를 반복하는 것으로 운영 증거를 대신하지 않는다. +현재 게시 head는 `aaccb7ad3`이다. 이번 수리에서는 setup-uv/setup-node action을 제거하고 runner 사전 설치 도구를 검증한다. Python matrix, frozen lock, 전체 pytest, Ruff와 Node 검사는 유지한다. 변경된 실제 shell의 성공·거절·exit 전달을 검증하고 독립 재검토 후 같은 PR에 게시한다. 이 수리는 호스트 격리를 증명하지 않는다. runner 격리 canary, 실제 중앙 품질 실행, 기존 #2560의 literal `auto`와 정식 App 판정은 계속 별도 조건이다. 제품 caller 활성화와 정상 병합은 조건을 충족한 뒤 진행한다. diff --git a/docs/doctoring/procurement-workbench-central-ci.md b/docs/doctoring/procurement-workbench-central-ci.md index 552d337942..1f0e06608c 100644 --- a/docs/doctoring/procurement-workbench-central-ci.md +++ b/docs/doctoring/procurement-workbench-central-ci.md @@ -18,7 +18,7 @@ - 준비 단계는 원래 `github.workspace` 바로 아래 새 checkout 디렉터리와 `runner.temp` 바로 아래 새 private 디렉터리를 만든다. 디렉터리가 이미 있으면 실패한다. 이전 Git hooks/config를 실행하거나 지우지 않는다. - `GITHUB_WORKSPACE`/`RUNNER_*`를 덮어쓰지 않는다. 경로는 `steps.prepare.outputs`로 전달한다. checkout 상대 경로는 workspace 안에 있고, 품질 shell마다 `working-directory`를 명시한다. -- setup-uv의 실제 cache-local-path는 준비 단계가 만든 private root 아래다. cache restore/save를 끈다. HOME·venv·pytest·cache는 같은 실행별 private root 아래다. +- setup-uv/setup-node action은 제거한다. runner가 사전 설치한 uv, Python 3.12/3.14, Node 24의 경로와 버전을 검증한다. 선택된 Python 경로를 lock/sync/run에 명시하고 managed Python과 Python 다운로드를 끈다. HOME·venv·pytest·cache는 같은 실행별 private root 아래다. cache restore/save action은 없다. - Cleanup은 실제 준비 단계 출력과 run/attempt/matrix identity를 대조한다. 다른 경로·traversal·symlink는 거절하며 foreign sentinel을 보존한다. output 쓰기 실패 시 준비 단계가 만든 디렉터리만 rollback한다. - PR 원본 필드를 별도 전달한다. `|| github.repository`/`|| github.sha` fallback이 없다. PR head와 push/merge SHA를 다른 fixture로 실행해 검증한다. - 다섯 품질 shell의 실제 body를 합성 uv/node 실행 파일로 실행한다. 각각 exit0 및 exit37 전달을 확인한다. 이 테스트는 실제 제품 검사/Actions 실행이 아니다. @@ -44,6 +44,6 @@ R3는 준비→출력→checkout path admission→effective cache→cleanup을 ## 원격 리뷰의 운영 격리 지적 -Devin의 setup-uv/setup-node 호스트 접근 지적은 미해결 운영 경계로 유지한다. 불변 action pin과 private 디렉터리는 코드 신원·생명주기 제어일 뿐 호스트 confinement 증거가 아니다. 승인된 runner의 격리·egress·동시 writer 통제·reset 증거를 실제 canary로 확인하기 전에는 제품 caller를 활성화하지 않는다. 문서 링크 수리나 로컬 테스트 통과로 이 지적을 resolved로 표시하지 않는다. +Devin이 지적한 setup-uv/setup-node action 실행을 이번 소스 후보에서 제거한다. runner의 사전 설치 도구 검증은 설치 action 실행을 없애지만 호스트 confinement 증거는 아니다. checkout action, 의존성 빌드와 제품 테스트의 실행 경계는 남는다. 승인된 runner의 격리·egress·동시 writer 통제·reset 증거를 실제 canary로 확인하기 전에는 제품 caller를 활성화하지 않는다. 독립 소스 재검토와 운영 격리 승인을 구분하며, 이 변경만으로 지적 전체를 resolved로 표시하지 않는다. Host 격리는 shell 디렉터리 검증만으로 증명되지 않는다. 악성 제품 code의 동시 경로 교체·호스트 조작·강제 종료 cleanup은 provisioning 격리/ephemeral reset 책임이다. Matrix skipped/missing은 원격 receipt 및 protection mapping에서 거절해야 한다. diff --git a/tests/test_procurement_workbench_ci.py b/tests/test_procurement_workbench_ci.py index 407185eff3..183b6322ae 100644 --- a/tests/test_procurement_workbench_ci.py +++ b/tests/test_procurement_workbench_ci.py @@ -73,7 +73,9 @@ def test_owned_documents_exist_and_keep_activation_separate(): def test_product_caller_reference_identifies_the_remote_repository(path): """A central document must link to the product PR, not central PR4.""" prose = (ROOT / path).read_text() - assert "https://github.com/ContextualWisdomLab/procurement-workbench/pull/4" in prose + assert ( + "https://github.com/ContextualWisdomLab/procurement-workbench/pull/4" in prose + ) def test_reusable_only_read_only_input_free(): @@ -102,6 +104,13 @@ def test_fixed_self_hosted_matrix_preserves_all_gates(): "max-parallel": "1", "matrix": {"python-version": ["3.12", "3.14"]}, } + commands = [ + '"$UV_BIN" lock --check --python "$PYTHON_BIN"', + '"$UV_BIN" sync --frozen --group dev --python "$PYTHON_BIN"', + '"$UV_BIN" run --frozen --no-managed-python --python "$PYTHON_BIN" pytest -q --basetemp "$PROCUREMENT_TEST_TEMP"', + '"$UV_BIN" run --frozen --no-managed-python --python "$PYTHON_BIN" ruff check .', + '"$NODE_BIN" --check procurement/ui.js', + ] names = [ "Verify frozen lockfile", "Install frozen dependencies", @@ -109,13 +118,6 @@ def test_fixed_self_hosted_matrix_preserves_all_gates(): "Run Ruff", "Check UI JavaScript syntax", ] - commands = [ - "uv lock --check", - "uv sync --frozen --group dev", - 'uv run pytest -q --basetemp "$PROCUREMENT_TEST_TEMP"', - "uv run ruff check .", - "node --check procurement/ui.js", - ] steps = j["steps"] indices = [] for name, command in zip(names, commands): @@ -257,15 +259,50 @@ def test_missing_or_malformed_serialized_context_rejects(tmp_path, payload): assert "procurement-ci:" in result.stderr -def test_action_pins_cache_and_output_wiring(): - """Checkout, actual cache location and cleanup consume the same producer.""" +def test_preinstalled_runner_toolchain_replaces_remote_setup_actions(): + """The self-hosted runner owns the toolchain; remote setup actions do not run.""" steps = job()["steps"] uses = [s["uses"] for s in steps if "uses" in s] assert uses == [ "actions/checkout@11d5960a326750d5838078e36cf38b85af677262", - "astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e", - "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", ] + toolchain = next( + s for s in steps if s["name"] == "Verify preinstalled runner toolchain" + ) + run = toolchain["run"] + assert "command -v uv" in run + for token in ("command -v uv", "python3.12", "python3.14", "node_bin", "v24"): + assert token in run + assert toolchain["id"] == "toolchain" + for name in ( + "Verify frozen lockfile", + "Install frozen dependencies", + "Run full pytest suite", + "Run Ruff", + ): + env = next(s for s in steps if s["name"] == name)["env"] + assert env["UV_NO_MANAGED_PYTHON"] == "1" + assert env["UV_PYTHON_DOWNLOADS"] == "never" + assert env["PYTHON_BIN"] == "${{ steps.toolchain.outputs.python_bin }}" + for name in ( + "Verify frozen lockfile", + "Install frozen dependencies", + "Run full pytest suite", + "Run Ruff", + "Check UI JavaScript syntax", + ): + step = next(s for s in steps if s["name"] == name) + assert "if" not in step + assert ( + step["working-directory"] == "${{ steps.prepare.outputs.source_absolute }}" + ) + + +def test_action_pins_cache_and_output_wiring(): + """Checkout, actual cache location and cleanup consume the same producer.""" + steps = job()["steps"] + uses = [s["uses"] for s in steps if "uses" in s] + assert uses == ["actions/checkout@11d5960a326750d5838078e36cf38b85af677262"] assert all(re.fullmatch(r".+@[a-f0-9]{40}", s) for s in uses) checkout = steps[3]["with"] assert checkout == { @@ -277,9 +314,6 @@ def test_action_pins_cache_and_output_wiring(): prepare = steps[2] assert prepare["id"] == "prepare" assert "GITHUB_ENV" not in prepare["run"] - setup = next(s for s in steps if s["name"] == "Setup uv")["with"] - assert setup["cache-local-path"] == "${{ steps.prepare.outputs.job_root }}/uv-cache" - assert setup["enable-cache"] == setup["save-cache"] == "false" cleanup = steps[-1] assert cleanup["if"] == "${{ always() && steps.prepare.outcome == 'success' }}" assert ( diff --git a/tests/test_procurement_workbench_ci_lifecycle.py b/tests/test_procurement_workbench_ci_lifecycle.py index 5d27277ceb..2232964abd 100644 --- a/tests/test_procurement_workbench_ci_lifecycle.py +++ b/tests/test_procurement_workbench_ci_lifecycle.py @@ -80,9 +80,17 @@ def test_prepare_checkout_cleanup_are_one_contiguous_lifecycle(tmp_path): step("Checkout admitted product source")["with"]["path"] == "${{ steps.prepare.outputs.source_relative }}" ) - setup = step("Setup uv")["with"] - assert setup["cache-local-path"] == "${{ steps.prepare.outputs.job_root }}/uv-cache" - assert setup["enable-cache"] == "false" and setup["save-cache"] == "false" + for name in ( + "Verify frozen lockfile", + "Install frozen dependencies", + "Run full pytest suite", + "Run Ruff", + ): + assert ( + step(name)["env"]["UV_CACHE_DIR"] + == "${{ steps.prepare.outputs.job_root }}/uv-cache" + ) + assert not any("cache@" in item.get("uses", "") for item in steps()) (private / "uv-cache").mkdir() (private / "uv-cache" / "sentinel").write_text("current-cache") victim = temp / "victim" @@ -179,6 +187,9 @@ def test_quality_shell_preserves_each_command_exit(tmp_path, name, exit_code): env = { **os.environ, "PATH": str(bin_dir), + "UV_BIN": str(bin_dir / "uv"), + "PYTHON_BIN": str(bin_dir / "python"), + "NODE_BIN": str(bin_dir / "node"), "PROCUREMENT_TEST_TEMP": str(tmp_path / "pytest"), } result = run(step(name)["run"], env, tmp_path) @@ -186,6 +197,124 @@ def test_quality_shell_preserves_each_command_exit(tmp_path, name, exit_code): assert result.returncode == exit_code +@pytest.mark.parametrize("version", ["3.12", "3.14", "3.13", ""]) +def test_toolchain_selects_only_supported_matrix_python(tmp_path, version): + """Execute real admission against synthetic tools, including an invalid matrix.""" + bin_dir = tmp_path / "tool chain" + bin_dir.mkdir() + versions = { + "uv": "uv 0.12.5 (synthetic)", + "python3.12": "Python 3.12.14", + "python3.14": "Python 3.14.7", + "node": "v24.21.0", + } + for name, text in versions.items(): + executable = bin_dir / name + executable.write_text(f"#!/bin/sh\nprintf '%s\\n' '{text}'\n") + executable.chmod(0o700) + output = tmp_path / "output" + result = run( + step("Verify preinstalled runner toolchain")["run"], + { + "PATH": f"{bin_dir}:/usr/bin:/bin", + "PYTHON_VERSION": version, + "GITHUB_OUTPUT": str(output), + }, + tmp_path, + ) + if version in ("3.12", "3.14"): + assert result.returncode == 0, result.stderr + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + assert values == { + "uv_bin": str(bin_dir / "uv"), + "python_bin": str(bin_dir / f"python{version}"), + "node_bin": str(bin_dir / "node"), + } + else: + assert result.returncode != 0 + assert "unsupported toolchain matrix" in result.stderr + assert not output.exists() + + +@pytest.mark.parametrize( + "tool,version,exit_code", + [ + ("uv", "uv 0.12.5", 37), + ("python3.12", "Python 3.12.14", 37), + ("python3.14", "Python 3.14.7", 37), + ("node", "v24.21.0", 37), + ("uv", "not uv", 0), + ("python3.12", "Python 3.14.7", 0), + ("python3.14", "Python 3.12.14", 0), + ("node", "v22.0.0", 0), + ("node", "v24.21.0\nv25.0.0", 0), + ("uv", "uv 0.12.5\nunexpected", 0), + ], +) +def test_toolchain_rejects_failed_or_wrong_version_before_outputs( + tmp_path, tool, version, exit_code +): + """A matching line cannot hide a failed command or extra output.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + versions = { + "uv": "uv 0.12.5", + "python3.12": "Python 3.12.14", + "python3.14": "Python 3.14.7", + "node": "v24.21.0", + } + for name, default in versions.items(): + text = version if name == tool else default + code = exit_code if name == tool else 0 + executable = bin_dir / name + executable.write_text(f"#!/bin/sh\nprintf '%s\\n' '{text}'\nexit {code}\n") + executable.chmod(0o700) + output = tmp_path / "output" + result = run( + step("Verify preinstalled runner toolchain")["run"], + { + "PATH": f"{bin_dir}:/usr/bin:/bin", + "PYTHON_VERSION": "3.14", + "GITHUB_OUTPUT": str(output), + }, + tmp_path, + ) + assert result.returncode != 0 + if exit_code: + assert result.returncode == exit_code + assert not output.exists() + + +@pytest.mark.parametrize("separator", ["\n", "\r"]) +def test_toolchain_rejects_record_separator_in_executable_path(tmp_path, separator): + """Path bytes cannot inject extra GitHub output records.""" + bin_dir = tmp_path / f"bin{separator}record" + bin_dir.mkdir() + versions = { + "uv": "uv 0.12.5", + "python3.12": "Python 3.12.14", + "python3.14": "Python 3.14.7", + "node": "v24.21.0", + } + for name, text in versions.items(): + executable = bin_dir / name + executable.write_text(f"#!/bin/sh\nprintf '%s\\n' '{text}'\n") + executable.chmod(0o700) + output = tmp_path / "output" + result = run( + step("Verify preinstalled runner toolchain")["run"], + { + "PATH": f"{bin_dir}:/usr/bin:/bin", + "PYTHON_VERSION": "3.14", + "GITHUB_OUTPUT": str(output), + }, + tmp_path, + ) + assert result.returncode != 0 + assert "unsafe tool path" in result.stderr + assert not output.exists() + + def test_checkout_path_is_direct_child_of_runner_workspace(tmp_path): """Portable path contract; pinned action boundary probe is retained in scratch.""" env, values, workspace, temp = prepare(tmp_path)