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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 53 additions & 4 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
소유자 결정).

<a id="release-app"></a>
**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 에는 사람의 승인을 요구하는
Expand Down
8 changes: 8 additions & 0 deletions docs/governance/RELEASE_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/scripts/test_release_please.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading