From 5cb606bbd0eb7d6da5d77686af5bdc501c66f63d Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 17:47:05 +0900 Subject: [PATCH 1/2] feat(build): publish to Maven Central (vanniktech, Central Portal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes every library module publishable as kr.devslab:devslab-kit-* on Maven Central, so consumers can depend on the starter without building from source. - Root build: put com.vanniktech.maven.publish (0.30.0) on the buildscript classpath and apply it to every module except the runnable sample-app, in the existing subprojects block (all the current Spring Boot / GraalVM toolchain / dependency-management config is preserved). coordinates() = group + module name + version; the POM is the module name and is read lazily from each module's own `description` (set in its build.gradle.kts). - gradle.properties (new): SONATYPE_HOST=CENTRAL_PORTAL, SONATYPE_AUTOMATIC_RELEASE, RELEASE_SIGNING_ENABLED, and the shared POM_* metadata (url, license, developer, scm). - .github/workflows/release.yml (new): a `v*` tag publishes every library module (signed) and opens a GitHub Release. Uses the org-level MAVEN_CENTRAL_* and SIGNING_* secrets; GraalVM 21 toolchain to match the build workflow. - Version 0.0.1-SNAPSHOT -> 0.1.0-SNAPSHOT (the first public release is 0.1.0). - CHANGELOG: documented Maven Central publishing, the pluggable cache (ADR 0002), and the JWT injected-clock fix; added the Korean mirror (CHANGELOG.ko.md). Verified: ./gradlew publishToMavenLocal --no-daemon green — all 16 library modules produce jar + sources + javadoc + .module + a POM carrying name, description, url, licenses, scm and developers (the fields Central validates); sample-app excluded. ./gradlew build --no-daemon green (17 test tasks). Signing is a no-op for -SNAPSHOT, so local publish needs no GPG key. The real release (flip 0.1.0-SNAPSHOT -> 0.1.0, tag, push) comes after the docs surfaces are ready, per the devslab-kr release checklist. If the org secrets are scoped to selected repositories, this repo must be granted access before the first release. --- .github/workflows/release.yml | 49 +++++++++++++++++++++ CHANGELOG.ko.md | 83 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 16 +++++++ build.gradle.kts | 43 +++++++++++++++++- gradle.properties | 23 ++++++++++ 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.ko.md create mode 100644 gradle.properties diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3ac8271 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: Release to Maven Central + +# Cutting a release is a tag push: `git tag v0.1.0 && git push origin v0.1.0`. +# This publishes every library module to Maven Central (Central Portal, signed +# with the org GPG key) and opens a GitHub Release with generated notes. +# +# Requires these org-level secrets (already configured on devslab-kr): +# MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD — Central Portal token +# SIGNING_KEY / SIGNING_KEY_ID / SIGNING_KEY_PASSWORD — ASCII-armored GPG key +# If the org secrets are scoped to "selected repositories", grant this repo access. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + publish: + name: Publish to Maven Central + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 21 (GraalVM CE) + uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm-community' + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Publish to Maven Central + run: ./gradlew publishToMavenCentral --no-daemon --no-configuration-cache --stacktrace + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/CHANGELOG.ko.md b/CHANGELOG.ko.md new file mode 100644 index 0000000..61bdd2b --- /dev/null +++ b/CHANGELOG.ko.md @@ -0,0 +1,83 @@ +# 변경 이력 (Changelog) + +이 프로젝트의 주요 변경 사항을 기록합니다. + +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[유의적 버전(SemVer)](https://semver.org/lang/ko/)을 준수합니다. + +라이브러리 메이저는 Spring Boot 메이저와 정렬됩니다: `4.x.y`는 Spring Boot 4.x를 대상으로 합니다. + +English: [CHANGELOG.md](CHANGELOG.md) + +## [Unreleased] + +### Added +- **Maven Central 배포** — 모든 라이브러리 모듈을 vanniktech maven-publish 플러그인으로 + Maven Central에 배포합니다 (Central Portal, 서명, `v*` 태그 시 자동 릴리스). + `release.yml`이 아티팩트를 배포하고 GitHub Release를 생성합니다. +- **플러그형 캐시** (ADR 0002) — `devslab.kit.cache.type` = `in-memory` / `redis` / + `none`. Redis 백엔드가 JSON 직렬화를 직접 책임지므로 (`Serializable` 불필요, 직렬화기 + 설정 불필요) 사용자별 메뉴 캐시도 자체 맵 대신 이 공유 캐시 매니저를 사용합니다. +- **최초 관리자 부트스트랩** (ADR 0001) — opt-in, 프로퍼티 기반 프로비저닝으로 빈 데이터베이스가 + 영구적인 백도어 없이 사용 가능한 대시보드에 도달합니다. `devslab.kit.bootstrap.*`(기본 OFF)가 + 첫 부팅 시 테넌트, 전체 `admin.*` 권한 세트를 가진 `PLATFORM_ADMIN` 역할, 관리자 사용자 1명을 + 멱등하게 생성합니다. 빈 비밀번호는 강력한 랜덤 비밀번호를 생성해 한 번 로깅하며, prod 안전장치는 + `prod`/`production` 프로파일에서 약한 비밀번호를 거부합니다. +- **비밀번호 강제 변경** — 사용자 계정의 `must_change_password` 플래그(`V11`)를 `CurrentUser`, + JWT 클레임, 로그인 응답으로 노출합니다. 셀프 서비스 `POST /admin/api/v1/auth/change-password`가 + 이전 비밀번호를 검증하고 새 비밀번호를 설정한 뒤 플래그를 지우고 토큰을 재발급합니다. +- **부트스트랩 상태 프로브** — 인증 없는 `GET /admin/api/v1/bootstrap/status`가 + `{ initialized: boolean }`을 반환합니다. 향후 가이드형 최초 실행 / 설정 마법사의 분기점입니다 + (ADR 0001 §6). + +### Fixed +- **JWT 검증이 주입된 `Clock`을 사용하도록 수정** — `JjwtAuthTokenService.parse()`가 토큰 만료를 + 주입된 시계가 아니라 실제 시스템 시계로 검증하고 있었습니다. 이로 인해 고정 시계로는 검증을 + 테스트할 수 없었고 `issue()`와 비대칭이었습니다. 운영 동작은 그대로입니다 (런타임은 양쪽 모두 + `Clock.systemUTC()` 사용). + +### Changed +- `sample-app`이 `SampleSeedRunner`를 끄고 스타터의 `devslab.kit.bootstrap.*` 러너로 + 전환했습니다 (로컬 개발 형태: `admin/admin`, `must-change-password=false`). + +### Added (초기 스캐폴드) +- 초기 프로젝트 스캐폴드 (Spring Boot 4 + Java 21 + Gradle). +- 기본 의존성: Spring Web MVC, Spring Security, Spring Data JPA, Spring Data Redis, + Flyway (PostgreSQL), Spring Boot Actuator, GraalVM Native, Testcontainers (PostgreSQL + Redis), + Docker Compose 지원. +- 기본 패키지 `kr.devslab.kit`. +- Gradle 멀티모듈 분리 (계획 문서 §5): + `devslab-kit-core`, `-{identity,access,tenant,menu,audit}-{api,core}`, + `-autoconfigure`, `-spring-boot-starter`, `-sample-app`. +- 코어 값 객체: `UserId`, `TenantId`, `RoleId`, `PermissionId`, `MenuId`, `PublicId`, + `DevslabKitException`. +- Tenant: `TenantContext`, `TenantContextHolder`, `TenantResolver`, `TenantMode` (api) + + `DefaultTenantContextHolder`, `FixedTenantResolver` (core). AutoConfig override 패턴을 + 증명하는 데 사용한, 완전히 배선된 첫 수직 슬라이스. +- Identity (api): `CurrentUser`, `CurrentUserProvider`, `UserStatus`, `LoginCommand`, + `LoginResult`, `UserAccountView`, `PasswordHasher`, `LoginFailureReason`, + `AccountLoginException`, `LoginSucceededEvent`, `LoginFailedEvent`, + `UserAccountCreatedEvent`. + Identity (core): `PlatformUserAccountEntity` + `JpaPlatformUserAccountRepository`, + `BCryptPasswordHasher`, `LocalLoginService`, `PlatformUserAccountService`, + `DefaultCurrentUserProvider`, `V1__platform_user_account.sql`. +- Access (api): `Permission`, `Role`, `PermissionChecker`, `PermissionDeniedException`. + Access (core): `Platform{Role,Permission,UserRole,RolePermission}Entity` + Jpa 리포지토리, + `UserRoleService`, `RolePermissionService`, `DefaultPermissionChecker`, `V2__platform_access.sql`. +- Menu (api): `MenuItem`, `MenuTree`, `MenuProvider`. + Menu (core): `PlatformMenuEntity` + `JpaPlatformMenuRepository`, `MenuTreeBuilder`, + `PermissionBasedMenuFilter`, `DefaultMenuProvider`, `V3__platform_menu.sql`. +- Audit (api): `AuditEvent`, `AuditActor`, `AuditAction`, `AuditTarget`, `AuditEventPublisher`. + Audit (core): `PlatformAuditLogEntity` + `JpaPlatformAuditLogRepository`, `AuditLogService` + (Jackson 직렬화 메타데이터), `DefaultAuditEventPublisher`, `V4__platform_audit_log.sql`. +- `DevslabKitProperties` (`devslab.kit.*` 접두사) + `@ConditionalOnMissingBean` override를 갖춘 + 5개 `AutoConfiguration`: `Tenant`, `Identity`, `Access`, `Menu`, `Audit`. +- `devslab-kit-sample-app`이 8개 스타터 빈(`TenantResolver`, `TenantContextHolder`, + `CurrentUserProvider`, `PasswordHasher`, `LocalLoginService`, `PermissionChecker`, + `MenuProvider`, `AuditEventPublisher`)과 BCrypt 왕복을 스모크 테스트합니다. + +### Notes +- 아직 `0.1.0` 이전 스캐폴드이며, `0.1.0` 전에 공개 표면이 바뀔 수 있습니다. +- SB 4.1-SNAPSHOT / Java 25 대신 Spring Boot 4.0.6(릴리스)와 Java 21을 대상으로 합니다. + SB SNAPSHOT + Java 25 조합이 일부 환경에서 IntelliJ Gradle 통합을 깨뜨려, 릴리스 버전으로 + 고정해 import 경로를 예측 가능하게 유지합니다. SB 4.1.x 릴리스가 나오면 Java 25를 재검토합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index d97ca4e..1b350cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot 4.x. +한국어: [CHANGELOG.ko.md](CHANGELOG.ko.md) + ## [Unreleased] ### Added +- **Maven Central publishing** — every library module ships to Maven Central via + the vanniktech maven-publish plugin (Central Portal, signed, auto-release on a + `v*` tag). `release.yml` publishes the artifacts and opens a GitHub Release. +- **Pluggable cache** (ADR 0002) — `devslab.kit.cache.type` = `in-memory` / + `redis` / `none`. The Redis backend owns JSON serialization (no `Serializable`, + no serializer wiring), and the per-user menu cache now rides this shared cache + manager instead of its own map. - **First-admin bootstrap** (ADR 0001) — opt-in, property-driven provisioning so a fresh database can reach a usable dashboard without a permanent backdoor. `devslab.kit.bootstrap.*` (OFF by default) idempotently creates a tenant, a @@ -25,6 +34,13 @@ The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot returning `{ initialized: boolean }`, the branch point for a future guided first-run / setup wizard (ADR 0001 §6). +### Fixed +- **JWT validation now honours the injected `Clock`** — `JjwtAuthTokenService.parse()` + validated token expiry against the real system clock instead of the injected one, + making validation untestable with a fixed clock and asymmetric with `issue()`. + Production behaviour is unchanged (the runtime uses `Clock.systemUTC()` on both + paths). + ### Changed - `sample-app` switched off its `SampleSeedRunner` onto the starter's `devslab.kit.bootstrap.*` runner (local-dev shape: `admin/admin`, diff --git a/build.gradle.kts b/build.gradle.kts index 19ed0bc..a38611f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,19 @@ +import com.vanniktech.maven.publish.MavenPublishBaseExtension + +// vanniktech maven-publish on the buildscript classpath so this root script can +// `import` its extension type and configure publishing for the library modules. +// (Declaring it `apply false` in plugins {} does not reliably expose the classes +// to the root script's compilation under Gradle 9.x — buildscript {} does.) +buildscript { + repositories { + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath("com.vanniktech:gradle-maven-publish-plugin:0.30.0") + } +} + plugins { id("org.springframework.boot") version "4.0.6" apply false id("io.spring.dependency-management") version "1.1.7" apply false @@ -6,14 +22,19 @@ plugins { allprojects { group = "kr.devslab" - version = "0.0.1-SNAPSHOT" + version = "0.1.0-SNAPSHOT" repositories { mavenCentral() } } +// Every module ships to Maven Central except the runnable reference app. +val nonPublishedModules = setOf("devslab-kit-sample-app") + subprojects { + val proj = this + apply(plugin = "java-library") apply(plugin = "io.spring.dependency-management") @@ -45,4 +66,24 @@ subprojects { tasks.withType().configureEach { useJUnitPlatform() } + + // --- Maven Central publishing for the library modules (not the sample app) --- + if (proj.name !in nonPublishedModules) { + apply(plugin = "com.vanniktech.maven.publish") + + configure { + // SONATYPE_HOST / SONATYPE_AUTOMATIC_RELEASE / RELEASE_SIGNING_ENABLED + // and the POM_* metadata come from gradle.properties. Signing is a + // no-op for -SNAPSHOT versions, so publishToMavenLocal needs no GPG + // key; a non-SNAPSHOT release tag signs with the org key. + signAllPublications() + coordinates(proj.group.toString(), proj.name, proj.version.toString()) + pom { + name.set(proj.name) + // Each module sets its own `description` in build.gradle.kts; read + // it lazily since module scripts evaluate after this block runs. + description.set(proj.provider { proj.description ?: "devslab-kit :: ${proj.name}" }) + } + } + } } diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8225068 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# --- Maven Central publishing (vanniktech maven-publish) --------------------- +# Publishes through the new Central Portal and auto-promotes the deployment, so a +# `v*` tag push releases without a manual "close + release" in the portal UI. +SONATYPE_HOST=CENTRAL_PORTAL +SONATYPE_AUTOMATIC_RELEASE=true +RELEASE_SIGNING_ENABLED=true + +# Shared POM metadata. Per-module / are set in build.gradle.kts +# (each module's own `description`). These apply to every published artifact. +POM_INCEPTION_YEAR=2026 +POM_URL=https://github.com/devslab-kr/devslab-kit + +POM_LICENSE_NAME=The Apache License, Version 2.0 +POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENSE_DIST=repo + +POM_DEVELOPER_ID=devslab-kr +POM_DEVELOPER_NAME=devslab +POM_DEVELOPER_URL=https://github.com/devslab-kr + +POM_SCM_URL=https://github.com/devslab-kr/devslab-kit +POM_SCM_CONNECTION=scm:git:git://github.com/devslab-kr/devslab-kit.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/devslab-kr/devslab-kit.git From 0b9c052048069aad5042ff2cc2f1d28ae74a41f3 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 18:07:21 +0900 Subject: [PATCH 2/2] fix(build): resolve signing config conflict that broke publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first publishing commit failed CI (and locally): vanniktech's signAllPublications() threw "The value for this property is final and cannot be changed any further" under Gradle 9.x. Root cause was a double-configuration of signing — RELEASE_SIGNING_ENABLED=true in gradle.properties AND signAllPublications() in the build script both configure the signing property, so the second call hit an already-finalized property (Gradle 9 finalizes earlier than 8.x). Bumping the plugin version alone did not help (0.34.0 failed the same way), confirming it was the conflict, not the version. - Remove RELEASE_SIGNING_ENABLED from gradle.properties; keep the explicit signAllPublications() (signs releases with the org key, no-op for -SNAPSHOT). - Bump vanniktech 0.30.0 -> 0.36.0 (latest; best Gradle 9.5 support) while here. - Comment the gotcha in build.gradle.kts so the two aren't combined again. Verified: ./gradlew publishToMavenLocal --no-daemon green — all 16 library modules produce jar + sources + javadoc + .module + .pom (Central-required set). --- build.gradle.kts | 14 +++++++++----- gradle.properties | 1 - 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a38611f..315eae2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ buildscript { gradlePluginPortal() } dependencies { - classpath("com.vanniktech:gradle-maven-publish-plugin:0.30.0") + classpath("com.vanniktech:gradle-maven-publish-plugin:0.36.0") } } @@ -72,10 +72,14 @@ subprojects { apply(plugin = "com.vanniktech.maven.publish") configure { - // SONATYPE_HOST / SONATYPE_AUTOMATIC_RELEASE / RELEASE_SIGNING_ENABLED - // and the POM_* metadata come from gradle.properties. Signing is a - // no-op for -SNAPSHOT versions, so publishToMavenLocal needs no GPG - // key; a non-SNAPSHOT release tag signs with the org key. + // SONATYPE_HOST / SONATYPE_AUTOMATIC_RELEASE and the POM_* metadata + // come from gradle.properties. signAllPublications() enables GPG + // signing (with the org key) for releases and is a no-op for + // -SNAPSHOT, so publishToMavenLocal needs no key. + // + // Do NOT also set RELEASE_SIGNING_ENABLED in gradle.properties: it + // configures signing too, and combining the two double-sets the + // signing property, which Gradle 9 rejects as "value is final". signAllPublications() coordinates(proj.group.toString(), proj.name, proj.version.toString()) pom { diff --git a/gradle.properties b/gradle.properties index 8225068..ea0c24f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,6 @@ # `v*` tag push releases without a manual "close + release" in the portal UI. SONATYPE_HOST=CENTRAL_PORTAL SONATYPE_AUTOMATIC_RELEASE=true -RELEASE_SIGNING_ENABLED=true # Shared POM metadata. Per-module / are set in build.gradle.kts # (each module's own `description`). These apply to every published artifact.