Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 315 additions & 0 deletions .github/workflows/procurement-workbench-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
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: 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_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_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_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_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_BIN" --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
63 changes: 63 additions & 0 deletions GOAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Procurement central self-hosted CI

상태: 중앙 PR #2620 소스 게시·로컬 검증·독립 소스 검토 완료 / 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`.
- 제품 [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를 변경하지 않는다.

## 실패 상태

다음 상태는 성공이 아니다.

- 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`를 입증한다.

## 다음 실행 조각

현재 게시 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 활성화와 정상 병합은 조건을 충족한 뒤 진행한다.
Loading
Loading