From f15b88ff907637609e5f6b62be38a0b710bd08d1 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Mon, 12 Oct 2026 11:44:17 +0900 Subject: [PATCH] ci(release): open the release pull request with a least-privilege App token release-please.yml mints a short-lived installation token with actions/create-github-app-token (SHA-pinned) from RELEASE_APP_CLIENT_ID and RELEASE_APP_PRIVATE_KEY, scoped to contents and pull requests write, and gives it to release-please and the branch checkout so the pull request starts CI. Without both secrets the job warns and falls back to GITHUB_TOKEN; with only one it fails. Tests hold the pin, the two permissions, the two uses and the fallback. docs/compatibility.md 5.1 and RELEASE_POLICY.md list the permissions and the owner's setup steps. Refs #895 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-please.yml | 55 +++++++++++++++++++--- CHANGELOG.md | 2 +- docs/compatibility.md | 57 +++++++++++++++++++++-- docs/governance/RELEASE_POLICY.md | 8 ++++ tests/unit/scripts/test_release_please.py | 52 +++++++++++++++++++++ 5 files changed, 162 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 61e82e97..71e61722 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -18,11 +18,25 @@ # - No labels (`skip-labeling`): POLICY 2.1 lists every label, and the tag is what # says a version is released. # -# The token. A pull request opened with GITHUB_TOKEN starts no `pull_request` -# workflows, so its required checks never report and it cannot be merged as it is. -# With a `RELEASE_PLEASE_TOKEN` secret (a fine-grained PAT or an App token with -# contents and pull-requests write) the checks run by themselves. Without it, close and -# reopen the pull request by hand — that event is a person's, and the checks start. +# The token (#895). A pull request opened with GITHUB_TOKEN starts no `pull_request` +# workflows, so its required checks never report and it cannot be merged as it is. The +# owner chose a least-privilege GitHub App: this job mints a short-lived installation +# token from two secrets and gives it to release-please and to the push below, so the +# pull request and its later pushes start CI like anyone else's. +# +# RELEASE_APP_CLIENT_ID the App's Client ID (Settings -> Developer settings) +# RELEASE_APP_PRIVATE_KEY a private key generated for the App (the whole .pem) +# +# The App holds two repository permissions and nothing else: Contents read and write +# (the release branch) and Pull requests read and write (open and edit the pull +# request). The token is minted with the same two, so a wider App still yields a narrow +# token. It is never used to merge or approve; the no-workflow-merges test and the +# release-please tests (under tests/unit) hold that. +# +# Without the two secrets the job warns and falls back to GITHUB_TOKEN, as it worked +# before: the pull request still opens, and a person closes and reopens it to start the +# checks. Releases keep working on the day the App is not yet set up. With only one of +# the two secrets the job fails — that is a broken setup, not a missing one. # # release.yml's `mode=prepare` is still there for what this cannot do: a pre-release, # and a critical patch inside the seven-day interval. @@ -55,11 +69,38 @@ jobs: permissions: contents: write pull-requests: write + env: + HAS_CLIENT_ID: ${{ secrets.RELEASE_APP_CLIENT_ID != '' }} + HAS_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY != '' }} steps: + - name: Check the release App secrets + id: app-secrets + run: | + if [ "${HAS_CLIENT_ID}" = "true" ] && [ "${HAS_PRIVATE_KEY}" = "true" ]; then + echo "use=true" >> "$GITHUB_OUTPUT" + echo "minting a release App token" + elif [ "${HAS_CLIENT_ID}" = "true" ] || [ "${HAS_PRIVATE_KEY}" = "true" ]; then + echo "::error title=Release App half configured::Set both RELEASE_APP_CLIENT_ID and RELEASE_APP_PRIVATE_KEY, or neither (docs/compatibility.md §5.1)." + exit 1 + else + echo "use=false" >> "$GITHUB_OUTPUT" + echo "::warning title=No release App token::RELEASE_APP_CLIENT_ID and RELEASE_APP_PRIVATE_KEY are not set. Falling back to GITHUB_TOKEN: the release pull request opens, but its required checks do not start until a person closes and reopens it (docs/compatibility.md §5.1)." + fi + + - name: Mint the release App token + id: app + if: steps.app-secrets.outputs.use == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - id: please uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: - token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} + token: ${{ steps.app.outputs.token || github.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json skip-github-release: true @@ -87,7 +128,7 @@ jobs: if: steps.please.outputs.pr with: ref: ${{ steps.branch.outputs.name }} - token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} + token: ${{ steps.app.outputs.token || github.token }} - name: Read the version release-please chose id: version diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8f01ce..b6503bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`semas` and `localdata` raise `AuthError` for data.go.kr's refused-key 403** (#916), as `datago` already did. They raised `TransportError(status_code=403)`. The error keeps `status_code=403`, its `dataset_id` and the original `TransportError` as `__cause__`, and carries the same activation (활용신청) hint as `datago`. `AuthError` is not a `TransportError` subclass, so **code that caught `TransportError` to see this 403 no longer catches it**: catch `AuthError` (or `PublicDataError`) instead. Other HTTP errors are still `TransportError`. The `kpubdata` CLI exits with 3 (auth) instead of 4 (transport) for this 403. - `Client.probe` classifies the catalogue datasets of `datago`, `semas` and `localdata` (#916). It returned `None` for every dataset without a spec; it now makes one one-record `list` call through the adapter and classifies the outcome with the same `PROBE_STATUSES`, so a key not activated for the service reads `application_required`. It still returns `None` for `datago.generic` (raw only) and for other providers' catalogue datasets, whose error codes are not data.go.kr's. `Client.probe_all` is unchanged: it enumerates spec datasets only. `API_SPEC.md` lists what `probe` covers. -- A spec dataset's application requirement is recorded in `dataset_metadata.json` and reaches `DatasetRef` (#902). A spec has no `application` field, so the `{required, url}` entries the datago catalogue carried for spec-served ids never reached users, and #898 removed those copies. Each spec dataset's metadata entry now has an `application` key — `{"required": true, "url": ...}` for the eight with a recorded answer (`air_quality`, `airkorea_forecast`, `bus_arrival`, `ultra_srt_ncst`, `tour_kor_area`, `tour_kor_festival`, `tour_kor_keyword`, `tour_kor_location`), `null` for the other seventeen, which had none. `DatasetRef.application_requirement` reads it after a catalogue declaration and before the recorded level, so those eight now answer `required` instead of `unknown`, and `to_dict()["application"]` falls back to it. A test fails when a spec dataset's entry lacks the key. No spec, fixture or spec digest changes; whether the spec schema should gain an `application` field is left open. +- A spec dataset's application requirement is recorded in `dataset_metadata.json` and reaches `DatasetRef` (#902). A spec has no `application` field, so the `{required, url}` entries the datago catalogue carried for spec-served ids never reached users, and #898 removed those copies. Each spec dataset's metadata entry now has an `application` key — `{"required": true, "url": ...}` for the eight with a recorded answer (`air_quality`, `airkorea_forecast`, `bus_arrival`, `ultra_srt_ncst`, `tour_kor_area`, `tour_kor_festival`, `tour_kor_keyword`, `tour_kor_location`), `null` for the other seventeen, which had none. `DatasetRef.application_requirement` reads it after a catalogue declaration and before the recorded level, so those eight now answer `required` instead of `unknown`, and `to_dict()["application"]` falls back to it. A test fails when a spec dataset's entry lacks the key. No spec, fixture or spec digest changes. The spec schema gains no `application` field: `dataset_metadata.json` is the one place (owner's decision, 2026-10-12); the seventeen `null` entries are confirmed by a person (#920). - A field's `title` and `unit` lines no longer move the spec digest that fixtures are bound to (#877, #522). They label what was recorded and change neither the request nor the casting, so filling them in voided the evidence of every bound fixture with no way to re-record it without a key. No spec on `main` had such a line, so every recorded digest is unchanged; any other change — the spec's own `title`, a field's `semantic_kind`, `type` or `description` — still moves it. For the four datasets whose fixtures are bound (`bus_arrival`, `social_enterprise`, `ultra_srt_fcst`, `ultra_srt_ncst`) `semantic_kind` is therefore not added here; it needs `make record`. - The compatibility matrix records the pin Builder's `main` has now (`docs/compatibility.md`). The row said `>=0.8.0,<0.9` with kpubdata 0.8.0; Builder moved to `>=0.9.0,<0.10` (kpubdata-builder#1050) and then to `>=0.10.0,<0.11` (kpubdata-builder#1217), and its tests run with 0.10.0. No change to the package. - The datago catalogue no longer declares the 14 datasets a spec also declares (#898): `air_quality`, `airkorea_forecast`, `apt_rent`, `metro_fare`, `offi_rent`, `offi_trade`, `rh_rent`, `sh_rent`, `sh_trade`, `tour_kor_area`, `tour_kor_festival`, `tour_kor_keyword`, `tour_kor_location`, `ultra_srt_ncst`. The spec already served each of these ids, so through `Client` nothing changes. Code that builds `DataGoAdapter` directly, without the spec bridge, no longer finds these keys: use `Client`. The README and status page count 134 catalogue datasets, not 148. diff --git a/docs/compatibility.md b/docs/compatibility.md index c62a124f..11ad0ce2 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -209,10 +209,59 @@ builder 의 릴리스를 받는다. builder 가 끝났고 studio 가 준비됐 함께 병합됐고, 창 검사는 "critical patch for #821" 로 통과했다(#895). 줄이 없었다면 10/13 까지 태그되지 않았다. `tests/unit/scripts/test_release_window.py` 가 이 두 경우를 `release.yml` 의 스크립트 그대로 돌려 확인한다. pre-release 는 release-please 가 하지 -않으므로 `mode=prepare` 로 낸다. `GITHUB_TOKEN` 으로 연 PR 은 -필수 체크가 돌지 않는다 — `RELEASE_PLEASE_TOKEN` secret 이 없으면 사람이 PR 을 닫았다 -다시 열어야 체크가 시작된다. builder·studio 는 아직 `mode=prepare` 를 쓴다 — 옮기는 일과 -두 버전이 어긋나면 실패하는 검사는 #895 에 남아 있다. +않으므로 `mode=prepare` 로 낸다. builder·studio 는 아직 `mode=prepare` 를 쓴다 — 옮기는 +일과 두 버전이 어긋나면 실패하는 검사는 #895 에 남아 있다. Studio 는 release-please 로 +버전을 따로 계산하지 않고 지금처럼 Builder 의 버전 태그를 따른다(ADR 0004, 2026-10-12 +소유자 결정). + + +**release PR 의 체크는 최소 권한 GitHub App 토큰으로 돈다**(#895, 2026-10-12 소유자 결정). +`GITHUB_TOKEN` 으로 연 PR 에서는 `pull_request` 워크플로가 시작되지 않아 필수 체크가 +보고되지 않는다. 그래서 `release-please.yml` 은 두 secret 으로 +`actions/create-github-app-token`(커밋 SHA 고정)에서 짧게 사는 설치 토큰을 받아 +release-please 와 release 브랜치 push 에 쓴다. 그 토큰이 연 PR 과 push 는 사람의 것처럼 +CI 를 시작한다. + +| Secret | 값 | +|---|---| +| `RELEASE_APP_CLIENT_ID` | App 의 Client ID (App 설정 화면 상단) | +| `RELEASE_APP_PRIVATE_KEY` | App 에서 만든 private key — `.pem` 파일 전체 | + +App 에 주는 권한은 저장소 권한 두 가지뿐이다. 토큰도 같은 두 가지로만 발급하므로 +App 에 권한이 더 붙어도 토큰은 좁다. + +| 권한 | 수준 | 쓰는 곳 | +|---|---|---| +| Contents | Read and write | release 브랜치 push | +| Pull requests | Read and write | release PR 열기·고치기 | +| (Metadata) | Read-only | GitHub 가 모든 App 에 자동으로 붙인다 | + +그 밖의 권한(Actions, Workflows, Administration, Checks, Issues …)은 주지 않는다. 토큰은 +병합·승인에 쓰지 않는다 — `tests/unit/test_no_workflow_merges.py` 가 `.github/` 어디에서도 +병합·자동 병합·승인을 막고, `tests/unit/scripts/test_release_please.py` 가 토큰이 +release-please 와 체크아웃 두 곳에만 쓰이고 두 권한으로만 발급되는 것을 지킨다. 병합은 +여전히 사람이 한다. + +**secret 이 없을 때는 지금 방식으로 돈다.** 두 secret 이 모두 없으면 잡은 경고 +(`No release App token`)를 남기고 `GITHUB_TOKEN` 으로 PR 을 연다 — 사람이 PR 을 닫았다 +다시 열어야 체크가 시작된다. App 설정 전에도 릴리스가 멈추지 않게 하려는 것이다. 하나만 +있으면 설정이 깨진 것이므로 잡이 실패한다. + +**소유자가 할 일** (한 번): + +1. 조직 `kpubdata-lab` → Settings → Developer settings → GitHub Apps → New GitHub App. + Webhook 은 끈다(Active 해제). Repository permissions 에서 Contents 와 Pull requests 만 + Read and write 로 둔다. "Only on this account" 로 만든다. +2. App 을 세 저장소(`kpubdata`, `kpubdata-builder`, `kpubdata-studio`)에만 설치한다 + ("Only select repositories"). +3. App 설정에서 Client ID 를 적어 두고 private key 를 만든다(.pem 다운로드). +4. 각 저장소의 Settings → Secrets and variables → Actions 에 `RELEASE_APP_CLIENT_ID`, + `RELEASE_APP_PRIVATE_KEY` 를 넣는다. 조직 secret 으로 세 저장소에만 공유해도 된다. +5. Actions → **Release please** → Run workflow 로 한 번 돌려, 경고가 사라지고 release PR 에 + 체크가 붙는 것을 본다. + +Builder·Studio 는 아직 `release-please.yml` 이 없으므로 secret 은 그 워크플로가 생길 때 +쓰인다. **워크플로는 PR 을 병합하지 않는다.** Dependabot PR 도 사람이 병합한다. 자동 병합 워크플로는 필수 체크는 지키지만, `review:R3` 이 아닌 PR 에는 사람의 승인을 요구하는 diff --git a/docs/governance/RELEASE_POLICY.md b/docs/governance/RELEASE_POLICY.md index 117f7dc1..64ba933c 100644 --- a/docs/governance/RELEASE_POLICY.md +++ b/docs/governance/RELEASE_POLICY.md @@ -54,6 +54,14 @@ The release workflow (`.github/workflows/release.yml`) enforces the ordering: 4. publish → a person dispatches publish-pypi.yml with the tag (trusted publisher) ``` +The release pull request that release-please opens gets its required checks through a +least-privilege GitHub App token (#895): `release-please.yml` mints it from the +`RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY` secrets with two repository +permissions only — Contents read and write, Pull requests read and write. The token never +merges or approves. Without the secrets the job warns and falls back to `GITHUB_TOKEN` +(a person closes and reopens the pull request to start the checks). The owner's setup +steps are in [compatibility §5.1](../compatibility.md#release-app). + No tag exists before the gates pass (#586). A re-run after the tag was pushed but the GitHub Release was not created finishes the release (#687). A re-run after a `release-window` refusal does nothing — it replays the same event; release with diff --git a/tests/unit/scripts/test_release_please.py b/tests/unit/scripts/test_release_please.py index c8af7fb3..bdb28a0c 100644 --- a/tests/unit/scripts/test_release_please.py +++ b/tests/unit/scripts/test_release_please.py @@ -118,6 +118,58 @@ def test_merging_the_release_please_branch_reaches_the_gated_release_job() -> No assert "startsWith(github.event.pull_request.head.ref, 'release/')" in release +APP_TOKEN = "${{ steps.app.outputs.token || github.token }}" + + +def _steps(workflow: str) -> list[str]: + """Each step of the one job, as text, split on the step's leading dash.""" + body = workflow.split("\n steps:\n", 1)[1] + return re.split(r"\n(?= - )", body) + + +def test_the_release_app_token_is_pinned_and_holds_two_permissions() -> None: + """The App token is the owner's choice for #895. It is minted with contents and pull + requests write and nothing else, from a pinned action.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + mint = next(step for step in _steps(workflow) if "create-github-app-token" in step) + + assert re.search(r"uses: actions/create-github-app-token@[0-9a-f]{40}\s", mint) + assert "client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}" in mint + assert "private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}" in mint + assert sorted(re.findall(r"permission-[a-z-]+: \w+", mint)) == [ + "permission-contents: write", + "permission-pull-requests: write", + ] + assert "secrets.RELEASE_PLEASE_TOKEN" not in workflow + + +def test_the_app_token_reaches_only_release_please_and_the_branch_checkout() -> None: + """Used anywhere else — a `gh` call, an approval — it would act with the App's + identity. Two uses: release-please opens the pull request, checkout pushes to it.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + users = [step for step in _steps(workflow) if "steps.app.outputs.token" in step] + + assert len(users) == 2 + assert "googleapis/release-please-action@" in users[0] + assert "actions/checkout@" in users[1] + assert workflow.count("steps.app.outputs.token") == 2 + assert workflow.count(APP_TOKEN) == 2 + assert "GH_TOKEN" not in workflow + + +def test_without_the_app_secrets_the_job_warns_and_falls_back() -> None: + """Releases must not stop on the day the App is not set up yet: no secrets is a + warning and GITHUB_TOKEN, as before. One secret without the other is an error.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + check = next(step for step in _steps(workflow) if "id: app-secrets" in step) + mint = next(step for step in _steps(workflow) if "create-github-app-token" in step) + + assert "if: steps.app-secrets.outputs.use == 'true'" in mint + assert "::warning title=No release App token::" in check + assert "::error title=Release App half configured::" in check + assert re.search(r"::error[^\n]*\n\s+exit 1\n", check) + + @pytest.fixture def repo(tmp_path: Path) -> Path: (tmp_path / "pyproject.toml").write_text(