From ccd1b217e60ff41414e561521d06beec5bdee0ab Mon Sep 17 00:00:00 2001 From: Younggi Choi <74581798+choiyounggi@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:14:08 +0900 Subject: [PATCH 1/2] feat(ci): require conversation resolution and promote test-quality to a blocking required check (#244) - setup_branch_protection.sh: payload adds required_conversation_resolution and the `test-quality / gate` context, lists modeb-linux by default and writes the remaining switches out as false; `--check` now prints a read-only ok:/drift: report from one GET. - ai-gates-quality.yml: the test-quality job is blocking; the other AI gates stay advisory. - _ai-gate.yml: the called job has no name:, so ran and skipped runs report under the same check name; zero changed files now fails a blocking gate. - docs/CI-GATES.md, CHANGELOG.md: real protection state, promotion exception with evidence, why required approvals stay off, Scorecard gap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Th13jVHptQQ1mCLah7XBay --- .github/workflows/_ai-gate.yml | 28 +- .github/workflows/ai-gates-quality.yml | 10 +- CHANGELOG.md | 17 ++ docs/CI-GATES.md | 154 +++++++++-- impl/tests/test_ai_gate_workflow.py | 179 ++++++++++++- impl/tests/test_branch_protection_script.py | 268 ++++++++++++++++---- scripts/setup_branch_protection.sh | 117 ++++++++- 7 files changed, 675 insertions(+), 98 deletions(-) diff --git a/.github/workflows/_ai-gate.yml b/.github/workflows/_ai-gate.yml index 0870ec52..3197beca 100644 --- a/.github/workflows/_ai-gate.yml +++ b/.github/workflows/_ai-gate.yml @@ -3,7 +3,8 @@ name: AI gate (reusable) # t4·t5의 게이트별 워크플로가 `uses: ./.github/workflows/_ai-gate.yml`로 이것을 # 부른다(프롬프트 파일 경로만 넘긴다). 이 워크플로는 게이트 자체(프롬프트·판정 # 임계)를 만들지 않는다 — 판정 배관만 만든다: 포크 차단, SHA 핀, 시크릿 부재/ -# 변경 파일 0건의 명시적 skip, exit code로만 판정(scripts/ai_gate_verdict.py). +# 변경 파일 0건의 명시적 처리(blocking이면 실패, advisory면 경고 — #244), exit +# code로만 판정(scripts/ai_gate_verdict.py). # 산문은 exit code를 정하지 못한다. on: @@ -95,7 +96,12 @@ concurrency: jobs: gate: - name: ${{ inputs.gate_name }} + # #244: 이 잡에는 name:을 두지 않는다. 잡 수준 if:로 skip된 재사용 잡은 + # name: 식을 평가하지 않은 채 보고된다(2026-10-22 실측: facebook/rocksdb, + # qualcomm-linux/meta-qcom, shakfu/inferna). 그러면 skip될 때만 체크 이름이 + # 달라져, skip-ai-gate 라벨·포크 PR에서 required check가 영원히 대기한다. + # 이름이 없으면 돌든 skip되든 "<호출 잡> / gate"로 보고된다 — required + # check 이름은 test-quality / gate (docs/CI-GATES.md). runs-on: ubuntu-latest # D6: 이 레포는 PUBLIC이다. 포크 PR은 시크릿 없이 돈다 — 시크릿을 포크 PR # 컨텍스트로 끌어오는 위험한 트리거는 이 워크플로에서 절대 쓰지 않는다 @@ -209,6 +215,8 @@ jobs: printf '%s\n' "${FILES[@]}" fi echo "count=${#FILES[@]}" >> "$GITHUB_OUTPUT" + mapfile -t DELETED_FILES < <(git diff --diff-filter=D --name-only "$MERGE_BASE" "$HEAD_SHA") + echo "deleted=${#DELETED_FILES[@]}" >> "$GITHUB_OUTPUT" DELIM="AI_GATE_FILES_$(openssl rand -hex 20)" { echo "list<<$DELIM" @@ -216,6 +224,22 @@ jobs: echo "$DELIM" } >> "$GITHUB_OUTPUT" + # #244: 위 목록은 --diff-filter=d라 삭제된 파일이 빠진다. 변경 파일 0건(삭제만 있는 + # PR, 빈 diff)이면 아래 판정 스텝이 전부 건너뛰어 잡이 판정 없이 초록이 된다 — + # 시크릿 부재와 같은 의미론으로 다룬다: blocking이면 실패, advisory면 경고. + - name: 변경 파일 없음 처리 + if: steps.check_secret.outputs.skip != 'true' && steps.changed_files.outputs.count == '0' + env: + BLOCKING: ${{ inputs.blocking }} + GATE_NAME: ${{ inputs.gate_name }} + DELETED: ${{ steps.changed_files.outputs.deleted }} + run: | + if [ "$BLOCKING" = "true" ]; then + echo "::error::[$GATE_NAME] 변경 파일 0건(삭제 ${DELETED}건) — 게이트가 판정할 수 없다. blocking 게이트이므로 잡을 실패시킨다. 삭제만 있는 PR은 skip-ai-gate 라벨(사유를 PR 본문에)로 우회한다." + exit 1 + fi + echo "::warning::[$GATE_NAME] 변경 파일 0건(삭제 ${DELETED}건) — 이 게이트는 판정하지 않았다. 이 잡의 초록불은 '검토하지 않음'을 뜻한다 — advisory이므로 머지는 막지 않는다." + - name: 프롬프트 조립 id: build_prompt if: steps.check_secret.outputs.skip != 'true' && steps.changed_files.outputs.count != '0' diff --git a/.github/workflows/ai-gates-quality.yml b/.github/workflows/ai-gates-quality.yml index 245b024b..32cc2bd5 100644 --- a/.github/workflows/ai-gates-quality.yml +++ b/.github/workflows/ai-gates-quality.yml @@ -38,9 +38,13 @@ jobs: with: gate_name: test-quality prompt_file: .github/ai-gates/test-quality.md - # D6: advisory로 시작한다. 승격은 docs/CI-GATES.md의 절차(약 50 PR, - # 수용률 80% 이상)를 거쳐 이 한 줄을 true로 뒤집는 일이다. - blocking: false + # D6: 2026-10-22 승격(issue #244). 승격 기준(약 50 PR, 수용률 80%)을 + # 채우기 전의 예외다 — 근거와 실측은 docs/CI-GATES.md "advisory → blocking + # 승격 절차". 이 잡 키와 재사용 잡 id(gate)가 required check + # `test-quality / gate`를 이룬다 — 이 잡 이름을 바꾸거나, 이 잡에 if:를 + # 달거나, pull_request: 아래에 types/branches/paths를 넣으면 보호 브랜치가 + # 영영 보고되지 않는 체크를 기다린다. + blocking: true # D5: D4가 성립시켜 준다 — 어떤 PR에서도 읽은 파일이 실리므로 # 공허한 통과 보호를 끌 이유가 없다. expect_nonempty: true diff --git a/CHANGELOG.md b/CHANGELOG.md index b45e7e02..eb4f5f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -473,6 +473,23 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). §2.1), and `scripts/rfc_lint.py` now fails when `CHANGELOG.md` cites an RFC whose status is not `Accepted` or `Superseded`, or a number with no document under `rfcs/` (issue #249). +- `scripts/setup_branch_protection.sh` now also requires conversation + resolution and the `test-quality / gate` check, lists + `modeb-linux (test_repo_state under real mode B)` (already required on + `main`) by default (`--include-modeb` is kept and does nothing), and + writes `required_linear_history`, `block_creations`, `lock_branch` and + `allow_fork_syncing` out as `false`, their defaults. `--check` now reads + the live protection with one GET and prints an `ok:` or `drift:` line + per check and setting. The `test-quality` AI gate is now blocking; the + other AI gates stay advisory. The reusable AI-gate job no longer sets a + `name:`, so every AI gate check is now called ` / gate`: GitHub + reports a job skipped by its own `if:` under the unevaluated name + expression, which would have left a required check waiting forever on + labelled or fork pull requests. A pull request whose diff only deletes + files, or is empty, now fails a blocking AI gate instead of passing it + unread. Required pull-request approvals stay off: with one collaborator + and `enforce_admins`, no pull request could ever be approved + (issue #244). ## [0.8.0] — 2026-09-02 "The Money-contract release." The RFC-0044/0045 designs accepted in 0.7.0 diff --git a/docs/CI-GATES.md b/docs/CI-GATES.md index eb8c21bf..ee98353f 100644 --- a/docs/CI-GATES.md +++ b/docs/CI-GATES.md @@ -21,12 +21,19 @@ AI 게이트가 산문이 아니라 스키마 검증으로 판정되는 배관(` | 산문 스니펫 컴파일(`check_doc_snippets.py`) | 결정론 | `ci.yml`(t2) | advisory → blocking 예정 | 문서에 박힌 `.lnpl` 스니펫이 실제로 컴파일되는지 | | `rfc-conformance` | AI | `_ai-gate.yml`(t4) | advisory | RFC가 말하는 것과 코드가 하는 것의 불일치 | | `golden-approval` | AI | `_ai-gate.yml`(t4) | advisory | 골든 시나리오 변경의 타당성 | -| `test-quality` | AI | `_ai-gate.yml`(t5) | advisory | 자기 코드를 자기가 평가하는 테스트(구현/평가 미분리), 가짜 통과 | +| `test-quality` | AI | `_ai-gate.yml`(t5) | blocking + required (2026-10-22 승격 — 승격 기준 예외, 아래 "advisory → blocking 승격 절차") | 자기 코드를 자기가 평가하는 테스트(구현/평가 미분리), 가짜 통과 | | `prose-factcheck` | AI | `_ai-gate.yml`(t5) | advisory | PR 산문(설명·주석)이 실제 diff와 어긋나는 주장 | | 컨테이너 이미지 배선(`test_release_workflow.py`의 `image` 잡 단언) | 결정론 | `release.yml`(태그 push) | blocking(릴리스 전용) | `image` 잡 누락, smoke-before-push 순서, 태그 목록(`vX.Y.Z`+`X.Y`, `latest` 없음), `run:` 블록의 `github.` 식 금지, `permissions` 범위, `publish-pypi` 비활성 유지 회귀 | | 요청당 결정적 카운터(`test_perf_counters.py`) | 결정론 | `ci.yml` — 테스트 스위트(matrix) 잡 안의 한 모듈 | blocking(`gate` 잡 경유) | 요청 1건당 DB 연결 생성·닫힘 수, 저장소 문장 수, 드라이버 생성 수의 회귀(예: 요청마다 연결을 하나 더 여는 변경) | | 주간 부하 리포트(`load-weekly.yml`) | 결정론 | `load-weekly.yml` — `schedule`(월요일 07:00 UTC) + `workflow_dispatch` | report-only(비-required) | 절대 처리량·지연 추이(gunicorn sync 워커 2개, fake·sqlite 백엔드, 50/100/200/400 rps). 판정으로 쓰지 않는다 | +AI 게이트 4종의 체크 이름은 `rfc-conformance / gate`, `golden-approval / gate`, +`test-quality / gate`, `prose-factcheck / gate`이다(generic-http 호출부는 +`<잡>-generic-http / gate`) — 재사용 잡에 `name:`이 없기 때문이고, 잡 수준 `if:`로 +skip된 잡은 `name:` 식을 평가하지 않은 채 보고된다(2026-10-22 공개 레포 4곳 +실측: facebook/rocksdb, qualcomm-linux/meta-qcom, shakfu/inferna, +zeta-chain/node. GitHub 문서에는 없는 동작이다). + AI 게이트 4종은 전부 이 문서가 만든 재사용 워크플로 `_ai-gate.yml`(`on: workflow_call`) 위에 얹힌다. 게이트별 워크플로는 `gate_name`·`prompt_file`만 넘기면 되고, 판정 자체(스키마 검증·exit code)는 전부 여기서 공유한다 — 산문이 @@ -88,6 +95,38 @@ AI 게이트 4종은 전부 **advisory**로 시작한다(`_ai-gate.yml`의 `bloc → blocking 단계적 승격이라는 조사된 정설을 적용한 것이다. 실제 50 PR을 채운 뒤 이 문서의 표를 이 레포의 실측 수용률로 갱신한다. +### test-quality 승격 — 기준 예외 (issue #244) + +`test-quality`(claude-code 호출부 잡 하나)는 2026-10-22에 위 기준을 채우기 +**전에** `blocking: true`로 올랐다. 기준(약 50 PR, 수용률 80%)은 충족되지 +않았다. 근거가 되는 실측(2026-10-22, main `315d410`): + +| 사실 | 측정 명령 | +|------|-----------| +| `ai-gates-quality.yml` 실행 33건, 브랜치 16개, 2026-08-31 이후 전부 `success` | `gh run list --repo choiyounggi/linkly --workflow ai-gates-quality.yml --limit 200 --json conclusion,headBranch,createdAt` | +| 2026-10-01 이후 실행 20건의 `test-quality` 잡 20건이 모두 시크릿이 있는 상태로 돌아 `success` | 각 실행에 `gh run view --repo choiyounggi/linkly --json jobs` — "시크릿 부재 처리" 스텝이 `skipped`, 잡이 `success` | +| 같은 20건의 로그에 "게이트 통과(exit 0)"가 20건, `##[warning]`·`##[error]` 애노테이션이 0건 — 러너 실패 0건, `unavailable` 0건, blocker finding 0건, 머지를 막았을 판정 0건 | 각 실행에 `gh run view --repo choiyounggi/linkly --log`, `test-quality` 잡의 줄(옛 이름 기준 접두사)만 센다 | +| 최근 PR #221·#259의 코멘트 0건, 리뷰 0건 | `gh pr view --repo choiyounggi/linkly --json comments,reviews` | + +수용률은 **잴 수 없다** — blocker finding이 0건이라 분모가 없고 sticky 코멘트도 +남지 않았다. 즉 이 승격은 "수용률 80%를 확인했다"가 아니라 "러너 실패 없이 +돌았고 오탐 신호도 없었다"에 기댄 예외다. 지금 승격하는 이유: #244에서 사용자가 +정했다 — 협업자가 1명이라 사람 리뷰를 요구할 수 없으므로(아래 "왜 required +approvals를 걸지 않는가") AI 게이트 하나가 두 번째 리뷰어 자리를 대신한다. +하나만 고른 이유는 네 게이트 중 `test-quality`가 모든 PR에서 돌고(`paths` +필터 없음) 러너 실패 이력이 없기 때문이다. 나머지 셋은 advisory로 남는다. + +게이트가 판정하지 못하는 경우는 전부 잡 실패다(blocking일 때): + +1. 시크릿 부재 — "시크릿 부재 처리"가 `::error::` + 실패. +2. 게이트 실행 실패 — "판정 반영"의 `unavailable` 갈래(Claude 스텝 실패 또는 빈 출력). +3. 변경 파일 0건 — 삭제만 있는 PR이거나 빈 diff. "변경 파일 없음 처리"가 실패시킨다 + (advisory이면 경고만). 삭제만 있는 PR은 아래 `skip-ai-gate` 라벨로 우회한다. +4. 판정 스크립트 exit 2(형식 오류)·exit 3(공허한 통과) — `blocking`과 무관하게 항상 실패. + +탈출구는 `skip-ai-gate` 라벨이다(아래 절). 이 예외는 기준을 채운 뒤 이 절을 +실측 수용률로 갱신해 닫는다. + ## `ANTHROPIC_API_KEY` 등록 절차와 OIDC 대안 ### 자격증명은 셋 중 하나 — API 키 / OAuth 토큰 / OIDC 페더레이션 @@ -110,8 +149,8 @@ AI 게이트 4종은 전부 **advisory**로 시작한다(`_ai-gate.yml`의 `bloc ### API 키를 리포지토리 시크릿으로 등록 -이 레포는 지금 시크릿이 하나도 등록돼 있지 않다(`gh secret list` → 0건). 등록 -전까지 모든 AI 게이트는 `_ai-gate.yml`의 "시크릿 존재 확인" 스텝에서 +이 레포에는 `CLAUDE_CODE_OAUTH_TOKEN`이 등록돼 있다(`gh secret list`, 2026-10-22). +시크릿이 없을 때 AI 게이트는 `_ai-gate.yml`의 "시크릿 존재 확인" 스텝에서 `SKIPPED: ANTHROPIC_API_KEY not configured`를 찍고 실행되지 않는다. **그때 잡이 어떻게 끝나는지가 중요하다.** 시크릿이 없다고 잡을 조용히 @@ -122,8 +161,13 @@ AI 게이트 4종은 전부 **advisory**로 시작한다(`_ai-gate.yml`의 `bloc | `blocking` | 시크릿 없음 → | |---|---| -| `false` (advisory, 현재 4종 전부) | `::warning::` 애노테이션을 남기고 잡은 초록. 애노테이션이 "이 초록은 검토하지 않았다는 뜻"이라고 화면에 적는다. 머지는 막지 않는다 | -| `true` (승격 후) | `::error::` + 잡 실패 | +| `false` (advisory — test-quality 외 3종) | `::warning::` 애노테이션을 남기고 잡은 초록. 애노테이션이 "이 초록은 검토하지 않았다는 뜻"이라고 화면에 적는다. 머지는 막지 않는다 | +| `true` (blocking — 현재 test-quality) | `::error::` + 잡 실패 | + +게이트가 판정하지 못하는 다른 두 경우도 같은 결과가 된다: 러너 실패 또는 빈 출력 +("판정 반영"의 `unavailable` 갈래)과 변경 파일 0건("변경 파일 없음 처리" — +삭제만 있는 PR이거나 빈 diff; 삭제만 있는 PR은 `skip-ai-gate` 라벨로 간다). +advisory면 경고 + 초록, blocking이면 에러 + 실패다. 두 번째 줄이 승격 절차의 구멍을 막는다. 게이트를 `blocking: true`로 올려 required status check에 등록한 뒤 시크릿이 만료·회전·개명되면, 이 갈래가 @@ -188,15 +232,23 @@ gh variable set AI_GATE_HTTP_API_STYLE --body "classic" # 선택 ## required status check 후보 -`main`에는 지금 브랜치 보호가 걸려 있지 않다 — 즉 **어떤 게이트도 지금은 -머지를 막지 못한다.** 결정론 게이트가 실패해도, AI 게이트가 blocker를 내도 -PR은 머지될 수 있다. 브랜치 보호를 켜는 일 자체는 레포 소유자의 몫(범위 -밖)이지만, 켤 때 required status check로 등록할 후보는 다음과 같다 — -GitHub이 실제로 만들어내는 체크런 이름 그대로다(`ci.yml`의 잡 `name:` 필드에서 -직접 확인, 추측 아님): +`main`의 브랜치 보호는 2026-10-22 현재 이렇다(`gh api +repos/choiyounggi/linkly/branches/main/protection`): required status check 5개 +(`gate (py3.11)`, `gate (py3.12)`, `gate (py3.13)`, `lint (ruff)`, +`modeb-linux (test_repo_state under real mode B)`), `strict` 켜짐, +`enforce_admins: true`, force push·삭제 차단, `required_pull_request_reviews` 없음, +`required_conversation_resolution: false`, linear history·block creations·lock·fork +syncing는 꺼짐. 즉 결정론 게이트가 실패하면 머지가 막히고, **AI 게이트는 아직 아무것도 +머지를 막지 않는다** — 아래 `test-quality / gate`가 보호에 들어가기 전까지는. +required status check로 등록할 후보는 다음과 같다 — GitHub이 실제로 만들어내는 +체크런 이름 그대로다(`ci.yml`의 잡 `name:` 필드에서 직접 확인, 추측 아님. AI 게이트의 +이름은 `_ai-gate.yml`의 잡에 `name:`이 없어서 `<호출 잡> / gate`다): - `gate (py3.11)` / `gate (py3.12)` / `gate (py3.13)` — 테스트 매트릭스(`ci.yml`) - `lint (ruff)` — ruff 게이트(`ci.yml`) +- `modeb-linux (test_repo_state under real mode B)` — 실제 mode B 게이트(`ci.yml`) +- `test-quality / gate` — 승격된 AI 게이트 하나(`ai-gates-quality.yml`의 `test-quality` + 호출 잡 + `_ai-gate.yml`의 잡 id `gate`) RFC lint, Plugin reference drift check, Version sync check, Doc snippet compile gate는 별도 체크런이 **아니다** — 전부 위 `gate` 잡 하나의 스텝으로 @@ -205,16 +257,28 @@ compile gate는 별도 체크런이 **아니다** — 전부 위 `gate` 잡 하 기재했었다 — 이 표는 그 정정판이다. `modeb-linux (test_repo_state under real mode B)`는 issue #161로 `ci.yml`에 -추가됐지만 의도적으로 이 후보 목록에서 제외한다 — GitHub 호스팅 러너에서 -실제로 green 실행을 관측하기 전까지는 required로 걸지 않는다 -(`scripts/setup_branch_protection.sh --include-modeb`로 나중에 추가할 수 있다). - -`scripts/setup_branch_protection.sh`가 위 페이로드를 적용하는 스크립트로 -존재한다(멱등, `--check`는 읽기 전용). 하지만 이 스크립트는 CI나 어떤 -오케스트레이션 태스크로도 자동 실행되지 않는다 — 적용은 항상 사람/코디네이터의 -명시적 결정을 거친 수동 단계다. - -AI 게이트 4종은 advisory인 동안은 required로 등록하지 않는다 — required +추가됐다. GitHub 호스팅 러너에서 green 실행이 관측됐고 +(`gh api repos/choiyounggi/linkly/commits/315d410/check-runs`, 2026-10-22: `success`) +라이브 보호에도 이미 required로 걸려 있으므로 기본 목록에 들어 있다. +`--include-modeb`는 옛 런북이 깨지지 않도록 남겨 뒀을 뿐 아무것도 바꾸지 않는다. + +`scripts/setup_branch_protection.sh`가 원하는 페이로드를 가진 스크립트로 +존재한다(멱등). 페이로드는 위 라이브 설정에 `test-quality / gate`와 +`required_conversation_resolution: true`를 더한 것이고, `required_linear_history`, +`block_creations`, `lock_branch`, `allow_fork_syncing`은 기본값(false)을 명시적으로 +적는다. `--check`는 읽기 전용이다: 페이로드를 출력하고, 라이브 main 보호 설정을 GET +한 번으로 읽어 항목마다 `ok:`/`drift:` 줄과 `summary:` 줄을 낸다. 오늘 기대하는 +drift는 정확히 `drift: check "test-quality / gate" missing live`와 +`drift: required_conversation_resolution desired=true live=false`다. 이 스크립트는 +CI나 어떤 오케스트레이션 태스크로도 자동 실행되지 않는다 — 적용은 항상 +사람/코디네이터의 명시적 결정을 거친 수동 단계이며, `--check` 뒤에, 그리고 +이 변경을 실은 PR의 head에서 `test-quality / gate` 체크런이 실제로 관측된 뒤에만 +한다. 변경 전에 만들어진 브랜치의 PR은 옛 체크 이름을 보고하므로, 새 required +check를 만족하려면 main을 머지해야 한다. + +AI 게이트 4종 중 `test-quality`만 `blocking: true`이고 위 목록에 +`test-quality / gate`로 올라 있다. 나머지 셋(`rfc-conformance`, `golden-approval`, +`prose-factcheck`)은 advisory인 동안 required로 등록하지 않는다 — required status check는 정의상 실패 시 머지를 막으므로, 오탐률이 검증되지 않은 채로 걸면 advisory 단계의 목적(수용률을 재는 동안 머지를 막지 않는 것)이 무너진다. 위 승격 절차를 거쳐 `blocking: true`로 뒤집힌 게이트만 이 목록에 추가한다. @@ -230,6 +294,20 @@ red/green)이고, 주간 `mutation-weekly` 잡은 SURVIVED 뮤테이션을 이 않는다. 성능 회귀의 PR 차단은 `gate` 잡 안의 결정적 카운터 테스트가 맡는다 (아래 "성능 회귀 — 결정적 카운터와 주간 부하 리포트" 절). +### 왜 required approvals를 걸지 않는가 + +협업자가 1명이고(`gh api repos/choiyounggi/linkly/collaborators --jq length` → 1, +2026-10-22) `enforce_admins: true`이며, PR 작성자는 자기 PR을 승인할 수 없다 +([GitHub 문서](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging)). +그래서 `required_approving_review_count: 1`을 걸면 모든 머지가 막힌다 — 관리자도 +예외가 아니다. 대신 두 가지를 둔다: 대화 해결 필수(`required_conversation_resolution`)와 +`test-quality / gate` required check. 이것은 사람 리뷰를 대체하지 못한다 — +OpenSSF Scorecard의 `Code-Review` 체크는 이 대안 아래에서 **충족되지 않는다** +([Scorecard checks](https://github.com/ossf/scorecard/blob/main/docs/checks.md): +"Review by bots, including bots powered by artificial intelligence / machine learning +(AI/ML), do not count as code review"). Scorecard도 활동 참여자가 1명이면 리뷰를 +요구하는 것이 불가능하다고 적는다. 두 번째 협업자가 생기면 다시 본다. + ## 뮤테이션 테스팅 2단계 `impl/tests/mutation_check.py`(77개 뮤테이션 + no-op 컨트롤, 전체 실행 @@ -285,11 +363,33 @@ fake·sqlite 백엔드를 50/100/200/400 rps로 잰 `scripts/load_probe.py`의 ## `skip-ai-gate` 라벨 -PR에 `skip-ai-gate` 라벨이 붙어 있으면 `_ai-gate.yml`의 잡 자체가 그 이유를 -로그에 남기고 skip된다(전체 잡이 안 도는 것이지, 판정만 우회하는 것이 아니다). +PR에 `skip-ai-gate` 라벨이 붙어 있으면 `_ai-gate.yml`의 잡 자체가 `if:`로 skip된다 +(전체 잡이 안 도는 것이지, 판정만 우회하는 것이 아니다). `if:`로 skip된 잡은 스텝을 +하나도 실행하지 않으므로 로그에 이유를 남기지 않는다 — 이유는 PR 본문에 적는다. + +이 라벨은 required check `test-quality / gate`의 **의도된 탈출구**다. 잡이 skip되면 +체크런 `test-quality / gate`는 `skipped`로 끝나고, GitHub은 `skipped`를 required status +check를 만족한 것으로 센다("Required status checks must have a successful, skipped, or +neutral status" — +[About protected branches](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches), +2026-10-22 확인). 재사용 잡에 `name:`이 없어서 skip돼도 같은 이름으로 보고된다는 점은 +GitHub 문서에 없고 공개 레포 4곳의 실측이다(위 로스터 아래 설명) — 보호를 적용한 뒤 +라벨을 붙인 PR 하나로 실제 이름을 확인한다. + +- **라벨은 실행을 시작시키지 않는다.** `pull_request`의 기본 activity type은 + `opened`/`synchronize`/`reopened`다 + ([문서](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)). + 라벨을 붙인 뒤에 커밋을 하나 push하거나 PR을 닫았다 다시 열어야(reopen) 워크플로가 + 새로 돌아 `skipped`가 보고된다. +- **삭제만 있는 PR**(변경 파일 0건)은 blocking 게이트가 판정할 수 없어 실패한다 + ("변경 파일 없음 처리"). 이 라벨로 간다. +- **라벨은 아직 레포에 없다.** 보호를 적용하기 전에 코디네이터가 + `gh label create skip-ai-gate --repo choiyounggi/linkly`로 만든다. +- **포크 PR**은 잡의 `if:`(`head.repo.full_name == github.repository`)에 걸려 skip된다. + 같은 경로로 required check를 통과한다(AI 리뷰 없음) — 포크 PR은 사람이 읽고 머지한다. 사용 기준: 급히 머지해야 하는데 AI 게이트가 (모델 문제·타임아웃·명백한 오탐 등으로) -막고 있을 때만 쓴다. 결정론 게이트를 우회하는 용도가 아니다 — 이 라벨은 -`_ai-gate.yml`의 잡 조건에만 걸려 있고 `ci.yml`의 결정론 게이트에는 아무 -영향을 주지 않는다. 라벨을 붙인 PR은 리뷰어가 그 사유를 PR 설명이나 코멘트에 -남기는 것을 권장한다(기계적으로 강제하지는 않는다). +막고 있을 때, 또는 삭제만 있는 PR일 때만 쓴다. 결정론 게이트를 우회하는 용도가 +아니다 — 이 라벨은 `_ai-gate.yml`의 잡 조건에만 걸려 있고 `ci.yml`의 결정론 게이트에는 +아무 영향을 주지 않는다. 라벨을 붙인 PR은 그 사유를 PR 본문에 적는다(기계적으로 +강제하지는 않는다). diff --git a/impl/tests/test_ai_gate_workflow.py b/impl/tests/test_ai_gate_workflow.py index ce3b19fc..af3893ee 100644 --- a/impl/tests/test_ai_gate_workflow.py +++ b/impl/tests/test_ai_gate_workflow.py @@ -48,6 +48,12 @@ UNAVAILABLE_ADVISORY = ("::warning::게이트를 실행하지 못했다(Claude Code 액션 실패 또는 빈 출력) " "— advisory이므로 잡은 초록불로 남는다. 원인이 일시 장애라면 " "재실행으로 충분하다.") +EMPTY_BLOCKING = ("::error::[test-quality] 변경 파일 0건(삭제 3건) — 게이트가 판정할 수 없다. " + "blocking 게이트이므로 잡을 실패시킨다. 삭제만 있는 PR은 skip-ai-gate " + "라벨(사유를 PR 본문에)로 우회한다.") +EMPTY_ADVISORY = ("::warning::[prose-factcheck] 변경 파일 0건(삭제 0건) — 이 게이트는 판정하지 " + "않았다. 이 잡의 초록불은 '검토하지 않음'을 뜻한다 — advisory이므로 머지는 " + "막지 않는다.") def _read(path): @@ -97,7 +103,7 @@ def _env_lines(marker): # 러너가 직접 넣어 주는 환경변수 — 스텝 env에 선언하지 않는다. RUNNER_PROVIDED = {"GITHUB_OUTPUT", "RUNNER_TEMP"} -# 이 태스크가 바꾸거나 새로 만든 스텝의 env 블록 전체(issue #252). claude-code 쪽 +# 이 태스크가 바꾸거나 새로 만든 스텝의 env 블록 전체(issue #252). claude-code 쪽 (issue #244: 변경 파일 없음 처리) # 줄은 b1cd523의 `_ai-gate.yml`과 같은 것이다. STEP_ENV = { "id: check_secret": [ @@ -112,6 +118,11 @@ def _env_lines(marker): " MISSING_SECRET: ${{ inputs.runner == 'generic-http' && " "'AI_GATE_HTTP_API_KEY' || 'ANTHROPIC_API_KEY' }}", ], + "name: 변경 파일 없음 처리": [ + " BLOCKING: ${{ inputs.blocking }}", + " GATE_NAME: ${{ inputs.gate_name }}", + " DELETED: ${{ steps.changed_files.outputs.deleted }}", + ], "id: http_inputs": [ " BASE_SHA: ${{ github.event.pull_request.base.sha }}", " HEAD_SHA: ${{ github.event.pull_request.head.sha }}", @@ -540,10 +551,87 @@ def write(name, text): self.assertNotIn("b2", diff) +def _bash_major(): + out = subprocess.run(["bash", "-c", "echo ${BASH_VERSINFO[0]}"], capture_output=True, text=True) + return int(out.stdout.strip() or 0) + + +class EmptyChangeSetTest(unittest.TestCase): + """#244 R12: 변경 파일 0건(삭제만 / 빈 diff)은 판정 불가 — blocking이면 실패, advisory면 경고.""" + + def run_empty(self, **env): + return _exec(_run_block("name: 변경 파일 없음 처리"), env) + + def test_blocking_gate_fails_on_zero_changed_files(self): + proc, _ = self.run_empty(BLOCKING="true", GATE_NAME="test-quality", DELETED="3") + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, EMPTY_BLOCKING + "\n") + + def test_advisory_gate_warns_on_zero_changed_files(self): + proc, _ = self.run_empty(BLOCKING="false", GATE_NAME="prose-factcheck", DELETED="0") + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, EMPTY_ADVISORY + "\n") + + def _file_list_output(self, edit_b): + if _bash_major() < 4: + self.skipTest("PATH bash is older than 4; the step uses mapfile (the runner has bash 5)") + git_env = {"PATH": os.environ["PATH"], "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull} + with _scratch() as box: + repo = os.path.join(box, "repo") + os.makedirs(repo) + + def git(*args): + return subprocess.run(["git", "-c", "user.name=t244", "-c", + "user.email=t244@example.invalid", *args], cwd=repo, + env=git_env, capture_output=True, text=True, + check=True).stdout.strip() + + git("init", "-q") + for name in ("a.txt", "b.txt", "c.txt"): + with open(os.path.join(repo, name), "w") as fh: + fh.write(name + "\n") + git("add", "a.txt", "b.txt", "c.txt") + git("commit", "-q", "-m", "base") + base = git("rev-parse", "HEAD") + git("rm", "-q", "a.txt") + if edit_b: + with open(os.path.join(repo, "b.txt"), "w") as fh: + fh.write("changed\n") + git("add", "b.txt") + else: + git("rm", "-q", "b.txt") + git("commit", "-q", "-m", "head") + head = git("rev-parse", "HEAD") + proc, out = _exec(_run_block("id: changed_files"), + dict(git_env, BASE_SHA=base, HEAD_SHA=head, PATHS_NOTE="t244"), + cwd=repo) + self.assertEqual(proc.returncode, 0, proc.stderr) + return out.splitlines() + + def test_deletion_only_diff_counts_zero_and_reports_deletions(self): + lines = self._file_list_output(edit_b=False) + self.assertIn("count=0", lines) + self.assertIn("deleted=2", lines) + + def test_mixed_diff_counts_edits_and_deletions(self): + lines = self._file_list_output(edit_b=True) + self.assertIn("count=1", lines) + self.assertIn("deleted=1", lines) + + def test_empty_change_step_is_wired_after_the_file_list(self): + self.assertIn(" if: steps.check_secret.outputs.skip != 'true' && " + "steps.changed_files.outputs.count == '0'\n", + _step_text("name: 변경 파일 없음 처리")) + text = _read(REUSABLE) + self.assertLess(text.index("id: changed_files"), text.index("name: 변경 파일 없음 처리")) + self.assertLess(text.index("name: 변경 파일 없음 처리"), text.index("id: build_prompt")) + + class WiringTest(unittest.TestCase): def test_every_gated_step_still_checks_the_secret(self): - self.assertEqual(_read(REUSABLE).count("steps.check_secret.outputs.skip != 'true'"), 10) + self.assertEqual(_read(REUSABLE).count("steps.check_secret.outputs.skip != 'true'"), 11) def test_claude_only_steps_are_gated_on_the_claude_runner(self): for marker in ("id: claude", "id: schema"): @@ -641,6 +729,17 @@ def test_claude_action_pin_is_unchanged(self): "uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10"), 1) +class CalledJobNameTest(unittest.TestCase): + """#244: 재사용 잡 `gate`에는 `name:`이 없다 — 체크 이름은 `<호출 잡> / gate`.""" + + def test_called_job_has_no_name(self): + text = _read(REUSABLE) + self.assertEqual(text.count("\n gate:\n"), 1) + self.assertEqual(re.search(r"^jobs:\n ([a-z][a-z0-9_-]*):$", text, re.M).group(1), "gate") + job = text[text.index("\n gate:\n") + 1:] + self.assertEqual(re.findall(r"^ name:.*$", job, re.M), []) + + def _job_text(path, job): """호출부 `path`의 잡 `job` 하나(다음 최상위 잡 키 직전까지).""" text = _read(path) @@ -652,6 +751,82 @@ def _job_text(path, job): return text[start:nxt.start()] if nxt else text[start:] +def _blocking(path, job): + """호출부 `path`의 잡 `job`이 넘기는 `blocking:` 값 하나.""" + values = re.findall(r"^ blocking: (true|false)$", _job_text(path, job), re.M) + if len(values) != 1: + raise AssertionError("%s/%s has %d blocking lines" % (path, job, len(values))) + return values[0] + + +class PromotedGateTest(unittest.TestCase): + """#244: test-quality(claude-code) 하나만 blocking이고, 그 값이 판정 불가 갈래 셋을 모두 실패로 만든다.""" + + def test_only_the_test_quality_claude_job_is_blocking(self): + for jobs in (CallerWiringTest.OLD_JOBS, CallerWiringTest.NEW_JOBS): + for path, names in jobs.items(): + for job in names: + with self.subTest(job=job): + expected = "true" if (path, job) == (CALLERS[1], "test-quality") else "false" + self.assertEqual(_blocking(path, job), expected) + total = 0 + for name in sorted(os.listdir(WORKFLOWS)): + if name.endswith(".yml"): + total += len(re.findall(r"^ +blocking: true$", _read(os.path.join(WORKFLOWS, name)), re.M)) + self.assertEqual(total, 1) + + def test_promoted_gate_fails_closed_without_credentials(self): + proc, _ = _exec(_run_block("name: 시크릿 부재 처리"), + dict(GATE_NAME="test-quality", MISSING_SECRET="ANTHROPIC_API_KEY", + BLOCKING=_blocking(CALLERS[1], "test-quality"))) + self.assertEqual(proc.returncode, 1) + self.assertTrue(proc.stdout.startswith( + "::error::[test-quality] ANTHROPIC_API_KEY가 없어 게이트를 실행하지 못했다"), proc.stdout) + + def test_promoted_gate_fails_closed_when_unavailable(self): + proc, _ = _exec(_run_block("name: 판정 반영"), + dict(VERDICT_CODE="unavailable", BLOCKING=_blocking(CALLERS[1], "test-quality"), + RUNNER_LABEL="Claude Code 액션", PROVIDER_LABEL="Anthropic API")) + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, UNAVAILABLE_BLOCKING + "\n") + + def test_promoted_gate_fails_closed_on_zero_changed_files(self): + proc, _ = _exec(_run_block("name: 변경 파일 없음 처리"), + dict(GATE_NAME="test-quality", DELETED="3", + BLOCKING=_blocking(CALLERS[1], "test-quality"))) + self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.stdout, EMPTY_BLOCKING + "\n") + + def test_advisory_sibling_still_passes_without_credentials(self): + proc, _ = _exec(_run_block("name: 시크릿 부재 처리"), + dict(GATE_NAME="prose-factcheck", MISSING_SECRET="ANTHROPIC_API_KEY", + BLOCKING=_blocking(CALLERS[1], "prose-factcheck"))) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertTrue(proc.stdout.startswith("::warning::"), proc.stdout) + + +class PromotedContextLiteralTest(unittest.TestCase): + """#244: required check `test-quality / gate`를 영원히 대기시킬 수 있는 변경을 막는다.""" + + def test_trigger_and_jobs_cannot_strand_the_required_check(self): + caller = _read(CALLERS[1]) + lines = caller.split("\n") + i = lines.index("on:") + self.assertEqual(lines[i + 1], " pull_request:") + nxt = next(line for line in lines[i + 2:] if line.strip()) + self.assertLessEqual(len(nxt) - len(nxt.lstrip(" ")), 2, nxt) + self.assertEqual(re.findall(r"^ +paths(?:-ignore)?:", caller, re.M), []) + self.assertEqual(caller.count("\n test-quality:\n"), 1) + job = _job_text(CALLERS[1], "test-quality") + self.assertIn(" gate_name: test-quality\n", job) + self.assertEqual(re.findall(r"^ if:.*$", job, re.M), []) + reusable = _read(REUSABLE) + self.assertEqual(re.findall(r"^ ([a-z][a-z0-9_-]*):$", + reusable[reusable.index("\njobs:\n"):], re.M), ["gate"]) + self.assertEqual(re.findall(r"^ name:.*$", + reusable[reusable.index("\n gate:\n"):], re.M), []) + + class CallerWiringTest(unittest.TestCase): """호출부 — 기존 4개 잡은 그대로, 게이트마다 `-generic-http` 잡 하나(R-t252-1).""" diff --git a/impl/tests/test_branch_protection_script.py b/impl/tests/test_branch_protection_script.py index 265aa1e8..3c0f1eb4 100644 --- a/impl/tests/test_branch_protection_script.py +++ b/impl/tests/test_branch_protection_script.py @@ -1,33 +1,96 @@ -"""`scripts/setup_branch_protection.sh`의 --check(읽기 전용) 모드 단언 (issue #166). +"""`scripts/setup_branch_protection.sh`의 --check(읽기 전용) 모드 단언 (issues #166, #244). -이 스크립트는 이 태스크에서 절대 실제로 `gh api` PUT을 호출하지 않는다 — -`--check`는 원하는 페이로드를 stdout에 출력할 뿐 아무것도 바꾸지 않는다. -실제 적용은 코디네이터가 Gate 2 이후 사람 확인을 받고 별도로 실행한다. +`--check`는 원하는 페이로드와, 라이브 main 보호 설정을 GET 한 번으로 읽은 읽기 전용 +drift 보고를 출력한다. 테스트는 격리된 PATH의 가짜 `gh`로 네트워크에 닿지 않고, +적용(PUT) 경로는 절대 실행하지 않는다. """ +import json import os +import re +import shutil import subprocess +import sys +import tempfile import unittest REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) SCRIPT_PATH = os.path.join(REPO, "scripts", "setup_branch_protection.sh") +CALLER = os.path.join(REPO, ".github", "workflows", "ai-gates-quality.yml") +REUSABLE = os.path.join(REPO, ".github", "workflows", "_ai-gate.yml") -REQUIRED_CONTEXTS = [ +EXPECTED_CONTEXTS = [ "gate (py3.11)", "gate (py3.12)", "gate (py3.13)", "lint (ruff)", + "modeb-linux (test_repo_state under real mode B)", + "test-quality / gate", ] -MODEB_CONTEXT = "modeb-linux (test_repo_state under real mode B)" - - -def _run(args): - return subprocess.run( - [SCRIPT_PATH] + args, - cwd=REPO, - capture_output=True, - text=True, - timeout=30, - ) +PROMOTED_CONTEXT = "test-quality / gate" +GET_ARGV = ("api --method GET -H Accept: application/vnd.github+json " + "-H X-GitHub-Api-Version: 2022-11-28 " + "/repos/choiyounggi/linkly/branches/main/protection") + + +def _live(contexts, conversation=False, reviews=None, linear=False): + body = { + "required_status_checks": {"strict": True, + "checks": [{"context": c, "app_id": 1} for c in contexts]}, + "enforce_admins": {"enabled": True}, + "required_conversation_resolution": {"enabled": conversation}, + "allow_force_pushes": {"enabled": False}, + "allow_deletions": {"enabled": False}, + "required_linear_history": {"enabled": linear}, + "block_creations": {"enabled": False}, + "lock_branch": {"enabled": False}, + "allow_fork_syncing": {"enabled": False}, + "required_signatures": {"enabled": False}, + } + if isinstance(reviews, dict): + body["required_pull_request_reviews"] = reviews + return body + + +def _run(args, body=None, gh_rc=0, with_gh=True, with_python=True): + """격리된 bin 디렉터리를 PATH로 스크립트를 돌린다. (proc, gh argv 줄 목록).""" + base = os.path.join(REPO, ".claude", "tmp") + os.makedirs(base, exist_ok=True) + bash = shutil.which("bash") + with tempfile.TemporaryDirectory(prefix="lnpl-t244-", dir=base) as box: + bindir = os.path.join(box, "bin") + os.makedirs(bindir) + os.symlink(bash, os.path.join(bindir, "bash")) + os.symlink(shutil.which("cat"), os.path.join(bindir, "cat")) + if with_python: + os.symlink(sys.executable, os.path.join(bindir, "python3")) + argv_log = os.path.join(box, "gh-argv.log") + open(argv_log, "w").close() + body_file = os.path.join(box, "gh-body") + with open(body_file, "w") as fh: + fh.write(json.dumps(body if body is not None else _live(EXPECTED_CONTEXTS[:5])) + if not isinstance(body, str) else body) + if with_gh: + gh = os.path.join(bindir, "gh") + with open(gh, "w") as fh: + fh.write("#!%s\nprintf '%%s\\n' \"$*\" >> \"$GH_ARGV_LOG\"\n" + "cat \"$GH_FAKE_BODY\"\nexit \"${GH_FAKE_RC:-0}\"\n" % bash) + os.chmod(gh, 0o755) + env = {"PATH": bindir, "GH_ARGV_LOG": argv_log, "GH_FAKE_BODY": body_file, + "GH_FAKE_RC": str(gh_rc), "LANG": "C.UTF-8"} + proc = subprocess.run([bash, SCRIPT_PATH] + args, cwd=REPO, env=env, + capture_output=True, text=True, timeout=30) + with open(argv_log) as fh: + argv = fh.read().splitlines() + return proc, argv + + +def _payload(stdout): + return json.loads(stdout.split("\n--- live vs desired")[0].split("\n", 1)[1]) + + +def _read(path): + with open(path, encoding="utf-8") as fh: + return fh.read() class ScriptSyntaxTest(unittest.TestCase): @@ -43,44 +106,149 @@ def test_script_is_executable(self): self.assertTrue(os.access(SCRIPT_PATH, os.X_OK)) -class CheckModeDefaultTest(unittest.TestCase): - """정상 케이스: --check는 실측 gh api 호출 없이 페이로드만 출력한다.""" - - def test_check_exits_zero(self): - result = _run(["--check"]) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_check_contains_all_default_contexts(self): - result = _run(["--check"]) - for context in REQUIRED_CONTEXTS: - self.assertIn(context, result.stdout) - - def test_check_excludes_modeb_by_default(self): - result = _run(["--check"]) - self.assertNotIn(MODEB_CONTEXT, result.stdout) - - def test_check_never_calls_gh(self): - result = _run(["--check"]) - self.assertNotIn("gh api", result.stdout) - - -class IncludeModebFlagTest(unittest.TestCase): - """경계값 케이스: --include-modeb 조합 시 modeb-linux가 목록에 추가된다.""" - - def test_check_include_modeb_adds_context(self): - result = _run(["--check", "--include-modeb"]) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn(MODEB_CONTEXT, result.stdout) - for context in REQUIRED_CONTEXTS: - self.assertIn(context, result.stdout) +class CheckPayloadTest(unittest.TestCase): + """정상 케이스: --check는 원하는 보호 페이로드와 라이브 대비 drift를 읽기 전용으로 보고한다.""" + + def test_check_payload_is_the_desired_protection(self): + proc, _ = _run(["--check"]) + self.assertEqual(proc.returncode, 0, proc.stderr) + payload = _payload(proc.stdout) + self.assertEqual(payload["required_status_checks"], + {"strict": True, "checks": [{"context": c} for c in EXPECTED_CONTEXTS]}) + self.assertEqual({k: v for k, v in payload.items() if k != "required_status_checks"}, { + "enforce_admins": True, + "required_pull_request_reviews": None, + "restrictions": None, + "allow_force_pushes": False, + "allow_deletions": False, + "required_conversation_resolution": True, + "required_linear_history": False, + "block_creations": False, + "lock_branch": False, + "allow_fork_syncing": False, + }) + self.assertEqual(list(payload), [ + "required_status_checks", "enforce_admins", "required_pull_request_reviews", + "restrictions", "allow_force_pushes", "allow_deletions", + "required_conversation_resolution", "required_linear_history", "block_creations", + "lock_branch", "allow_fork_syncing"]) + + def test_check_reports_drift_against_live_today(self): + proc, _ = _run(["--check"]) + self.assertEqual(proc.returncode, 0, proc.stderr) + lines = proc.stdout.splitlines() + self.assertIn('drift: check "test-quality / gate" missing live', lines) + self.assertIn("drift: required_conversation_resolution desired=true live=false", lines) + for context in EXPECTED_CONTEXTS[:5]: + self.assertIn('ok: check "%s"' % context, lines) + self.assertEqual(lines[-1], "summary: 2 drift, 15 ok") + + def test_check_issues_exactly_one_get(self): + proc, argv = _run(["--check"]) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(argv, [GET_ARGV]) + for line in argv: + for forbidden in ("PUT", "PATCH", "--input"): + self.assertNotIn(forbidden, line) + + +class CheckDriftBoundaryTest(unittest.TestCase): + """경계값 케이스: 라이브가 같을 때·비었을 때·여분이 있을 때의 drift 보고.""" + + def test_check_in_sync_reports_no_drift(self): + proc, _ = _run(["--check"], body=_live(EXPECTED_CONTEXTS, conversation=True)) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout.splitlines()[-1], "summary: 0 drift, 17 ok") + self.assertNotIn("drift:", proc.stdout) + + def test_check_reports_live_only_context(self): + proc, _ = _run(["--check"], body=_live(EXPECTED_CONTEXTS + ["legacy (ci)"], conversation=True)) + lines = proc.stdout.splitlines() + self.assertIn('drift: check "legacy (ci)" live only (PUT would remove it)', lines) + self.assertEqual(lines[-1], "summary: 1 drift, 17 ok") + + def test_check_empty_live_checks_reports_every_context(self): + proc, _ = _run(["--check"], body=_live([], conversation=True)) + lines = proc.stdout.splitlines() + for context in EXPECTED_CONTEXTS: + self.assertIn('drift: check "%s" missing live' % context, lines) + self.assertEqual(lines[-1], "summary: 6 drift, 11 ok") + + def test_check_live_reviews_present_is_drift(self): + body = _live(EXPECTED_CONTEXTS, conversation=True, + reviews={"required_approving_review_count": 1}) + proc, _ = _run(["--check"], body=body) + self.assertIn("drift: required_pull_request_reviews desired=null live=set", + proc.stdout.splitlines()) + + def test_check_live_linear_history_is_drift(self): + proc, _ = _run(["--check"], body=_live(EXPECTED_CONTEXTS, conversation=True, linear=True)) + self.assertIn("drift: required_linear_history desired=false live=true", + proc.stdout.splitlines()) + + def test_include_modeb_is_a_noop(self): + plain, _ = _run(["--check"]) + flagged, _ = _run(["--check", "--include-modeb"]) + self.assertEqual(flagged.returncode, 0, flagged.stderr) + self.assertEqual(flagged.stdout, plain.stdout) + contexts = [c["context"] for c in _payload(flagged.stdout)["required_status_checks"]["checks"]] + self.assertEqual(contexts.count("modeb-linux (test_repo_state under real mode B)"), 1) + + def test_checks_literal_is_one_line(self): + text = _read(SCRIPT_PATH) + literals = re.findall(r"^CHECKS='(.+)'$", text, re.M) + self.assertEqual(len(literals), 1) + for context in EXPECTED_CONTEXTS: + self.assertIn('{"context":"%s"}' % context, literals[0]) + + def test_promoted_context_is_composed_from_the_workflow_sources(self): + caller = _read(CALLER) + reusable = _read(REUSABLE) + self.assertEqual(caller.count("\n test-quality:\n"), 1) + job_id = re.search(r"^jobs:\n ([a-z][a-z0-9_-]*):$", reusable, re.M).group(1) + self.assertEqual(re.findall(r"^ name:.*$", reusable[reusable.index("\n gate:\n"):], re.M), []) + self.assertEqual("test-quality / " + job_id, PROMOTED_CONTEXT) + checks_line = re.search(r"^CHECKS='(.+)'$", _read(SCRIPT_PATH), re.M).group(1) + self.assertIn('{"context":"%s"}' % PROMOTED_CONTEXT, checks_line) + + +class CheckUnavailableTest(unittest.TestCase): + """에러 케이스: 라이브를 읽지 못해도 --check는 페이로드를 출력하고 0으로 끝난다.""" + + def test_gh_failure_reports_unavailable(self): + proc, _ = _run(["--check"], gh_rc=1) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("live: unavailable (gh api exited 1)", proc.stderr) + self.assertEqual(_payload(proc.stdout)["enforce_admins"], True) + self.assertNotIn("drift:", proc.stdout) + self.assertNotIn("summary:", proc.stdout) + + def test_missing_gh_reports_unavailable(self): + proc, _ = _run(["--check"], with_gh=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("live: unavailable (gh not found)", proc.stderr) + + def test_missing_python_reports_unavailable(self): + proc, argv = _run(["--check"], with_python=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("live: unavailable (python3 not found)", proc.stderr) + self.assertEqual(argv, []) + + def test_unparseable_response_reports_unavailable(self): + proc, _ = _run(["--check"], body="not json") + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("live: unavailable (unparseable response)", proc.stderr) + self.assertNotIn("summary:", proc.stdout) class UnknownFlagTest(unittest.TestCase): - """에러 케이스: 알 수 없는 플래그는 비정상 종료한다.""" + """에러 케이스: 알 수 없는 플래그는 종료 코드 2로 끝나고 gh를 부르지 않는다.""" - def test_unknown_flag_exits_nonzero(self): - result = _run(["--bogus"]) - self.assertNotEqual(result.returncode, 0) + def test_unknown_flag_exits_two(self): + proc, argv = _run(["--bogus"]) + self.assertEqual(proc.returncode, 2) + self.assertIn("unknown flag: --bogus", proc.stderr) + self.assertEqual(argv, []) if __name__ == "__main__": diff --git a/scripts/setup_branch_protection.sh b/scripts/setup_branch_protection.sh index 40ce4cc7..403e08f7 100755 --- a/scripts/setup_branch_protection.sh +++ b/scripts/setup_branch_protection.sh @@ -1,28 +1,25 @@ #!/usr/bin/env bash -# Idempotent main-branch-protection preparer (issue #166). NEVER auto-run by -# CI or any orchestration task — apply only after an explicit human/coordinator -# decision, post-merge-gate. `--check` is read-only (prints current vs desired, -# changes nothing). `--include-modeb` adds the unproven modeb-linux job to the -# required-checks list (default: excluded — see design.md D2; enable after the -# first observed green run on GitHub-hosted runners). +# Idempotent main-branch-protection preparer (issues #166, #244). NEVER auto-run +# by CI or any orchestration task — apply only after an explicit human/coordinator +# decision. `--check` is read-only: it prints the desired payload, then reads the +# live protection with ONE GET (never PUT/PATCH) and prints an ok:/drift: line +# per required check and per setting. `--include-modeb` is kept so old runbooks +# keep working; it changes nothing, because modeb-linux is in the default list +# since #244. set -euo pipefail OWNER="choiyounggi" REPO="linkly" BRANCH="main" -INCLUDE_MODEB=0 CHECK_ONLY=0 for arg in "$@"; do case "$arg" in - --include-modeb) INCLUDE_MODEB=1 ;; + --include-modeb) ;; --check) CHECK_ONLY=1 ;; *) echo "unknown flag: $arg" >&2; exit 2 ;; esac done -CHECKS='{"context":"gate (py3.11)"},{"context":"gate (py3.12)"},{"context":"gate (py3.13)"},{"context":"lint (ruff)"}' -if [ "$INCLUDE_MODEB" -eq 1 ]; then - CHECKS="$CHECKS,{\"context\":\"modeb-linux (test_repo_state under real mode B)\"}" -fi +CHECKS='{"context":"gate (py3.11)"},{"context":"gate (py3.12)"},{"context":"gate (py3.13)"},{"context":"lint (ruff)"},{"context":"modeb-linux (test_repo_state under real mode B)"},{"context":"test-quality / gate"}' PAYLOAD=$(cat </dev/null 2>&1; then + echo "live: unavailable (python3 not found)" >&2 + exit 0 + fi + if ! command -v gh >/dev/null 2>&1; then + echo "live: unavailable (gh not found)" >&2 + exit 0 + fi + rc=0 + LIVE=$(gh api --method GET -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/$OWNER/$REPO/branches/$BRANCH/protection" 2>/dev/null) || rc=$? + if [ "$rc" -ne 0 ]; then + echo "live: unavailable (gh api exited $rc)" >&2 + exit 0 + fi + echo "--- live vs desired (GET /repos/$OWNER/$REPO/branches/$BRANCH/protection) ---" + crc=0 + DESIRED_JSON="$PAYLOAD" LIVE_JSON="$LIVE" python3 - <<'PY' || crc=$? +import json +import os +import sys + +desired = json.loads(os.environ["DESIRED_JSON"]) +try: + live = json.loads(os.environ["LIVE_JSON"]) +except ValueError: + live = None +if not isinstance(live, dict): + print("live: unavailable (unparseable response)", file=sys.stderr) + sys.exit(0) + + +def render(value): + if value is True: + return "true" + if value is False: + return "false" + if value is None: + return "null" + return "set" + + +def live_bool(key): + if key not in live: + return "absent" + value = live[key] + if isinstance(value, dict) and "enabled" in value: + return render(value["enabled"]) + return render(value) + + +lines = [] +want = [c["context"] for c in desired["required_status_checks"]["checks"]] +rsc = live.get("required_status_checks") or {} +have = [c.get("context") for c in rsc.get("checks", [])] +for ctx in want: + if ctx in have: + lines.append(("ok", 'check "%s"' % ctx)) + else: + lines.append(("drift", 'check "%s" missing live' % ctx)) +for ctx in have: + if ctx not in want: + lines.append(("drift", 'check "%s" live only (PUT would remove it)' % ctx)) +settings = [("strict", desired["required_status_checks"]["strict"], + render(rsc["strict"]) if "strict" in rsc else "absent")] +for key in ("enforce_admins", "required_conversation_resolution"): + settings.append((key, desired[key], live_bool(key))) +for key in ("required_pull_request_reviews", "restrictions"): + settings.append((key, desired[key], render(live.get(key)))) +for key in ("allow_force_pushes", "allow_deletions", "required_linear_history", + "block_creations", "lock_branch", "allow_fork_syncing"): + settings.append((key, desired[key], live_bool(key))) +for name, want_value, live_value in settings: + shown = render(want_value) + if shown == live_value: + lines.append(("ok", "%s=%s" % (name, shown))) + else: + lines.append(("drift", "%s desired=%s live=%s" % (name, shown, live_value))) +for kind, text in lines: + print("%s: %s" % (kind, text)) +drift = sum(1 for kind, _ in lines if kind == "drift") +print("summary: %d drift, %d ok" % (drift, len(lines) - drift)) +PY + if [ "$crc" -ne 0 ]; then + echo "live: unavailable (comparator exited $crc)" >&2 + fi exit 0 fi @@ -50,4 +139,4 @@ echo "$PAYLOAD" | gh api --method PUT \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/$OWNER/$REPO/branches/$BRANCH/protection" \ --input - -echo "setup_branch_protection: applied (include_modeb=$INCLUDE_MODEB)" +echo "setup_branch_protection: applied" From e3b1b98c1d3a154253b2e047bce185140568d57a Mon Sep 17 00:00:00 2001 From: Younggi Choi <74581798+choiyounggi@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:37:11 +0900 Subject: [PATCH 2/2] chore(release): cut v0.9.0 and enable PyPI Trusted Publishing (#245) Version 0.9.0 on every version-sync site, generated references and grammar schema regenerated. CHANGELOG [Unreleased] moved into [0.9.0] (62 bullets before and after; the 7 breaking entries name their contract). publish-pypi is enabled behind environment pypi with needs [build, release], id-token write only and a SHA-pinned action; gates now fails when the tag differs from the pyproject version. RELEASING.md documents the monthly cadence, the Trusted Publisher values and the failed-publish recovery. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Th13jVHptQQ1mCLah7XBay --- .claude-plugin/marketplace.json | 6 +- .github/workflows/release.yml | 41 +- CHANGELOG.md | 387 ++++++++++-------- docs/CI-GATES.md | 2 +- docs/RELEASING.md | 68 ++- impl/lnpl/__init__.py | 2 +- impl/tests/test_release_workflow.py | 182 +++++++- plugins/lnpl-dev/.claude-plugin/plugin.json | 2 +- plugins/lnpl-mcp/.claude-plugin/plugin.json | 2 +- plugins/lnpl/.claude-plugin/plugin.json | 2 +- .../lnpl-authoring/references/declarations.md | 2 +- .../lnpl-authoring/references/grammar.md | 2 +- .../lnpl-authoring/references/naming.md | 2 +- .../lnpl-authoring/references/patterns.md | 2 +- .../skills/lnpl-authoring/references/rfcs.md | 2 +- .../skills/lnpl-authoring/references/spec.md | 2 +- .../skills/lnpl-authoring/references/types.md | 2 +- .../skills/lnpl-authoring/references/verbs.md | 2 +- pyproject.toml | 2 +- schemas/lnpl-grammar.json | 2 +- 20 files changed, 492 insertions(+), 222 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5c689e06..67f0ead3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -17,7 +17,7 @@ }, "source": "./plugins/lnpl", "homepage": "https://github.com/choiyounggi/linkly", - "version": "0.8.0", + "version": "0.9.0", "tags": [ "lnpl", "linkly", @@ -37,7 +37,7 @@ }, "source": "./plugins/lnpl-dev", "homepage": "https://github.com/choiyounggi/linkly", - "version": "0.8.0", + "version": "0.9.0", "tags": [ "linkly", "contributor", @@ -56,7 +56,7 @@ }, "source": "./plugins/lnpl-mcp", "homepage": "https://github.com/choiyounggi/linkly", - "version": "0.8.0", + "version": "0.9.0", "tags": [ "lnpl", "linkly", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb600198..ababd773 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,11 @@ name: Release -# issue #141 phase 1: tag-triggered build + GitHub Release. PyPI publishing is -# explicitly out of scope for this phase (user decision, issue #141) — the -# `publish-pypi` job below is scaffolded but disabled (`if: false`) until a -# PyPI Trusted Publisher is registered for this project (see -# https://docs.pypi.org/trusted-publishers/using-a-publisher/). When that -# happens, also add `environment: pypi` to that job (PyPA recommends gating -# publish jobs behind a protected GitHub Environment) and drop the `if: false`. +# Tag-triggered release (issue #141; PyPI publishing enabled by issue #245): +# gates -> build -> release (GitHub Release) -> publish-pypi (PyPI through +# Trusted Publishing and the `pypi` environment), plus gates -> image (GHCR). +# gates first checks that the tag equals pyproject.toml's version. +# Registration values, the `pypi` environment prerequisite and the recovery +# when publish-pypi fails after the GitHub Release exists: docs/RELEASING.md. on: push: @@ -23,6 +22,15 @@ jobs: with: python-version: "3.12" + - name: Tag matches the pyproject version (issue #245) + run: | + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match pyproject.toml version $version" + exit 1 + fi + echo "tag $GITHUB_REF_NAME matches pyproject.toml version $version" + - name: Install run: python -m pip install --upgrade pip && pip install . @@ -212,15 +220,16 @@ jobs: sbom: true provenance: true - # Scaffolded, disabled: PyPI publishing needs a Trusted Publisher registered - # at https://pypi.org/manage/project//settings/publishing/ first - # (issue #141 explicitly excludes actual publishing from this phase). Once - # registered, flip `if: false` off and add `environment: pypi` above - # `runs-on` — PyPA recommends a protected environment for publish jobs. + # PyPI publishing through Trusted Publishing (issue #245): PyPI accepts this + # job's OIDC token for owner choiyounggi, repository linkly, workflow + # release.yml and environment pypi; the token permission below is the job's + # only one. needs: [build, release] keeps the irreversible upload last: a + # failed gates, build or release job skips this job, so PyPI never gets a + # version whose GitHub Release was not created. Recovery: docs/RELEASING.md. publish-pypi: - name: Publish to PyPI (disabled — issue #141 phase 1) - if: false - needs: build + name: Publish to PyPI (Trusted Publishing) + needs: [build, release] + environment: pypi runs-on: ubuntu-latest permissions: id-token: write # required for PyPI Trusted Publishing (OIDC) @@ -231,4 +240,4 @@ jobs: path: dist - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4f5f73..b50c92fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,184 +9,8 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). ## [Unreleased] -### Fixed -- `lnpl serve` / `build_app()` on a repository driver without the - idempotency claim methods (today `lnpl-postgres`) ignored an - `Idempotency-Key` header without warning, so a retried request ran its - workflow again. On such a driver a workflow request carrying the header - (M22) and every `POST /-/events/` delivery, whose CloudEvents `id` - is its key (E8), are now refused with 501 `idempotency-unsupported` - (problem+json) and nothing runs; workflow requests without the header - run as before. `lnpl serve` prints one boot line to stderr, - `lnpl serve: repository driver -- idempotency: - unsupported ...`, and the `GET /-/readyz` 200 body now carries - `"capabilities": {"idempotency": }` (information only, never a - 503). Requests on `--backend fake` are not refused (issue #228). -- `lnpl outbox drain`, `lnpl outbox ack` and `lnpl relay` crashed with an - `AttributeError` traceback on a persistent backend whose driver lacks - `drain_outbox`/`ack_outbox` (today `lnpl-postgres`). They now print one - stderr line, `error: backend does not implement the - outbox drain contract (drain_outbox/ack_outbox) — see docs/backends.md - §3`, close the driver and exit with rc 2 before any drain, ack or - publish call; `--backend fake` keeps its existing rc 2 refusal - (issue #229). -- `capability http`'s `retry`/`breaker`/`path` clauses were parsed and - compiled but silently dropped before reaching `HttpNetworkDriver` — both - `lnpl run`/`lnpl serve` (`cli.py:_open_endpoints`) and - `build_app()`/`make_wsgi_app` (`wsgi.py:_resolve_network`) projected a - declared capability down to `{"method", "auth"}` before handing it to - the driver. A declared `retry` never retried; a declared `path` made - any `call ... with ` fail with `"has path arguments but no path - declared"` (issue #176). -- OpenAPI generation crashed (`KeyError`) on any workflow using `create - as ` together with `respond ...` — - `_response_schema`'s `by_binding` map only ever held entities' own - default binding names, never an `as`-declared alias, so the RFC-0030 - golden example itself could not compile to an OpenAPI document, and - `lnpl serve` (which calls the same generator at startup) could not bind - either. `_response_schema` now also resolves a `respond` reference - against the workflow's own `create ... as` result bindings, and raises a - clear `OpenApiError` instead of a bare `KeyError` for any reference - resolving to neither (issue #173). -- `lnpl migrate`가 다개체(multi-entity) 모듈에서 `create`가 쓴 행을 조용히 - 건너뛰고 rc 0으로 "완료"를 보고하던 문제를 고쳤다 — `id` 필드를 선언하지 - 않은 entity의 행은 `create`가 자기 자신의 저장 키를 `id` 값으로 쓰는데, - migrate가 그 값으로 저장 키를 다시 계산하면 이중으로 접두되어 재조회가 - 실패했다. 이제 그 경우를 행 자신의 `id`로 재조회해 복구하고, 그래도 - 확인할 수 없는 행이나 후보가 있었는데 하나도 못 쓴 실행은 rc 2로 - 시끄럽게 실패한다 (issue #179). -- In-workflow write-state self-conflicts (issue #174): the default seed - rule now excludes an entity whose first repository operation is - `create` (previously seeded if it was ever read anywhere in the - workflow, even after being created first); `persist()` now advances the - bound row's optimistic-lock version after a successful write, so a - second `set` on the same binding in one run no longer raises a phantom - write conflict. -- `respond` naming a binding that a guard skipped, or a field the bound row - does not carry, crashed with a raw `KeyError` (CLI rc 1; `lnpl serve` - answered 500 with no `failed_step`). Such a reference is now omitted from - the response; an absent field raises one `respond-field-missing` warning - per reference, and when every reference is omitted the response is `{}`. - A new compile warning `guard-scoped-binding-escape` flags a `respond` or - `send` reference to a binding that only exists inside a guard. Existing - programs that crashed now answer; none that succeeded change (issue #198). -- `If-Match` ignored a `by ` lookup key: the precondition was checked - against the payload `id`, so a stale ETag on a row read by another key got - 200 instead of 412. It now evaluates the row the workflow's first read - addresses; a `by` ref that cannot be resolved from the request answers 400 - `precondition-unsupported` without running the workflow, and an absent - addressed row answers 412. Workflows without `by` behave as before - (issue #199). -- The default `Money` sample was `{"amount": "0", ...}`, which fails the - type's own codec (USD has two decimals), so the default `spec`/`run` - payload failed unconditionally. The sample is now - `{"amount": "1.00", "currency": "USD"}`; any golden or fixture that - pinned the old sample must be regenerated (issue #203). -- A `derived` field assigned with `set`/`format` earlier in the same guard - scope was still rejected by `emit ... with .`, with an - error text that contradicted RFC-0049. It is now accepted when the - assignment precedes the `emit` in the same scope, and otherwise rejected - naming the missing assignment and the `emit` line; RFC-0049 was corrected - in place (issue #204). -- Optimistic-lock write conflicts left the server as `500 workflow-failed`, - indistinguishable from a server fault. They now fail with - `failure_kind` `write-conflict` and answer `409 write-conflict` - (problem+json, with `failed_step`); the event-consume path answers 503 - with `Retry-After` and releases the claim. Clients that retried on 500 - should retry on 409 (issue #201). -- Fixed a self-conflict in the SQLite driver where calling `update` on a - bound row and then `set`ting the same row again failed with `"write - conflict"` (issue #182). -- Fixed `FakeRepository`'s `delete`, which unconditionally returned - `affected: 1` without removing the row — it now actually deletes the - row and returns `affected: 0` for a missing row. A workflow that reads - the same row after `delete` now gets the same result on Fake and - SQLite (issue #183). -- Fixed `lnpl diff` (`differential.verify()`) and `lnpl build` reporting - different RFCs as the first rejection reason for a workflow that - combines two or more of a Money guard, a lookup key (`by `), and - a numeric-shape predicate (`is-numeric`/`is-not-numeric`) — - `differential.verify()`'s check order is now unified with - `backend.build()`/`backend.emit_mlir()`'s Money (RFC-0051) → lookup - key (RFC-0052) → numeric predicate (RFC-0050) order. Two combinations - change: for a Money + numeric-predicate program, `diff`'s first report - moves from RFC-0050 to RFC-0051; for a lookup-key + numeric-predicate - program, from RFC-0050 to RFC-0052. Both still reject with rc 4 — only - the reason changes (issue #185). -- Fixed `lnpl build`-ing a workflow that uses a numeric-shape predicate - (`is-numeric`/`is-not-numeric`) in an environment without MLIR/LLVM - tools reporting a tool-missing error (`"mlir-opt not found ..."`) - before the RFC-0050 rejection — `build()` now rejects the numeric - predicate early, in the same place as the Money/lookup-key rejection - (#181), right before `verify_lnpl_module()` (the tool lookup). This - early check sees the ops stream truncated by `_lnpl_ops`'s - seed/payload, so a guard step that comes after an unseeded read and - still succeeds today (`seeded=frozenset()`) still succeeds — only the - case where the rejection used to come after the tool error changes - (issue #186). -- `build_app()` (the gunicorn operations path) now accepts four - operational options that previously existed only for `lnpl serve`: - `LNPL_METRICS`, `LNPL_CAPTURE_ON_FAILURE`, `LNPL_TRUST_INCOMING_TRACE` - (boolean — `1`/`true`/`yes`/`on`, `0`/`false`/`no`/`off`), and - `LNPL_RATE_LIMIT` (a finite number greater than 0). An argument of the - same name (e.g. `metrics=`) is also accepted, and an explicit argument - wins over the environment variable. An empty string means unset, and - an invalid value fails startup with a `WsgiConfigError` naming the - variable. readyz check ③ (`jwt-secret-env`) now also runs on the - `build_app()` path — it used to be skipped even when - `LNPL_JWT_SECRET_ENV` was given. `lnpl serve --rate-limit nan`/`inf` - is now rejected with the same wording as `0` (rc 2). - `impl/tests/test_build_app_serve_parity.py` enforces that every - `serve` option has a `build_app()` counterpart (issue #187). -- The gunicorn `build_app()` path now accepts - `--cache`/`--network`/`--token-provider`/`--jwt-issuer`/`--config`/`--profile` - under the same rules as `lnpl serve` — the - `LNPL_CACHE`/`LNPL_NETWORK`/`LNPL_TOKEN_PROVIDER`/`LNPL_JWT_ISSUER`/`LNPL_CONFIG`/`LNPL_PROFILE` - environment variables, or an argument of the same name. The - startup-failure message for an unknown `LNPL_BACKEND` value no longer - includes the value (issue #187). -- A service whose `security` block declared `role ` without `jwt` - compiled, and `lnpl serve` then answered 200 to a request with no - Authorization header, because route authentication keys on `jwt` alone - and the role check was never reached. Such a service is now rejected at - compile time with a `LowerError` whose message leads with - `role-requires-jwt`: `lnpl compile` exits 2, `build_app()` refuses to - start, and the MCP compile response is `isError`. `jwt` together with - `role` still answers 401/403 as before. Compatibility: a document that - declared `role` without `jwt` compiled before and no longer compiles; - add `jwt` to that service's `security` block (issue #213). -- `lnpl serve` listened with socketserver's default backlog of 5, so a - burst of more than about 6 connections arriving while the accept loop - was busy had its extra SYNs dropped, and those clients waited 1 s or - more to retry. The backlog is now 128 (the kernel still caps it at - `somaxconn`). Production serving through gunicorn is unaffected - (issue #226). -- `lnpl relay` drained the outbox outside any transaction, so on a driver - whose `drain_outbox` locks rows with `SELECT ... FOR UPDATE SKIP LOCKED` - (today `lnpl-postgres`) the locks ended with the statement and two - relays could publish the same row. Each relay batch (drain, publish or - POST, ack) now runs inside one driver transaction: `begin()` before the - drain, `commit()` after the batch, `rollback()` when an ack failure, an - unexpected publisher error or an interrupt escapes it; a row whose - publish fails stays un-acked and the batch still commits. A second relay - skips rows the first one holds until its commit (exactly-once per batch - on row-locking drivers; end-to-end delivery stays at-least-once). - Behaviour on sqlite is unchanged (issue #257). -- `lnpl serve` / `build_app()`: an `Idempotency-Key` header longer than - 255 bytes reached the repository driver, and on `lnpl-postgres` (whose - `lnpl_idempotency (workflow_id, key)` btree index cannot hold a key of - about 4000 characters) the request failed with an unhandled 500. Such a - request is now refused with 400 `idempotency-key-too-long` - (problem+json, M23) before any driver is opened, on every backend; keys - of up to 255 bytes, an empty key and requests without the header behave - as before. Separately, when the workflow behind `POST /-/events/` - raised an exception `run_workflow` does not catch, the claim on the - CloudEvents `id` was released while the workflow transaction was still - open, so the Postgres driver had to wait for a second pool connection - and sqlite committed the failed run's partial writes together with the - release. The open transaction is now rolled back first, so the key is - free at once and no partial write survives; if that rollback itself - fails, the claim is kept until its TTL instead (issue #258). +## [0.9.0] — 2026-10-22 +"The operations-hardening release." ### Added - `RepositoryDriver.supports_idempotency()` and @@ -391,6 +215,18 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). (issue #252). ### Changed +- A service whose `security` block declared `role ` without `jwt` + compiled, and `lnpl serve` then answered 200 to a request with no + Authorization header, because route authentication keys on `jwt` alone + and the role check was never reached. Such a service is now rejected at + compile time with a `LowerError` whose message leads with + `role-requires-jwt`: `lnpl compile` exits 2, `build_app()` refuses to + start, and the MCP compile response is `isError`. `jwt` together with + `role` still answers 401/403 as before. Compatibility: a document that + declared `role` without `jwt` compiled before and no longer compiles; + add `jwt` to that service's `security` block (issue #213). + Contract: docs/compatibility.md §5 CLI 표면 — `lnpl compile` now rejects + (exit 2) a document it accepted before. - `update`/`delete` (bare or `by `) that affects no row now fails its step with `failure_kind` `not-found` instead of completing: `lnpl serve` answers `404 not-found` with `failed_step` (consume path: 422 @@ -402,6 +238,9 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). entity is `update`/`delete` now fails on the default `fake` seed too. Delete fails by default; no idempotent-delete notation is offered (issue #215, RFC-0064). + Contract: RFC-0064 owns this language rule (docs/compatibility.md, + "이 문서가 다루지 않는 것"); none of the doc's five contracts lists it — it shows as + `lnpl serve` answering 404 where the request completed before. - Persistent backends (sqlite, postgres) are no longer seeded from the request payload, so a read of a missing row no longer stores a phantom row. The step now fails with `failure_kind` `not-found` and `lnpl serve` @@ -411,6 +250,10 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). a program that relied on a read miss succeeding on a persistent backend now gets 404 and must create the row first (issue #197, RFC-0052 §4 corrected in place). + Contract: RFC-0052 owns this language rule (docs/compatibility.md, + "이 문서가 다루지 않는 것"); none of the doc's five contracts lists it — on sqlite + and postgres it shows as `lnpl serve` answering 404 where it stored a + phantom row and succeeded before. - `derived generated` (a per-run UUIDv4 id) and `derived clock` (the run's start instant) mark entity fields the server fills at `create`/`insert`; `spec` pins them with `given run.id` / `given run.clock`. A `create` or @@ -421,6 +264,10 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). collide on one key from the second run; it now fails on the first, so add `derived generated` to the id or send an id. Mode B refuses the markers (issue #209, RFC-0055). + Contract: RFC-0055 owns this language rule (docs/compatibility.md, + "이 문서가 다루지 않는 것"); none of the doc's five contracts lists it — it shows as + `lnpl serve` answering 400 `id-required` to an id-less `create`/`insert` + that ran before. - Two structural changes to flow control, with one parse-time and one runtime consequence. (a) A control keyword (`when`, `until`, `repeat`, `pipeline`, `parallel`) indented inside an open `pipeline` body is now a @@ -438,6 +285,10 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). reading an assigned field used to be a compile error and is now accepted in mode A, so a program that dodged the error by reordering still works; a mode-B build of a workflow that uses either form is now refused. + Contract: docs/compatibility.md §5 CLI 표면 — `lnpl compile` now rejects a + document that indented a control keyword inside an open `pipeline` body + and compiled before; the language rules belong to RFC-0058 and RFC-0060 + (docs/compatibility.md, "이 문서가 다루지 않는 것"). - Formalized the position that rate limiting is a single linkly process's own defense, which loosens by N × K across instances and workers — a global or per-client limit is the gateway's job. Added a @@ -456,6 +307,10 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). sends a byte-identical body. Mode B is unchanged (it builds no call body). This replaces the earlier note that a call without `send` is byte-identical to before (issue #200, RFC-0057). + Contract: RFC-0063 owns this language rule (docs/compatibility.md, + "이 문서가 다루지 않는 것"); none of the doc's five contracts lists it — in mode A + it shows as the outbound body of a `call`/`request` without `send` no + longer carrying Password-family keys. - Compatibility (issues #218, #219; RFC-0049, RFC-0057 and RFC-0063 amended): the compile-time Password-family refusal now judges an `input.` (or bare) reference over every entity that declares @@ -468,6 +323,10 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). the request URL path. A document that compiled only through one of these gaps now fails with `LowerError`; references to fields no entity declares Password-family compile exactly as before, with an unchanged IR. + Contract: docs/compatibility.md §5 CLI 표면 — `lnpl compile` now rejects a + document that compiled only through these gaps; the language rules belong + to RFC-0049, RFC-0057 and RFC-0063 (docs/compatibility.md, + "이 문서가 다루지 않는 것"). - RFC-0049 through RFC-0064, whose implementations and tests are already in this release, move from `Draft` to `Accepted` (RFC-0007 §2.1), and `scripts/rfc_lint.py` now fails when `CHANGELOG.md` cites @@ -491,6 +350,175 @@ see [docs/compatibility.md](docs/compatibility.md) for what 0.x guarantees). and `enforce_admins`, no pull request could ever be approved (issue #244). +### Fixed +- `lnpl serve` / `build_app()` on a repository driver without the + idempotency claim methods (today `lnpl-postgres`) ignored an + `Idempotency-Key` header without warning, so a retried request ran its + workflow again. On such a driver a workflow request carrying the header + (M22) and every `POST /-/events/` delivery, whose CloudEvents `id` + is its key (E8), are now refused with 501 `idempotency-unsupported` + (problem+json) and nothing runs; workflow requests without the header + run as before. `lnpl serve` prints one boot line to stderr, + `lnpl serve: repository driver -- idempotency: + unsupported ...`, and the `GET /-/readyz` 200 body now carries + `"capabilities": {"idempotency": }` (information only, never a + 503). Requests on `--backend fake` are not refused (issue #228). +- `lnpl outbox drain`, `lnpl outbox ack` and `lnpl relay` crashed with an + `AttributeError` traceback on a persistent backend whose driver lacks + `drain_outbox`/`ack_outbox` (today `lnpl-postgres`). They now print one + stderr line, `error: backend does not implement the + outbox drain contract (drain_outbox/ack_outbox) — see docs/backends.md + §3`, close the driver and exit with rc 2 before any drain, ack or + publish call; `--backend fake` keeps its existing rc 2 refusal + (issue #229). +- `capability http`'s `retry`/`breaker`/`path` clauses were parsed and + compiled but silently dropped before reaching `HttpNetworkDriver` — both + `lnpl run`/`lnpl serve` (`cli.py:_open_endpoints`) and + `build_app()`/`make_wsgi_app` (`wsgi.py:_resolve_network`) projected a + declared capability down to `{"method", "auth"}` before handing it to + the driver. A declared `retry` never retried; a declared `path` made + any `call ... with ` fail with `"has path arguments but no path + declared"` (issue #176). +- OpenAPI generation crashed (`KeyError`) on any workflow using `create + as ` together with `respond ...` — + `_response_schema`'s `by_binding` map only ever held entities' own + default binding names, never an `as`-declared alias, so the RFC-0030 + golden example itself could not compile to an OpenAPI document, and + `lnpl serve` (which calls the same generator at startup) could not bind + either. `_response_schema` now also resolves a `respond` reference + against the workflow's own `create ... as` result bindings, and raises a + clear `OpenApiError` instead of a bare `KeyError` for any reference + resolving to neither (issue #173). +- `lnpl migrate`가 다개체(multi-entity) 모듈에서 `create`가 쓴 행을 조용히 + 건너뛰고 rc 0으로 "완료"를 보고하던 문제를 고쳤다 — `id` 필드를 선언하지 + 않은 entity의 행은 `create`가 자기 자신의 저장 키를 `id` 값으로 쓰는데, + migrate가 그 값으로 저장 키를 다시 계산하면 이중으로 접두되어 재조회가 + 실패했다. 이제 그 경우를 행 자신의 `id`로 재조회해 복구하고, 그래도 + 확인할 수 없는 행이나 후보가 있었는데 하나도 못 쓴 실행은 rc 2로 + 시끄럽게 실패한다 (issue #179). +- In-workflow write-state self-conflicts (issue #174): the default seed + rule now excludes an entity whose first repository operation is + `create` (previously seeded if it was ever read anywhere in the + workflow, even after being created first); `persist()` now advances the + bound row's optimistic-lock version after a successful write, so a + second `set` on the same binding in one run no longer raises a phantom + write conflict. +- `respond` naming a binding that a guard skipped, or a field the bound row + does not carry, crashed with a raw `KeyError` (CLI rc 1; `lnpl serve` + answered 500 with no `failed_step`). Such a reference is now omitted from + the response; an absent field raises one `respond-field-missing` warning + per reference, and when every reference is omitted the response is `{}`. + A new compile warning `guard-scoped-binding-escape` flags a `respond` or + `send` reference to a binding that only exists inside a guard. Existing + programs that crashed now answer; none that succeeded change (issue #198). +- `If-Match` ignored a `by ` lookup key: the precondition was checked + against the payload `id`, so a stale ETag on a row read by another key got + 200 instead of 412. It now evaluates the row the workflow's first read + addresses; a `by` ref that cannot be resolved from the request answers 400 + `precondition-unsupported` without running the workflow, and an absent + addressed row answers 412. Workflows without `by` behave as before + (issue #199). +- The default `Money` sample was `{"amount": "0", ...}`, which fails the + type's own codec (USD has two decimals), so the default `spec`/`run` + payload failed unconditionally. The sample is now + `{"amount": "1.00", "currency": "USD"}`; any golden or fixture that + pinned the old sample must be regenerated (issue #203). +- A `derived` field assigned with `set`/`format` earlier in the same guard + scope was still rejected by `emit ... with .`, with an + error text that contradicted RFC-0049. It is now accepted when the + assignment precedes the `emit` in the same scope, and otherwise rejected + naming the missing assignment and the `emit` line; RFC-0049 was corrected + in place (issue #204). +- Optimistic-lock write conflicts left the server as `500 workflow-failed`, + indistinguishable from a server fault. They now fail with + `failure_kind` `write-conflict` and answer `409 write-conflict` + (problem+json, with `failed_step`); the event-consume path answers 503 + with `Retry-After` and releases the claim. Clients that retried on 500 + should retry on 409 (issue #201). +- Fixed a self-conflict in the SQLite driver where calling `update` on a + bound row and then `set`ting the same row again failed with `"write + conflict"` (issue #182). +- Fixed `FakeRepository`'s `delete`, which unconditionally returned + `affected: 1` without removing the row — it now actually deletes the + row and returns `affected: 0` for a missing row. A workflow that reads + the same row after `delete` now gets the same result on Fake and + SQLite (issue #183). +- Fixed `lnpl diff` (`differential.verify()`) and `lnpl build` reporting + different RFCs as the first rejection reason for a workflow that + combines two or more of a Money guard, a lookup key (`by `), and + a numeric-shape predicate (`is-numeric`/`is-not-numeric`) — + `differential.verify()`'s check order is now unified with + `backend.build()`/`backend.emit_mlir()`'s Money (RFC-0051) → lookup + key (RFC-0052) → numeric predicate (RFC-0050) order. Two combinations + change: for a Money + numeric-predicate program, `diff`'s first report + moves from RFC-0050 to RFC-0051; for a lookup-key + numeric-predicate + program, from RFC-0050 to RFC-0052. Both still reject with rc 4 — only + the reason changes (issue #185). +- Fixed `lnpl build`-ing a workflow that uses a numeric-shape predicate + (`is-numeric`/`is-not-numeric`) in an environment without MLIR/LLVM + tools reporting a tool-missing error (`"mlir-opt not found ..."`) + before the RFC-0050 rejection — `build()` now rejects the numeric + predicate early, in the same place as the Money/lookup-key rejection + (#181), right before `verify_lnpl_module()` (the tool lookup). This + early check sees the ops stream truncated by `_lnpl_ops`'s + seed/payload, so a guard step that comes after an unseeded read and + still succeeds today (`seeded=frozenset()`) still succeeds — only the + case where the rejection used to come after the tool error changes + (issue #186). +- `build_app()` (the gunicorn operations path) now accepts four + operational options that previously existed only for `lnpl serve`: + `LNPL_METRICS`, `LNPL_CAPTURE_ON_FAILURE`, `LNPL_TRUST_INCOMING_TRACE` + (boolean — `1`/`true`/`yes`/`on`, `0`/`false`/`no`/`off`), and + `LNPL_RATE_LIMIT` (a finite number greater than 0). An argument of the + same name (e.g. `metrics=`) is also accepted, and an explicit argument + wins over the environment variable. An empty string means unset, and + an invalid value fails startup with a `WsgiConfigError` naming the + variable. readyz check ③ (`jwt-secret-env`) now also runs on the + `build_app()` path — it used to be skipped even when + `LNPL_JWT_SECRET_ENV` was given. `lnpl serve --rate-limit nan`/`inf` + is now rejected with the same wording as `0` (rc 2). + `impl/tests/test_build_app_serve_parity.py` enforces that every + `serve` option has a `build_app()` counterpart (issue #187). +- The gunicorn `build_app()` path now accepts + `--cache`/`--network`/`--token-provider`/`--jwt-issuer`/`--config`/`--profile` + under the same rules as `lnpl serve` — the + `LNPL_CACHE`/`LNPL_NETWORK`/`LNPL_TOKEN_PROVIDER`/`LNPL_JWT_ISSUER`/`LNPL_CONFIG`/`LNPL_PROFILE` + environment variables, or an argument of the same name. The + startup-failure message for an unknown `LNPL_BACKEND` value no longer + includes the value (issue #187). +- `lnpl serve` listened with socketserver's default backlog of 5, so a + burst of more than about 6 connections arriving while the accept loop + was busy had its extra SYNs dropped, and those clients waited 1 s or + more to retry. The backlog is now 128 (the kernel still caps it at + `somaxconn`). Production serving through gunicorn is unaffected + (issue #226). +- `lnpl relay` drained the outbox outside any transaction, so on a driver + whose `drain_outbox` locks rows with `SELECT ... FOR UPDATE SKIP LOCKED` + (today `lnpl-postgres`) the locks ended with the statement and two + relays could publish the same row. Each relay batch (drain, publish or + POST, ack) now runs inside one driver transaction: `begin()` before the + drain, `commit()` after the batch, `rollback()` when an ack failure, an + unexpected publisher error or an interrupt escapes it; a row whose + publish fails stays un-acked and the batch still commits. A second relay + skips rows the first one holds until its commit (exactly-once per batch + on row-locking drivers; end-to-end delivery stays at-least-once). + Behaviour on sqlite is unchanged (issue #257). +- `lnpl serve` / `build_app()`: an `Idempotency-Key` header longer than + 255 bytes reached the repository driver, and on `lnpl-postgres` (whose + `lnpl_idempotency (workflow_id, key)` btree index cannot hold a key of + about 4000 characters) the request failed with an unhandled 500. Such a + request is now refused with 400 `idempotency-key-too-long` + (problem+json, M23) before any driver is opened, on every backend; keys + of up to 255 bytes, an empty key and requests without the header behave + as before. Separately, when the workflow behind `POST /-/events/` + raised an exception `run_workflow` does not catch, the claim on the + CloudEvents `id` was released while the workflow transaction was still + open, so the Postgres driver had to wait for a second pool connection + and sqlite committed the failed run's partial writes together with the + release. The open transaction is now rolled back first, so the key is + free at once and no partial write survives; if that rollback itself + fails, the claim is kept until its TTL instead (issue #258). + ## [0.8.0] — 2026-09-02 "The Money-contract release." The RFC-0044/0045 designs accepted in 0.7.0 now reach the last two places they had not: `spec` blocks can seed and @@ -776,7 +804,8 @@ when its condition became true (fixed in #5, after this tag). RFC-0008 G8's condition-field plumbing was only correct for exactly two condition fields (fixed in #4, after this tag). -[Unreleased]: https://github.com/choiyounggi/linkly/compare/v0.8.0...HEAD +[Unreleased]: https://github.com/choiyounggi/linkly/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/choiyounggi/linkly/releases/tag/v0.9.0 [0.8.0]: https://github.com/choiyounggi/linkly/releases/tag/v0.8.0 [0.7.0]: https://github.com/choiyounggi/linkly/releases/tag/v0.7.0 [0.6.0]: https://github.com/choiyounggi/linkly/releases/tag/v0.6.0 diff --git a/docs/CI-GATES.md b/docs/CI-GATES.md index ee98353f..594ca4a9 100644 --- a/docs/CI-GATES.md +++ b/docs/CI-GATES.md @@ -23,7 +23,7 @@ AI 게이트가 산문이 아니라 스키마 검증으로 판정되는 배관(` | `golden-approval` | AI | `_ai-gate.yml`(t4) | advisory | 골든 시나리오 변경의 타당성 | | `test-quality` | AI | `_ai-gate.yml`(t5) | blocking + required (2026-10-22 승격 — 승격 기준 예외, 아래 "advisory → blocking 승격 절차") | 자기 코드를 자기가 평가하는 테스트(구현/평가 미분리), 가짜 통과 | | `prose-factcheck` | AI | `_ai-gate.yml`(t5) | advisory | PR 산문(설명·주석)이 실제 diff와 어긋나는 주장 | -| 컨테이너 이미지 배선(`test_release_workflow.py`의 `image` 잡 단언) | 결정론 | `release.yml`(태그 push) | blocking(릴리스 전용) | `image` 잡 누락, smoke-before-push 순서, 태그 목록(`vX.Y.Z`+`X.Y`, `latest` 없음), `run:` 블록의 `github.` 식 금지, `permissions` 범위, `publish-pypi` 비활성 유지 회귀 | +| 릴리스 워크플로 배선(`test_release_workflow.py`의 `image`·`publish-pypi` 잡과 태그·버전 가드 단언) | 결정론 | `release.yml`(태그 push) | blocking(릴리스 전용) | `image` 잡 누락, smoke-before-push 순서, 태그 목록(`vX.Y.Z`+`X.Y`, `latest` 없음), `run:` 블록의 `github.` 식 금지, `permissions` 범위, `publish-pypi` 배선 회귀(`environment: pypi`, `id-token: write` 단독 권한, `needs: [build, release]`, `if: false` 부활, `skip-existing` 추가, 액션 SHA 핀), `gates`의 태그·`pyproject.toml` 버전 불일치 미검출 | | 요청당 결정적 카운터(`test_perf_counters.py`) | 결정론 | `ci.yml` — 테스트 스위트(matrix) 잡 안의 한 모듈 | blocking(`gate` 잡 경유) | 요청 1건당 DB 연결 생성·닫힘 수, 저장소 문장 수, 드라이버 생성 수의 회귀(예: 요청마다 연결을 하나 더 여는 변경) | | 주간 부하 리포트(`load-weekly.yml`) | 결정론 | `load-weekly.yml` — `schedule`(월요일 07:00 UTC) + `workflow_dispatch` | report-only(비-required) | 절대 처리량·지연 추이(gunicorn sync 워커 2개, fake·sqlite 백엔드, 50/100/200/400 rps). 판정으로 쓰지 않는다 | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1c085447..bd5e59be 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,6 +6,11 @@ PR 게이트와 태그 릴리스는 `.github/workflows/ci.yml`·`release.yml`로 CHANGELOG, 릴리스 노트 본문)와, 자동화가 실패했을 때의 로컬 재현·수동 폴백을 순서대로 고정한다. +## 릴리스 주기 + +매월 1회 릴리스한다. 그와 별개로, P0 수정이 `main`에 머지되면 다음 달을 +기다리지 않고 바로 릴리스한다(issue #245). + ## 절차 1. **완료 게이트를 통과시킨다.** PR과 `main` push마다 `ci.yml`이 자동으로 @@ -48,8 +53,12 @@ CHANGELOG, 릴리스 노트 본문)와, 자동화가 실패했을 때의 로컬 git tag vX.Y.Z git push origin vX.Y.Z ``` - `release.yml`이 게이트 재실행 → `python -m build`로 sdist+wheel 빌드 → - `gh release create`까지 수행한다. 같은 태그 push가 `image` 잡도 깨운다 + `release.yml`이 태그와 `pyproject.toml` 버전 일치 확인·게이트 재실행 → + `python -m build`로 sdist+wheel 빌드 → `gh release create` → + `publish-pypi`(PyPI 업로드)까지 수행한다. `publish-pypi`는 `gates`· + `build`·`release`가 모두 성공했을 때만 돈다(`needs: [build, release]`). + 첫 태그 전에 아래 "PyPI 발행" 절의 사전 조건을 갖춘다. 같은 태그 push가 + `image` 잡도 깨운다 (issue #190): `docker/Dockerfile`로 런타임 전용 이미지를 빌드하고, 마운트한 소스로 `/-/healthz` 200을 받는 스모크를 통과한 뒤에만 `ghcr.io//linkly`에 `vX.Y.Z`와 `X.Y` 두 태그로 push한다 @@ -77,8 +86,7 @@ CHANGELOG, 릴리스 노트 본문)와, 자동화가 실패했을 때의 로컬 (`>=3.2,<3.3`)로 맞춘다 — 비워 두면 최신 3.3.x가 설치되어 의존성 충돌 경고가 난다. 위 레시피는 로컬에서 실측했다(`docker build` 성공, 설치 후 `lnpl` 버전 불변, `/-/healthz` 여전히 200). PyPI - 발행은 아직 비활성이다(워크플로의 `publish-pypi` 잡 주석 참고 — - Trusted Publisher 미등록, 사용자 결정으로 이번 단계 범위 밖). + 발행의 사전 조건과 실패 복구는 아래 "PyPI 발행" 절을 본다. 자동화가 실패하면 수동으로: ``` gh release create vX.Y.Z --title "linkly vX.Y.Z — <한 줄 테마>" \ @@ -89,6 +97,58 @@ CHANGELOG, 릴리스 노트 본문)와, 자동화가 실패했을 때의 로컬 틀린 근거가 된다(`gh release view vX.Y.Z`가 CHANGELOG 소급의 유일한 근거이기 때문). +## PyPI 발행 (Trusted Publishing) + +### 사전 조건 (첫 발행 전 1회) + +1. PyPI에 `lnpl`의 pending publisher를 등록한다. pypi.org에 로그인해 + https://pypi.org/manage/account/publishing/ 에서 GitHub 탭을 열고, 아래 + 값을 입력해 pending publisher를 추가한다. + + | 항목 | 값 | + |------|-----| + | PyPI Project Name | `lnpl` | + | Owner | `choiyounggi` | + | Repository name | `linkly` | + | Workflow name | `release.yml` | + | Environment name | `pypi` | + + pending publisher는 첫 업로드 전까지 이름을 예약하지 않으며, 그 전에 다른 + 사람이 `lnpl`을 먼저 등록하면 무효가 된다. 그러므로 첫 태그를 push하기 + 직전에 `curl -s -o /dev/null -w '%{http_code}' https://pypi.org/pypi/lnpl/json` + 이 여전히 `404`를 찍어야 한다. 근거: + https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/ + +2. 태그를 push하기 전에 GitHub `pypi` environment를 만든다. 저장소 + Settings → Environments → New environment에서 이름을 `pypi`로 하고, + Deployment branches and tags → Selected branches and tags에 태그 규칙 + `v*`를 추가한다. 이유: 존재하지 않는 environment를 참조한 워크플로는 + 보호 규칙이 없는 environment를 새로 만든다. 근거: + https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments + +3. 잡 로그에 `invalid-publisher`가 보이면 PyPI 쪽과 GitHub 쪽의 owner, + repository, 워크플로 파일명, environment 이름을 서로 맞춰 본다. 근거: + https://docs.pypi.org/trusted-publishers/troubleshooting/ + +### publish-pypi가 실패했을 때 + +이 시점에 GitHub Release는 이미 존재한다. PyPI는 삭제한 뒤에도 같은 파일명을 +다시 받지 않는다(근거: https://pypi.org/help/#file-name-reuse). + +| PyPI 상태와 원인 | 확인 | 조치 | +|------------------|------|------| +| 아무 파일도 없음, 외부 원인(로그에 `invalid-publisher`/`invalid-pending-publisher`, `pypi` environment 없음 또는 이름 불일치) | `curl -s -o /dev/null -w '%{http_code}' https://pypi.org/pypi/lnpl/X.Y.Z/json` → `404`, 잡 로그 | PyPI publisher 또는 GitHub environment 설정을 고친 뒤 `gh run rerun --failed`(30일 이내; 같은 SHA·ref로 실패한 잡만 다시 돈다 — 저장소 변경이 필요 없을 때만 유효) | +| 아무 파일도 없음, 저장소 쪽 원인(액션 핀이나 그 이미지 실패, release.yml 오류, 액션 기본값 `verify-metadata`(twine check)가 메타데이터 거부) | 같은 `404`, 잡 로그 | `main`에 PR로 고친 뒤 다음 patch 버전(`X.Y.(Z+1)`)을 이 문서 절차 전체로 릴리스한다. 재실행은 같은 커밋을 다시 쓰므로 고친 내용이 반영되지 않고, 재태그는 금지다. 새 patch 절의 `### Fixed`에 원인과 "X.Y.Z는 PyPI에 올라가지 않았다"를 적는다 | +| 일부 파일만 있음 | https://pypi.org/project/lnpl/X.Y.Z/#files | 2행과 같이 다음 patch 버전 | +| 모든 파일 있음(업로드 뒤 잡 실패) | 같은 파일 목록 | 발행 완료로 보고 `pip install lnpl==X.Y.Z`로 확인한다 | + +태그를 지우거나 다시 push하지 않는다, GitHub Release를 지우지 않는다, 같은 +버전을 다시 올리지 않는다. + +태그가 `pyproject.toml` 버전과 달라 `gates`의 태그·버전 확인 단계에서 멈췄다면 GitHub Release도 PyPI 업로드도 없다. 그래도 태그를 지우거나 다시 push하지 않으므로 그 버전 번호는 버려진다 — `main`에서 버전을 고친 뒤 다음 patch 버전으로 릴리스한다. + +근거: 재실행 https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs ("up to 30 days", 같은 `GITHUB_SHA`·`GITHUB_REF`); 아티팩트 https://github.com/actions/download-artifact ("the current workflow run"); `verify-metadata` 기본값 https://github.com/pypa/gh-action-pypi-publish + ## 참고 - 컨테이너 이미지는 이제 `release.yml`의 `image` 잡이 같은 태그 push로 diff --git a/impl/lnpl/__init__.py b/impl/lnpl/__init__.py index 3f3f0825..d08b049b 100644 --- a/impl/lnpl/__init__.py +++ b/impl/lnpl/__init__.py @@ -9,4 +9,4 @@ *generated* OpenAPI document. """ -__version__ = "0.8.0" +__version__ = "0.9.0" diff --git a/impl/tests/test_release_workflow.py b/impl/tests/test_release_workflow.py index fab81f7e..57c81bf4 100644 --- a/impl/tests/test_release_workflow.py +++ b/impl/tests/test_release_workflow.py @@ -9,6 +9,8 @@ import os import re import subprocess +import sys +import tempfile import unittest REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -37,6 +39,70 @@ def _image_job_text(): return text[start:end] +def _job_text(text, job): + """`job`'s own text: from its two-space `:` key line to the next + two-space job key, or to end of file when it is the last job (issue + #245). Two-space comment lines never match the job-key pattern. + """ + start = text.index("\n %s:\n" % job) + nxt = re.search(r"\n [A-Za-z0-9_-]+:\n", text[start + 1:]) + if nxt is None: + return text[start:] + return text[start:start + 1 + nxt.start()] + + +def _publish_wiring_problems(job_text): + """Every way `job_text` differs from the enabled publish-pypi wiring + (issue #245), as fixed strings in a fixed order. Exact four-space + indents: a key moved under a step (eight spaces) does not count. + """ + problems = [] + if "\n if:" in job_text: + problems.append("job-level if present") + if "\n environment: pypi\n" not in job_text: + problems.append("environment is not pypi") + if "\n needs: [build, release]\n" not in job_text: + problems.append("needs is not [build, release]") + if re.search(r"\n permissions:\n id-token: write[^\n]*\n [a-z]", job_text) is None: + problems.append("permissions is not exactly id-token: write") + if re.search(r"uses: pypa/gh-action-pypi-publish@[0-9a-f]{40} # v\d+\.\d+\.\d+$", + job_text, re.M) is None: + problems.append("publish action is not SHA-pinned") + if "skip-existing" in job_text: + problems.append("skip-existing is set") + return problems + + +def _tag_guard_script(): + """The `gates` job's tag/version guard step script, dedented the way + `_computed_tags` dedents the image job's script (issue #245). + """ + job_text = _job_text(_read(), "gates") + marker = (" - name: Tag matches the pyproject version (issue #245)\n" + " run: |\n") + start = job_text.index(marker) + len(marker) + end = job_text.index("\n\n - ", start) + return "\n".join(line[10:] for line in job_text[start:end].split("\n")) + "\n" + + +def _run_tag_guard(tag, version): + """Run the guard step under `bash -e` (the runner's command for a step + without `shell:`) next to a synthetic pyproject.toml; return (rc, stdout). + """ + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, "pyproject.toml"), "w", encoding="utf-8") as fh: + fh.write('[project]\nname = "lnpl"\nversion = "%s"\n' % version) + with open(os.path.join(tmp, "step.sh"), "w", encoding="utf-8") as fh: + fh.write(_tag_guard_script()) + proc = subprocess.run( + ["bash", "-e", "step.sh"], cwd=tmp, + env={"PATH": os.path.dirname(sys.executable) + os.pathsep + os.environ["PATH"], + "GITHUB_REF_NAME": tag}, + capture_output=True, text=True, + ) + return proc.returncode, proc.stdout + + def _computed_tags(tag, owner): """Run the `Compute image tags` step's own script under bash and return the tag lines it writes to `$GITHUB_OUTPUT` — text presence alone cannot @@ -144,11 +210,6 @@ def test_permissions_are_exactly_contents_read_and_packages_write(self): self.assertNotIn("id-token:", _image_job_text()) self.assertNotIn("attestations:", _image_job_text()) - def test_publish_pypi_still_disabled(self): - text = _read() - start = text.index("\n publish-pypi:\n") - self.assertIn("if: false", text[start:]) - def test_dockerfile_path_and_digest_pin_referenced(self): self.assertIn("docker/Dockerfile", _image_job_text()) dockerfile_text = _read_dockerfile() @@ -171,5 +232,116 @@ def test_sbom_and_provenance_attestations_requested(self): self.assertIn("provenance: true", job_text) +PIN_LINE = ("uses: pypa/gh-action-pypi-publish@" + "7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3") + + +def _publish_job(): + return _job_text(_read(), "publish-pypi") + + +class PublishPypiWiringTest(unittest.TestCase): + """issue #245: publish-pypi is enabled behind `environment: pypi`, + runs only after build and release, holds only `id-token: write`, and + uses a SHA-pinned publish action without `skip-existing`. + """ + + def _mutant(self, old, new): + job = _publish_job() + self.assertIn(old, job) + return job.replace(old, new, 1) + + def test_real_publish_job_has_no_wiring_problems(self): + self.assertEqual(_publish_wiring_problems(_publish_job()), []) + + def test_publish_step_is_pinned_to_the_v1_14_3_commit(self): + job = _publish_job() + self.assertIn(PIN_LINE, job) + self.assertNotIn("@release/v1", job) + + def test_dist_is_downloaded_before_publishing_under_the_enabled_name(self): + job = _publish_job() + self.assertLess(job.index("uses: actions/download-artifact@"), + job.index("uses: pypa/gh-action-pypi-publish@")) + self.assertIn("\n name: Publish to PyPI (Trusted Publishing)\n", job) + + def test_reintroduced_if_false_is_reported(self): + mutant = self._mutant(" publish-pypi:\n", " publish-pypi:\n if: false\n") + self.assertEqual(_publish_wiring_problems(mutant), ["job-level if present"]) + + def test_missing_environment_is_reported(self): + mutant = self._mutant(" environment: pypi\n", "") + self.assertEqual(_publish_wiring_problems(mutant), ["environment is not pypi"]) + + def test_environment_moved_under_a_step_is_reported(self): + mutant = self._mutant(" environment: pypi\n", "") + self.assertIn(PIN_LINE + "\n", mutant) + mutant = mutant.replace(PIN_LINE + "\n", PIN_LINE + "\n environment: pypi\n") + self.assertEqual(_publish_wiring_problems(mutant), ["environment is not pypi"]) + + def test_needs_without_release_is_reported(self): + mutant = self._mutant("needs: [build, release]", "needs: build") + self.assertEqual(_publish_wiring_problems(mutant), ["needs is not [build, release]"]) + + def test_extra_permission_and_floating_ref_are_reported(self): + mutant = self._mutant( + " id-token: write # required for PyPI Trusted Publishing (OIDC)\n", + " id-token: write # required for PyPI Trusted Publishing (OIDC)\n" + " contents: write\n") + self.assertIn(PIN_LINE, mutant) + mutant = mutant.replace(PIN_LINE, "uses: pypa/gh-action-pypi-publish@release/v1") + self.assertEqual(_publish_wiring_problems(mutant), + ["permissions is not exactly id-token: write", + "publish action is not SHA-pinned"]) + + def test_skip_existing_is_reported(self): + mutant = self._mutant(PIN_LINE + "\n", + PIN_LINE + "\n with:\n skip-existing: true\n") + self.assertEqual(_publish_wiring_problems(mutant), ["skip-existing is set"]) + + def test_last_job_slice_runs_to_end_of_file_and_stops_at_a_following_job(self): + text = _read() + job = _job_text(text, "publish-pypi") + self.assertEqual(job, text[text.index("\n publish-pypi:\n"):]) + self.assertEqual(_job_text(job + "\n later:\n runs-on: x\n", "publish-pypi"), job) + + def test_empty_job_body_reports_the_four_missing_keys(self): + self.assertEqual(_publish_wiring_problems("\n publish-pypi:\n"), + ["environment is not pypi", + "needs is not [build, release]", + "permissions is not exactly id-token: write", + "publish action is not SHA-pinned"]) + + +class TagVersionGuardTest(unittest.TestCase): + """issue #245: `gates` fails when the pushed tag (minus a leading `v`) + differs from pyproject.toml's [project] version, so build, release and + publish-pypi never run for a mismatched tag. + """ + + def test_matching_tag_passes(self): + rc, out = _run_tag_guard("v0.9.0", "0.9.0") + self.assertEqual(rc, 0) + self.assertIn("tag v0.9.0 matches pyproject.toml version 0.9.0", out) + + def test_tag_on_a_later_dev_commit_fails(self): + rc, out = _run_tag_guard("v0.9.0", "0.9.1.dev0") + self.assertEqual(rc, 1) + self.assertIn("::error::tag v0.9.0 does not match pyproject.toml version 0.9.1.dev0", out) + + def test_pre_release_suffix_is_not_trimmed(self): + rc, out = _run_tag_guard("v0.9.0-rc1", "0.9.0") + self.assertEqual(rc, 1) + self.assertIn("::error::tag v0.9.0-rc1 does not match pyproject.toml version 0.9.0", out) + + def test_empty_tag_fails(self): + rc, out = _run_tag_guard("", "0.9.0") + self.assertEqual(rc, 1) + self.assertIn("::error::tag does not match pyproject.toml version 0.9.0", out) + script = _tag_guard_script() + self.assertIn("GITHUB_REF_NAME", script) + self.assertNotIn("${{", script) + + if __name__ == "__main__": unittest.main() diff --git a/plugins/lnpl-dev/.claude-plugin/plugin.json b/plugins/lnpl-dev/.claude-plugin/plugin.json index 5aadde9e..5f0f8f02 100644 --- a/plugins/lnpl-dev/.claude-plugin/plugin.json +++ b/plugins/lnpl-dev/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "lnpl-dev", "description": "Contributor tooling for the linkly platform itself — not for writing `.lnpl`. This repo has four environment preconditions that all fail silently, an RFC process with non-reusable numbering and a fixed seven-section template, and a mutation harness whose stale copy list makes every mutant fail identically. These skills route to the repo's own doctor and lint scripts and record the traps that have actually been hit.", - "version": "0.8.0", + "version": "0.9.0", "author": { "name": "choiyounggi", "url": "https://github.com/choiyounggi" diff --git a/plugins/lnpl-mcp/.claude-plugin/plugin.json b/plugins/lnpl-mcp/.claude-plugin/plugin.json index e6677231..83ebf58d 100644 --- a/plugins/lnpl-mcp/.claude-plugin/plugin.json +++ b/plugins/lnpl-mcp/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "lnpl-mcp", "displayName": "lnpl-mcp", "description": "Expose the LNPL compiler to the model as MCP tools instead of a shell command. `lnpl compile` writes its diagnostics to stderr and exits 0, so a caller that branches on the exit code misses every warning — and an unknown verb is a warning that means the step does nothing at runtime. These tools return the diagnostic records as structured data, call the already-imported compiler directly (no PATH lookup, no stream redirection), and route a design decision to the knowledge base before it is made.", - "version": "0.8.0", + "version": "0.9.0", "author": { "name": "choiyounggi", "url": "https://github.com/choiyounggi" diff --git a/plugins/lnpl/.claude-plugin/plugin.json b/plugins/lnpl/.claude-plugin/plugin.json index 64b19af1..93c083db 100644 --- a/plugins/lnpl/.claude-plugin/plugin.json +++ b/plugins/lnpl/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "lnpl", "displayName": "lnpl", "description": "Author `.lnpl` for the linkly LLM-native platform. LNPL's vocabulary is closed and absent from model training data, so a plausible-looking source compiles and then silently does nothing — an unknown verb derives no effect, and most declarations are never enforced. This plugin routes the model to the vocabulary generated from the compiler's own tables, checks at session start that the compiler is reachable at all, surfaces `lnpl compile` diagnostics the moment a file is written, offers an independent reviewer subagent so the session that wrote a file does not grade its own vocabulary, derives spec cases from declarations, consults the knowledge base before design decisions, and gates completion on evidence.", - "version": "0.8.0", + "version": "0.9.0", "author": { "name": "choiyounggi", "url": "https://github.com/choiyounggi" diff --git a/plugins/lnpl/skills/lnpl-authoring/references/declarations.md b/plugins/lnpl/skills/lnpl-authoring/references/declarations.md index eef98eff..14b0303f 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/declarations.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/declarations.md @@ -2,7 +2,7 @@ # 선언과 집행 (ENFORCEMENT) -> lnpl 0.8.0 기준. +> lnpl 0.9.0 기준. 선언했다고 집행되는 것이 아니다. 아래 표가 정본이다 — `enforced`만 실행을 바꾼다. `measured`는 관측·보고하되 막지 않고, `unenforced`는 런타임이 완전히 무시한다(issue #38). diff --git a/plugins/lnpl/skills/lnpl-authoring/references/grammar.md b/plugins/lnpl/skills/lnpl-authoring/references/grammar.md index 3003929d..dcd47102 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/grammar.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/grammar.md @@ -2,7 +2,7 @@ # 문법 — 키워드와 예약어 -> lnpl 0.8.0 기준. +> lnpl 0.9.0 기준. LNPL은 닫힌 키워드 집합을 쓴다. 아래에 없는 키워드는 문법이 아니다. diff --git a/plugins/lnpl/skills/lnpl-authoring/references/naming.md b/plugins/lnpl/skills/lnpl-authoring/references/naming.md index f612089e..205f2ff0 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/naming.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/naming.md @@ -2,7 +2,7 @@ # 이름과 참조 — 선언명·노드 id·스텝 객체 -> lnpl 0.8.0 기준. +> lnpl 0.9.0 기준. 선언에 붙인 이름은 곧 **노드 id**가 되고, 스텝이 엔티티를 가리킬 때 쓰는 철자도 그 규칙에서 나온다. 둘 다 기계적이고, 둘 다 틀리면 조용히 실패하지 않고 **컴파일이 거부한다** — 다만 에러가 이유를 말해주지 않아서 규칙을 모르면 빠져나올 수 없다(이슈 #50). diff --git a/plugins/lnpl/skills/lnpl-authoring/references/patterns.md b/plugins/lnpl/skills/lnpl-authoring/references/patterns.md index 4b58ef91..1330b478 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/patterns.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/patterns.md @@ -2,7 +2,7 @@ # 컬렉션이 필요해 보일 때 — 안티패턴과 권장패턴 -> lnpl 0.8.0 기준. +> lnpl 0.9.0 기준. `.lnpl`을 쓰다가 "여기 목록/맵이 필요하겠다"는 느낌이 들 때 멈추는 표다 — 그 느낌을 따라가면 파서가 받아주지 않거나(컬렉션 필드는 문법에 없다), 파서가 받아준다 해도 의미가 없는 문장을 쓰게 된다. linkly는 닫힌 어휘라 그럴듯한 낱말이 조용히 아무 일도 하지 않는 쪽이 실패 모드다(RFC-0048). diff --git a/plugins/lnpl/skills/lnpl-authoring/references/rfcs.md b/plugins/lnpl/skills/lnpl-authoring/references/rfcs.md index 66f6b8ce..283148cc 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/rfcs.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/rfcs.md @@ -2,7 +2,7 @@ # RFC 포인터 — 규칙의 근거와 로드맵 -> lnpl 0.8.0 기준. +> lnpl 0.9.0 기준. `.lnpl`을 쓰다 막혔을 때 **어느 RFC를 열지**만 답하는 표다. 규칙의 정본은 이 디렉터리의 다른 참조들이고, RFC는 그 규칙이 **왜 그런지**와 **아직 없는 것의 로드맵**을 갖는다. 아직 없는 어휘를 만났을 때 (`sum`/`count` 같은) "없다"에서 멈추지 않으려면 여기를 본다. diff --git a/plugins/lnpl/skills/lnpl-authoring/references/spec.md b/plugins/lnpl/skills/lnpl-authoring/references/spec.md index 8b92e5c0..f2c1510b 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/spec.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/spec.md @@ -2,7 +2,7 @@ # spec 블록 -> lnpl 0.8.0 기준. +> lnpl 0.9.0 기준. 워크플로 안의 `spec` 블록은 `given` / `when` / `expect` 세 절을 갖는다. 워크플로당 블록 여러 개를 선언할 수 있고 블록마다 독립 케이스 하나가 된다 — diff --git a/plugins/lnpl/skills/lnpl-authoring/references/types.md b/plugins/lnpl/skills/lnpl-authoring/references/types.md index c3e16e1c..413e98a3 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/types.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/types.md @@ -2,7 +2,7 @@ # 타입과 Refinement -> lnpl 0.8.0 기준. +> lnpl 0.9.0 기준. ## 의미 타입 diff --git a/plugins/lnpl/skills/lnpl-authoring/references/verbs.md b/plugins/lnpl/skills/lnpl-authoring/references/verbs.md index b4c5b3d1..8c40661b 100644 --- a/plugins/lnpl/skills/lnpl-authoring/references/verbs.md +++ b/plugins/lnpl/skills/lnpl-authoring/references/verbs.md @@ -2,7 +2,7 @@ # 동사 어휘 (VERB_LEXICON) -> lnpl 0.8.0 기준 (vocab 4572cb618b8b). +> lnpl 0.9.0 기준 (vocab 4009b5f7507b). 워크플로 스텝의 **첫 낱말**이 동사다. 아래 표에 없는 동사는 에러가 아니라 **효과 없는 no-op**으로 실행된다 — 파일은 컴파일되고, 런타임은 아무것도 하지 않는다(issue #36). 진단 코드 `unknown-verb`가 그때 발생한다. diff --git a/pyproject.toml b/pyproject.toml index 67f6e190..756e93a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lnpl" -version = "0.8.0" +version = "0.9.0" description = "LNPL — the surface language, Semantic IR, and runtime of the linkly LLM-native programming platform" readme = "README.md" requires-python = ">=3.11" diff --git a/schemas/lnpl-grammar.json b/schemas/lnpl-grammar.json index c2519708..e6bf9746 100644 --- a/schemas/lnpl-grammar.json +++ b/schemas/lnpl-grammar.json @@ -284,7 +284,7 @@ "refine" ] }, - "lnpl_version": "0.8.0", + "lnpl_version": "0.9.0", "reserved": [ "if", "for",