Files
tech-log-frontend/docs/operations/ci-quality-gates.md
T

26 KiB

CI quality-gate orchestration

config/ci/gates.json is the executable registry for all 26 gates and the sole workflow model. .gitea/workflows/quality-gates.yml is generated from that strict v2 contract; it is not an independent configuration authority. Update the registry, run corepack pnpm generate:ci-workflow, and commit the generated bytes together. corepack pnpm check:ci-workflow rejects missing, stale, CRLF, or otherwise byte-different output. corepack pnpm check:ci runs artifact-schema checking, the typed contract checker, and this workflow drift check. The Gitea adapter runs each gate as an independent matrix check with full fan-out and no soft-fail wiring.

Contract loading validates every registered command entry against the authoritative root package-script graph and lifecycle/install policy before the runner enters its execution callback. Foreign cwd/workspace script dispatch, argument-sensitive dispatchers, and ineffective lifecycle suppression therefore fail preflight even when the later check:ci command would never run. For npm, that root-only boundary also parses options following an explicit run/run-script target or implicit test/start/stop/restart target: --workspace, -w, --workspaces, and --prefix are foreign manifest scope and are rejected before execution. The first literal -- ends npm option parsing, so later tokens are ordinary script arguments. pnpm and Yarn differ: their options following the selected script name are forwarded to that script; their cwd/workspace selectors are rejected only where the manager consumes them before script selection.

The npm graph includes existing root-manifest pre<script> and post<script> hooks around every explicit run/run-script dependency and implicit start/stop/restart/test dependency. Hooks are omitted only when ordered npm options establish an unambiguous effective --ignore-scripts value before the first literal --; bare and explicitly true forms omit hooks, while false, negative, contradictory, malformed, and post-delimiter forms keep traversal or fail closed. Case-insensitive npm_config_workspace, npm_config_workspaces, npm_config_prefix, npm_config_userconfig, and npm_config_globalconfig assignments are rejected whenever the tokenized command invokes npm. Direct assignments, static paths whose basename is env, exact command/exec prefix chains, prior exports, and set -a assignments share one prefix grammar. Only modeled non-scope env -i, env -u, env --unset, and env -- forms are allowed; cwd-changing or unknown options fail closed. Dynamic assignment names, unmodeled environment mutation, npm --userconfig/--globalconfig, and unquoted pathname expansion before the npm argument delimiter are rejected. The gate runner checks the same inherited environment names before loading the contract or entering the execution callback, so they cannot reach a child gate process.

The dependency graph is:

MERGE_READY
  -> RELEASE_READY
    -> PROD_PROMOTION_READY
      -> FIELD_SLO_READY

DOCUMENTATION_READY (off-chain)

check:architecture는 dependency-cruiser 결과와 별도로 Babel parser/Node resolver 기반 staticImportGraphartifacts/quality/dependency-report.json에 기록한다. TS/TSX의 static, dynamic, type, CommonJS 및 JSDoc import를 검사하고, 로컬 source import에는 실제 TypeScript 확장자를 요구한다. 실행 source나 로컬 specifier에 .js/.jsx/.mjs/.cjs가 있거나 unresolved dependency, parse failure, error-severity layer violation, cycle 또는 지원하지 않는 architecture rule shape가 하나라도 있으면 gate는 실패한다. resolver, unresolved import, layer edge와 cycle regression fixture도 같은 명령에서 실행된다.

Gitea workflow는 run ID/attempt, checkout SHA와 ref에서 build/release 식별자를 만들고 최소 contents: read 권한만 요청한다. Gate runner는 HEAD의 full commit ID와 commit timestamp를 한 번 읽어 SOURCE_DATE_EPOCH를 유도한 뒤 workflow의 SHA와 실제 checkout이 일치하는지 확인한다. CI=true에서 이 식별자 중 하나라도 없거나 불일치하면 build step 전에 fail-closed하며, build와 release manifest도 같은 build ID, commit SHA, release ID와 timestamp를 가져야 한다.

Release tier의 immutable_builddist의 hidden .vite entries, raw pnpm-lock.yaml, build/module inventory와 local release/security evidence를 한 번 생성해 run ID/attempt로 구분된 archive로 게시한다. Raw lock bytes의 SHA-256은 dependency inventory와 candidate manifest 양쪽의 lockfileSha256과 같아야 한다. Producer는 게시 전에 manifest가 선언한 exact member set과 각 byte digest를 검증하고 archive SHA-256을 job output으로 고정한다. Vulnerability/provenance provider job은 같은 archive를 각각 내려받아 output SHA-256과 exact member set을 확인한 뒤 checkout과 분리된 root에만 추출하고, CANDIDATE_LOCKFILE_PATHCANDIDATE_DIST_SHA256를 외부 command에 전달한다. Provider command 실행 후에는 archive와 추출된 candidate를 다시 검증하고, strict provider JSON과 candidate digest가 일치할 때만 report를 업로드한다. Promotion은 같은 archive와 두 report를 별도 경로로 내려받고 SHA/member 검증을 마친 뒤 격리된 root에 추출하여 local evidence를 read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다. Promotion job에는 build/rebuild command가 없으며 검증한 archive 자체를 변경 없이 그대로 승격한다. Local evidence 관계 검증에 필요한 policy와 verifier source도 archive member로 고정되며, checkout 밖의 격리된 cwd에서도 archive path와 기대 digest만으로 candidate-internal check를 재계산하고 archived secret-scan policy/rule/SARIF/zero-finding/digest 관계를 검증한다. Archive에 없는 checkout source를 다시 scan했다고 주장하지 않는다.

Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. Linux runner에는 실행 가능한 /usr/bin/bwrap, /usr/bin/prlimit, /usr/bin/systemd-run, /usr/bin/systemctl, bwrap --size 지원, active user bus, systemd user manager 254 이상, unified cgroup v2와 delegated memory/pids/CPU controller가 모두 필요하다. 실행 파일, user manager/version, trust/archive/path 같은 host-side preflight 실패는 raw report 생성 전에 차단된다. bwrap --size 수용 여부와 실제 delegated controller/limit 값은 owned raw inode를 만든 뒤 scope 안에서만 확정할 수 있으며, 이 단계의 실패는 해당 inode를 identity-bound cleanup하고 fail closed한다.

각 provider는 고유한 collected user scope에서 실행된다. Supervisor는 실행 전에 실제 cgroup membership과 memory.max=1073741824, memory.swap.max=0, pids.max=64, cpu.max="100000 100000"을 확인한다. 내부 process에는 core 0, file-size 8,388,607 bytes, open FD 64, CPU 1,200초 상한도 적용된다. 같은 UID 전체에 합산되는 RLIMIT_NPROC로 provider별 32개를 보장한다고 주장하지 않으며 aggregate PID authority는 cgroup TasksMax=64다. Bubblewrap는 network namespace를 분리해 완전 offline으로 실행하고 workspace, verified candidate, .git mask, /tmp, /etc, /proc, /dev를 read-only로 유지한다. Archive 검증·추출과 sandbox/trust preflight가 끝난 뒤 supervisor가 생성하고 inode를 고정한 정확한 raw report 파일 하나만 read-write bind된다. 주변 untrusted directory 전체는 writable이 아니다. 실행 전 또는 provider 실패 시, 그리고 성공적으로 sealed evidence를 게시한 뒤에도 supervisor가 소유한 inode만 atomic quarantine을 거쳐 제거하므로 빈 stale report 없이 재시도할 수 있다.

Provider command와 provider-prefixed environment는 bounded length-prefixed bwrap vector로 systemd-run stdin에 전달되어 supervisor/systemd/bwrap wrapper argv나 unit metadata에 노출되지 않는다. 단, 최종 provider executable의 일반 argv는 같은 UID의 process inspection에 보일 수 있으므로 command 문자열과 인자에 token, password, private-key material을 넣으면 안 된다. Credential은 반드시 해당 종류의 VULNERABILITY_PROVIDER_* 또는 PROVENANCE_PROVIDER_* environment로만 전달하고 *_COMMAND에는 넣지 않는다. 정상 provider 종료까지 같은 stdin을 parent-liveness pipe로 열어 두며 supervisor hard death의 EOF를 받은 in-scope wrapper는 provider process group 전체를 종료하고 dev/inode가 일치하는 raw report만 정리한다.

별도의 trusted guardian child는 provider scope 밖에서 filesystem transaction 전체를 소유한다. Client는 spawn 전에 canonical raw/evidence directory를 O_DIRECTORY|O_NOFOLLOW로 열고 identity를 확인한 뒤 provider kind와 nonce에서 canonical raw/final 및 nonce-private raw-staging/sealed-temp exact leaf를 확정한다. Client가 두 private file을 O_RDWR|O_CREAT|O_EXCL|O_NOFOLLOW mode 0600으로 미리 할당하고 dev/inode를 기록한다. Directory descriptor는 guardian fd 3/4, private file descriptor는 fd 5/6으로 상속되며 guardian argv에는 Node와 helper 경로만 있다. 최초 canonical v2 frame에는 provider kind, absolute deadline, 32-byte nonce만 전달한다.

Guardian bootstrap은 procfs link를 읽기 전에 fd 5/6을 fstat한다. Procfs pathname은 canonical directory의 exact direct-child grammar를 만족하고 descriptor-relative lstat이 이미 확보한 fd identity/type/mode/size/link count와 일치할 때만 해당 identity의 cleanup alias가 된다. Guard frame 검증 뒤 raw staging을 fixed raw leaf에 no-overwrite hard link하고 두 alias의 link count 2를 확인한 다음 private alias를 제거하고 raw directory를 sync한다. Canonical raw가 같은 identity와 link count 1로 남은 뒤에만 READY를 응답한다. READY 전 종료 시 client는 pre-spawn raw identity로 raw staging/canonical을, sealed identity로 temp/final을 각각 확인해 일치하는 alias만 정리한다. 현재 canonical pathname을 새 ownership identity로 승격하지 않으므로 외부 file과 concurrent same-kind winner를 보존하면서 같은 workspace를 즉시 재시도할 수 있다. Supervisor는 READY identity와 canonical target도 정확히 확인한다.

검증된 JSON bytes는 pinned temp inode에만 기록하고 0400 적용과 file sync를 마친다. Guardian은 authenticated publish의 size/hash/identity를 재검증하고 같은 directory에서 no-replace link(temp, final), temp unlink, directory sync를 수행한 뒤 PUBLISHED를 응답한다. GITHUB_OUTPUT append 이후 commit은 raw inode를 제거하고 commitPending으로만 전이하며, 깨끗한 control EOF까지 확인해야 PASS가 된다. 그 전의 EOF, deadline, 잘못된 frame/nonce, commit 뒤 추가 byte 또는 guardian 조기 종료는 identity가 일치하는 raw/temp/final을 모두 정리하고 fail closed한다. Provider wall timeout 30분에 post-processing 10분을 더한 guardian lease 상한은 40분이다.

Client가 private allocation 뒤 guardian spawn 전에 hard stop되면 empty mode-0600 nonce-private leaf만 남을 수 있다. 기록되지 않은 pathname은 ownership 근거가 아니므로 자동 sweeping하지 않는다. 이 private leaf는 fixed raw/final name을 점유하지 않아 same-kind retry를 막지 않는다.

GITHUB_OUTPUT은 runner가 소유한 regular file이라는 실행기 계약을 전제로 한다. Sealing/output I/O의 OS-level cancellation을 보장하지 않는다. Provider scope가 이미 수집된 뒤 guardian이 종료되면 scope-active latch는 늦은 kill을 시작하지 않고 lifecycle error만 기록한다. 이후 publish/commit의 awaited failure가 identity가 고정된 raw/temp/final fallback을 모두 정리하고 fail closed한다.

Provider stdout/stderr는 credential을 포함할 수 있는 untrusted bytes이므로 CI log로 재전송하거나 보관하지 않고, byte 수만 합산해 1 MiB 상한을 적용한다. Guardian의 stderr/control fd가 닫혀 진단 출력이 EPIPE/EBADF가 되어도 cleanup 뒤 nonzero 종료는 생략되지 않는다. Provider wall-clock 상한은 30분이다. Wall timeout, aggregate output 초과, parent-liveness loss, 실행 중 guardian loss는 provider process group을 명시적으로 SIGKILL한다. 일반 command 실패와 FD/CPU RLIMIT 종료는 실제 exit/signal로 systemd completion을 거치며, 모든 경로에서 wrapper 종료와 systemd unit/cgroup collection을 확인한다. Adapter는 사전에 배치된 offline data와 supervisor candidate binding만 읽어 정확한 report inode에 기록해야 한다. Scope/cgroup limit drift, residual unit, workspace·host home/toolcache·sealed evidence 접근 의존성은 모두 blocking failure다.

Workflow가 실행하는 action은 scripts/contracts/ci-gates.ts의 단일 typed, runtime-frozen registry에서만 resolve된다. uses:에는 repository 별칭, tag, branch 또는 short SHA를 허용하지 않고 다음 absolute upstream URL과 full 40-hex commit만 사용한다.

  • https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 (v4.3.1)
  • https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 (v4.4.0)
  • https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 (patched v4 branch)
  • https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 (patched v4 branch)

Gitea 1.22 공식 Actions 문서는 artifact v4 호환 경로로 이 ChristopherHX patched upload/download fork를 권고한다. 이 권고는 실제 설치 환경의 호환성을 증명하지 않으므로, 지원 baseline은 계속 Gitea 1.26.4+ 및 Gitea Runner 1.0.0+이며 action 변경은 registry, semantic tests, snapshot과 generated workflow를 함께 갱신해야 한다.

이 workflow를 required check로 전환하기 전에 staging instance에서 permissions, gitea.run_attempt, 위 digest-pinned patched artifact action을 포함한 한 번의 실제 end-to-end provider smoke를 반드시 통과시켜야 한다. mock/local generation 결과는 required-check enablement evidence가 아니다. 모든 setup step은 node-version-file: .nvmrc를 사용하므로 CI Node.js 버전은 .nvmrc의 exact pin과 같다. CI contract는 .nvmrc가 full semantic version인지, 모든 job이 이 파일을 사용하는지 함께 검사한다. ubuntu-latest runner label은 관리자가 임의 환경에 매핑할 수 있으므로 provenance로 사용하지 않는다. 대신 그 label을 immutable container image에 매핑하고 동일한 image digest를 repository variable RUNNER_IMAGE_DIGEST에 설정한다. Workflow는 이 값을 runtime environment의 CI_RUNNER_IMAGE로 전달하며, 값이 비어 있으면 모든 gate가 실행 전에 실패한다. Gitea repository variable 이름에는 CI prefix를 사용할 수 없으므로 두 이름을 의도적으로 구분한다.

Pull requests and develop pushes evaluate merge readiness. Version tags evaluate merge then release readiness. Production and field evaluation require an explicit workflow dispatch. The field tier cannot pass until the 28-day sample threshold decision is recorded. Documentation readiness consumes the canonical project-note evidence in which both scoped diagrams already received 100/100 PASS_SCOPED; the repo ledger preserves the evidence scope and canonical digests.

All jobs upload the shared artifacts/ tree even after failure. Numeric retention remains an organization/provider decision; the workflow intentionally does not invent retention-days. The relative minimums are recorded in the registry: merge evidence through the PR decision, coherent release evidence through the next release promotion, drill evidence through the next production promotion, and field evidence through aggregation.

Runbook jobs write to the stable path artifacts/runbooks/<runbook-id>/record.json; the dynamic release identity is stored inside the record. This keeps gate evidence lookup independent from slashes or other provider-specific characters in RELEASE_ID, while each workflow artifact remains scoped to its own run.

Browser-backed merge gates install and execute the pinned Chromium, Firefox, and WebKit engines. This makes route behavior, reflow, native dialog semantics, theme persistence, and automated accessibility a cross-engine contract rather than a Chromium-only smoke check.

FE-GATE-005의 unit suite에는 client cache/storage의 deterministic contract가 포함된다. storage-registry.test.ts는 key별 codec, schema/TTL, 16,384-byte 상한, quota/security/corrupt cleanup과 memory fallback을, cross-tab-invalidation.test.ts는 2,048-byte exact wire, topic/release epoch, self/duplicate/out-of-order/gap, BroadcastChannel → localStorage → local-only degrade와 cleanup을 검증한다. tanstack-cache-coordinator.test.ts는 topic을 local namespace로만 resolve하고 query key/data를 wire에 보내지 않으며 mutation 중 remote hint를 coalesce하는지 검증한다. 결과는 기존 artifacts/tests/unit.xml과 coverage evidence에 포함된다.

FE-GATE-006과 runtime adapter unit은 QueryInvalidationProvider/coordinator가 실제 production provider tree와 bootstrap에 존재하고 browser capability getter가 실패해도 boot가 DEGRADED_LOCAL_ONLY로 계속되는지 확인한다.

FE-GATE-008test:browser-capabilities suite는 File/Blob, native file input, IndexedDB, OPFS, Cache Storage와 StorageManager reference runtime을 Vite dev origin의 실제 browser API에 연결한다. API가 없는 engine에서는 skip하지 않고 adapter의 명시적인 UNSUPPORTED fallback을 검증한다. 생성한 database, OPFS namespace와 owned cache는 각 test가 자신이 만든 opaque namespace만 정리한다.

FE-GATE-010은 application/domain/presentation에서 raw browser storage와 picker global 접근을 막고, 선택되지 않은 reference runtime을 bootstrap이나 installed feature가 import하는 것도 거절한다. catalog의 referenceRuntime.sourceRoots와 conformance script가 실제로 존재해야 하며, productionComposition은 project capability 결정 전까지 false다. 같은 gate의 realtime source/fixture 검사는 native SSE/WebSocket/Web Push API가 소유 adapter 밖으로 새는 것, presentation timer owner, 미선택 runtime의 bootstrap/installed-feature 조합을 차단한다. optional recipe source gate는 uncomposed runtime 전체를 tree-shaking 없이 합성해 catalog의 gzip 예산도 blocking으로 검증한다. FE-GATE-020은 reference runtime source·전용 test·catalog metadata를 제거한 임시 repository에서도 base typecheck, architecture, test, build가 통과하는지 검증해 skeleton의 선택성을 유지한다. browser file/storage와 realtime runtime은 각각 독립 removal fixture와 JUnit evidence를 가지며, realtime fixture는 공통 event authority, SSE, WebSocket, bounded Polling, Web Push source와 공개 export, 전용 boundary script를 제거한 뒤 base gate를 다시 실행한다.

같은 FE-GATE-010FE-REG-QUERY governance는 installed query마다 namespace, serialization/identity, invalidation topic, version, crossContext: "invalidate-only"persistence: "disabled"를 요구한다. 따라서 cross-tab invalidation은 조립됐지만 query persistence와 기존 IndexedDB reference runtime은 계속 미조립이다.

현재 FE-GATE-008 browser-capability suite에는 실제 두 page의 BroadcastChannel/localStorage fallback scenario가 아직 없다. unit fake 통과를 native multi-tab promotion evidence로 간주하지 않는다. 이 gap은 Client cache and browser storage platform의 완료 기준에 미완료로 남아 있다. 현재 장애 분류와 안전한 local-only 복구 절차는 Client cache and Web Storage recovery를 따른다.

Repository variables required by higher tiers:

  • RUNNER_IMAGE_DIGEST for the immutable job-container image digest used by the ubuntu-latest runner label; the workflow exposes it to gates as CI_RUNNER_IMAGE (required by every tier)
  • HOSTING_BASE_URL for live header verification
  • FIELD_WEB_VITALS_INPUT for the privacy-approved field sample document
  • MIN_ELIGIBLE_SAMPLES after the baseline decision
  • VULNERABILITY_PROVIDER_COMMAND and PROVENANCE_PROVIDER_COMMAND for the independently operated scanner and attestor commands
  • VULNERABILITY_PUBLIC_KEY_PATH, VULNERABILITY_KEY_ID, PROVENANCE_PUBLIC_KEY_PATH, and PROVENANCE_KEY_ID for separately managed trusted Ed25519 verification material

두 provider role은 서로 다른 key ID뿐 아니라 canonical DER-SPKI public-key bytes도 사용해야 한다. 동일 key를 서로 다른 ID로 재등록한 구성도 finalizer가 거절한다.

If any external provider command, report, trust path, or key ID is absent, promotion remains unavailable with FAIL_UNVERIFIED; there is no local generator/restore fallback.

Each provider command must atomically emit strict provider evidence v2 from the supervisor bindings for evidence type, issued/expires timestamps, run ID/attempt, PROVIDER_INVOCATION_NONCE, source identity, candidate digests, key ID, and DER-SPKI key fingerprint. Promotion receives the two supervisor job outputs as VULNERABILITY_INVOCATION_NONCE and PROVENANCE_INVOCATION_NONCE; do not replace them with constants or values parsed from provider reports.

Promotion verification/staging step과 promoted-release upload action 사이에는 어떤 step도 둘 수 없고 upload에는 if: always()를 사용할 수 없다. 이 인접성은 실패한 검증의 publication을 막지만 staging path를 upload action에 cryptographically hand off하지는 않는다. 따라서 promotion runner는 trusted, exclusive, single-tenant여야 하고 staging step 종료부터 바로 다음 upload action이 파일을 여는 시점까지 같은 UID로 실행되는 provider command나 다른 process가 살아남지 않는다는 운영 가정을 만족해야 한다. 업로드된 artifact의 downstream consumer도 artifact service나 transfer action을 신뢰 경계 밖으로 보고, candidate manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시 검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest revalidation을 대신하지 않는다. Promotion job에는 job-level if가 없다. 기본 needs 성공 의미론으로 immutable build, vulnerability provider, provenance provider 세 job이 모두 성공해야 하며, always()cancelled()로 cancellation을 덮어쓰지 않는다. Bare always()는 step cleanup에만 사용된다. 다만 cancellation 시 cleanup 실행 여부는 workflow 정적 타입이나 단위 테스트로 증명하지 않았으며 runner/native smoke에서 확인해야 하는 신뢰 경계다. Finalizer output은 RUNNER_TEMP 아래 random private directory이며 exact-five upload는 ${{ steps.finalize.outputs.staging_root }} 아래 다음 다섯 canonical pathname만 사용한다: release-candidate.tar.gz, vulnerability-report.json, provenance-attestation.json, provider-verification.json, promotion-verification.json. 바로 다음 always() cleanup은 staging path/token, runner-temp device/inode와 staging-leaf device/inode 여섯 output을 모두 요구한다. cleanup은 pin한 leaf descriptor에서 exact-five name만 unlink하고 non-recursive rmdir만 사용하므로 교체된 directory나 canary tree를 recursive 삭제하지 않는다. stable .release/promoted-staging directory를 만들거나 재사용하지 않는다. exact five는 captured archive 한 개, captured report 두 개와 process 안에서 생성한 provider/promotion verification v3 두 개이며 promotion record는 provider record, local assessment, report hashes와 run/source/candidate/ nonces/key identities/trust-policy hash 및 signed secretScanAttestation을 함께 bind한다. 이 attestation은 PASS와 local-assessment/source-set/policy/SARIF/ scan-input digest를 포함한다. Supervisor/finalizer는 captured archive에서 기대 tuple을 유도해 exact equality를 확인하지만, 실제로 같은 source-set 전체를 독립 스캔하고 forged empty SARIF에 서명하지 않을 책임은 trusted vulnerability provider에 있다. Staging은 restrictive umask와 무관하게 directory 0700, file 0400을 강제하고, 모든 write 뒤 live time으로 exact-five signature/freshness를 다시 확인한 뒤에만 output을 공개한다. Descriptor-relative 정리는 ancestor/leaf 교체와 symlink를 fail-closed로 처리하지만 upload action의 same-UID pathname reopen 또는 atomic renameat2 handoff를 보장하지 않는다. staging Gitea smoke/native adapter 확인 전에는 그 경계를 닫았다고 보고하지 않는다. 실제 smoke는 exact-five upload-download와 success, validation failure, upload failure, cancellation 각각의 cleanup을 관찰해야 한다. 현재 repository에는 native uploader나 renameat2 보장이 없다. 또한 portable Node의 mkdir와 최초 pathname lstat는 atomic하지 않다. 구현은 mkdir 직후 metadata를 저장하고 이후 O_DIRECTORY|O_NOFOLLOW descriptor의 device/inode와 비교한 뒤에만 permission을 바꾸지만, 최초 lstat보다 앞서 성공한 malicious same-UID 교체는 native/privilege 경계로 남는다. 따라서 RUNNER_TEMP private 0700 ancestor와 exclusive single-tenant runner가 필수다. 실패 cleanup도 created device/inode와 opened descriptor가 일치한 뒤에만 활성화된다. 불일치 descriptor는 close만 수행하며 현재 visible replacement pathname은 unlink나 rmdir하지 않는다. 공격자가 original directory를 다른 이름이나 parent 밖으로 이동한 경우 portable Node parent scan으로 안전하게 회수할 수 없으므로, 공격자를 배제한 trusted runner/native cleanup 또는 격리된 test fixture가 잔여 directory를 후처리해야 한다.

Branch protection must mark each FE-GATE-* / <name> check required for its declared tier. This repository cannot configure server-side protection by committing a file.