From 559a5d2d2b68c7720fe3b0666d915b3bb1ad5fbd Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Sun, 26 Jul 2026 21:12:26 -0700 Subject: [PATCH 1/7] fix: parity with continuum framework --- .github/workflows/gradle-build.yml | 49 ++++++++-- .github/workflows/structures-js-publish.yml | 96 +++++++++++++++++++ ...inotic.java-application-conventions.gradle | 3 +- 3 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/structures-js-publish.yml diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 9f53c5eb..4adddfa4 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -9,17 +9,27 @@ on: - 'helm/**' - 'docker-compose/**' - 'structures-js/**' - - 'webdocs/**' + - 'webdocs/**' + - 'website/**' + pull_request: + branches: + - develop + paths-ignore: + - 'helm/**' + - 'docker-compose/**' + - 'structures-js/**' + - 'webdocs/**' - 'website/**' +concurrency: + group: gradle-build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: gradle_build_and_publish: name: Build and Publish runs-on: ubuntu-latest steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - name: Checkout code uses: actions/checkout@v4 @@ -34,15 +44,32 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + # PR builds get a unique version (-pr.) so the resulting + # Docker image can be pulled locally to test the PR before merging. + - name: Determine build version + id: ver + run: | + BASE=$(grep '^structuresVersion=' gradle.properties | cut -d= -f2) + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) + VERSION="${BASE%-SNAPSHOT}-pr${{ github.event.pull_request.number }}.${SHORT_SHA}" + echo "gradle_args=-PstructuresVersion=${VERSION}" >> "$GITHUB_OUTPUT" + else + VERSION="${BASE}" + echo "gradle_args=" >> "$GITHUB_OUTPUT" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Building version ${VERSION}" + - name: Build with Gradle id: gradle_build continue-on-error: true env: TESTCONTAINERS_RYUK_DISABLED: true - run: ./gradlew build + run: ./gradlew build ${{ steps.ver.outputs.gradle_args }} - name: Publish to Maven Central with JReleaser - if: ${{ steps.gradle_build.outcome == 'success' }} + if: ${{ github.event_name == 'push' && steps.gradle_build.outcome == 'success' }} run: ./gradlew publish && ./gradlew jreleaserDeploy --info env: JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.MAVEN_TOKEN_USERNAME }} @@ -52,9 +79,11 @@ jobs: JRELEASER_GPG_SECRET_KEY: ${{ secrets.GPG_KEY_NEW }} JRELEASER_GPG_PASSPHRASE: ${{ secrets.GPG_KEY_PASS_NEW }} + # Fork and dependabot PRs do not receive the Docker Hub secrets, so only + # publish images for pushes and same-repo PRs. - name: Publish to Docker Hub - if: ${{ steps.gradle_build.outcome == 'success' }} - run: ./gradlew bootBuildImage --publishImage + if: ${{ steps.gradle_build.outcome == 'success' && (github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) }} + run: ./gradlew bootBuildImage --publishImage ${{ steps.ver.outputs.gradle_args }} env: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} @@ -111,7 +140,7 @@ jobs: path: gh-pages - name: Generate Allure report - uses: simple-elf/allure-report-action@v1.13 + uses: simple-elf/allure-report-action@v1.15 id: allure-report with: allure_results: allure-results @@ -135,7 +164,7 @@ jobs: context: 'Test Report' description: 'Passed' state: 'success' - sha: ${{ github.sha }} + sha: ${{ github.event.pull_request.head.sha || github.sha }} target_url: https://mindsignited.github.io/structures/allure/${{ github.run_number }} - name: Check If Failure diff --git a/.github/workflows/structures-js-publish.yml b/.github/workflows/structures-js-publish.yml new file mode 100644 index 00000000..28ba9600 --- /dev/null +++ b/.github/workflows/structures-js-publish.yml @@ -0,0 +1,96 @@ +name: Publish structures-js packages + +# Not wired up to automatic triggers yet — run manually from the Actions tab. +# To enable automatic publishing, replace `on:` with: +# on: +# push: +# branches: +# - develop +# tags: +# - 'v*' +on: + workflow_dispatch: + +concurrency: + group: structures-js-publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish: + name: Publish @kinotic/structures-api and @kinotic/structures-cli + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: 'https://registry.npmjs.org' + scope: '@kinotic' + cache: pnpm + cache-dependency-path: | + structures-js/structures-api/pnpm-lock.yaml + structures-js/structures-cli/pnpm-lock.yaml + + - name: Determine version and dist-tag + id: ver + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + DIST_TAG="latest" + else + BASE=$(node -p "require('./structures-js/structures-api/package.json').version") + BASE=${BASE%%-*} + SHORT_SHA=$(git rev-parse --short=7 HEAD) + VERSION="${BASE}-dev.${SHORT_SHA}" + DIST_TAG="dev" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "dist_tag=${DIST_TAG}" >> "$GITHUB_OUTPUT" + echo "Publishing version ${VERSION} with dist-tag ${DIST_TAG}" + + - name: Set version in structures-api + working-directory: structures-js/structures-api + run: npm version --no-git-tag-version --allow-same-version "${{ steps.ver.outputs.version }}" + + - name: Install structures-api deps + working-directory: structures-js/structures-api + run: pnpm install --frozen-lockfile + + - name: Build structures-api + working-directory: structures-js/structures-api + run: pnpm build + + - name: Publish structures-api to npm + working-directory: structures-js/structures-api + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm publish --access public --no-git-checks --tag "${{ steps.ver.outputs.dist_tag }}" + + - name: Set version and pin api dep in structures-cli + working-directory: structures-js/structures-cli + run: | + npm version --no-git-tag-version --allow-same-version "${{ steps.ver.outputs.version }}" + node -e " + const fs = require('fs'); + const p = JSON.parse(fs.readFileSync('package.json', 'utf8')); + p.dependencies['@kinotic/structures-api'] = '${{ steps.ver.outputs.version }}'; + fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n'); + " + + - name: Install structures-cli deps + working-directory: structures-js/structures-cli + run: pnpm install --no-frozen-lockfile + + - name: Publish structures-cli to npm + working-directory: structures-js/structures-cli + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm publish --access public --no-git-checks --tag "${{ steps.ver.outputs.dist_tag }}" \ No newline at end of file diff --git a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle index c4a79e69..224c1333 100644 --- a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle +++ b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle @@ -38,7 +38,8 @@ bootBuildImage { network = "host" publish = true imageName = "mindsignited/${project.name}:${project.version}" - tags = (!version.endsWith('SNAPSHOT') ? + // Only release versions (no -SNAPSHOT / -prN.sha suffix) may claim the latest tag + tags = (!version.contains('-') ? [ "mindsignited/${project.name}:latest" ] From d4bc5d03e14b3e59ef1e5052d2aac039d77c9038 Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Sun, 26 Jul 2026 21:13:11 -0700 Subject: [PATCH 2/7] fix: add SNAPSHOT to version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ef2f06f3..80b1d6ea 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -structuresVersion=3.5.7 +structuresVersion=3.5.8-SNAPSHOT allureVersion=2.32.0 antlrVersion=4.13.1 From b7bd8aa29de7e7a8466446e5ba837e1580433ab2 Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Tue, 28 Jul 2026 08:05:16 -0700 Subject: [PATCH 3/7] feat: persist OIDC authentication across page refreshes Restore the OIDC session on page refresh from the oidc-client-ts user store instead of forcing a re-login. The router guard now awaits a restore attempt (silent renew if the token expired) before redirecting to /login, and only a definitive denial clears the persisted session. - Share token selection/cookie writing via util/tokenCookie.ts - Stop writing the refresh token to a cookie; localStorage user store is the single persistence mechanism - Clear the persisted user on logout so refresh cannot restore it - Fix the token cookie secure flag on http origins after silent renew Co-Authored-By: Claude Fable 5 --- .../src/plugins/StructuresUI.ts | 15 +- structures-frontend/src/states/IUserState.ts | 221 ++++++++++++------ .../src/util/OidcSessionManager.ts | 58 +---- structures-frontend/src/util/tokenCookie.ts | 48 ++++ 4 files changed, 215 insertions(+), 127 deletions(-) create mode 100644 structures-frontend/src/util/tokenCookie.ts diff --git a/structures-frontend/src/plugins/StructuresUI.ts b/structures-frontend/src/plugins/StructuresUI.ts index 7bf65517..71ba56f6 100644 --- a/structures-frontend/src/plugins/StructuresUI.ts +++ b/structures-frontend/src/plugins/StructuresUI.ts @@ -5,15 +5,18 @@ import {StructuresStates} from '@/states/index' export function createStructuresUI(): Plugin { return { install(_: App, options: {router: Router}) { - options.router.beforeEach((to: RouteLocationNormalized, _: RouteLocationNormalized, next: NavigationGuardNext) => { + options.router.beforeEach(async (to: RouteLocationNormalized, _: RouteLocationNormalized, next: NavigationGuardNext) => { const { authenticationRequired } = to.meta - if ((authenticationRequired === undefined || authenticationRequired) - && !StructuresStates.getUserState().isAuthenticated()){ - next({ path: '/login', query: { referer: to.fullPath } }) - } else { - next() + if (authenticationRequired === undefined || authenticationRequired) { + const userState = StructuresStates.getUserState() + if (!userState.isAuthenticated() + && !(await userState.restoreSession())) { + next({ path: '/login', query: { referer: to.fullPath } }) + return + } } + next() }) StructuresStates.getApplicationState().initialize(options.router) diff --git a/structures-frontend/src/states/IUserState.ts b/structures-frontend/src/states/IUserState.ts index 8117e4a3..62ffae0e 100644 --- a/structures-frontend/src/states/IUserState.ts +++ b/structures-frontend/src/states/IUserState.ts @@ -1,13 +1,29 @@ import { ConnectedInfo, ConnectionInfo, Continuum } from '@kinotic/continuum-client' import { reactive } from 'vue' import Cookies from 'js-cookie' -import { User } from 'oidc-client-ts' +import { User, UserManager } from 'oidc-client-ts' import { createDebug } from '@/util/debug' const debug = createDebug('user-state'); import { oidcSessionManager } from '@/util/OidcSessionManager' import { configService } from '@/util/config' import { createConnectionInfo } from '../util/helpers' +import { selectToken, writeTokenCookie } from '@/util/tokenCookie' +import { createUserManagerSettings } from '@/pages/login/OidcConfiguration' + +// Deliberately outside the 'oidc.' prefix so AuthenticationManager.clearOidcState() +// does not wipe it during error recovery. The provider name is not a secret; it is +// needed to rebuild the UserManager when restoring a session after a page refresh. +const AUTH_PROVIDER_KEY = 'auth.provider' + +// Prefix used by the oidc-client-ts WebStorageStateStore for the persisted user +const OIDC_USER_KEY_PREFIX = 'oidc.user:' + +/** + * Thrown when the frontend role gate rejects an otherwise valid token. Lets the + * restore path tell a definitive denial apart from a transient failure. + */ +class FrontendRoleDeniedError extends Error {} export interface IUserState { connectedInfo: ConnectedInfo | null @@ -17,6 +33,7 @@ export interface IUserState { isAuthenticated(): boolean authenticate(login: string, passcode: string): Promise handleOidcLogin(user: User, provider: string): Promise + restoreSession(): Promise logout(): Promise } @@ -25,6 +42,7 @@ export class UserState implements IUserState { public oidcUser: User | null = null private authenticated: boolean = false private accessDenied: boolean = false + private restorePromise: Promise | null = null public async authenticate(login: string, passcode: string): Promise { try { @@ -62,22 +80,111 @@ export class UserState implements IUserState { debug('No existing connection to disconnect') } - const connectionInfo: ConnectionInfo = createConnectionInfo() + try { + await this.establishConnection(user, provider) + } catch (reason: any) { + this.accessDenied = true + if (reason instanceof Error) { + throw reason + } else if (reason) { + throw new Error(reason) + } else { + throw new Error('OIDC authentication failed') + } + } + } - let tokenToUse = user.access_token; + /** + * Attempt to restore an OIDC session persisted by oidc-client-ts in localStorage. + * Called by the router guard on page refresh before redirecting to login. + * Resolves true if a session was restored and the Continuum connection + * re-established; never rejects, so the guard always reaches a decision. + */ + public restoreSession(): Promise { + if (!this.restorePromise) { + this.restorePromise = this.doRestoreSession() + .catch(error => { + debug('Session restore failed unexpectedly: %O', error) + return false + }) + .finally(() => { + this.restorePromise = null + }) + } + return this.restorePromise + } - if (user.access_token && !this.isValidJWT(user.access_token)) { - debug('Access token is not a valid JWT, using ID token for Microsoft social login'); - tokenToUse = user.id_token || user.access_token; + private async doRestoreSession(): Promise { + if (this.isAuthenticated()) { + return true } - connectionInfo.connectHeaders = { - Authorization: `Bearer ${tokenToUse}` + const provider = localStorage.getItem(AUTH_PROVIDER_KEY) + if (!provider) { + return false } try { - this.connectedInfo = await Continuum.connect(connectionInfo) + // Temporary manager just to read/renew the persisted user. Renewal and + // session monitoring stay off so its timers never race the long-lived + // oidcSessionManager that takes over once the session is established. + const settings = await createUserManagerSettings(provider) + const userManager = new UserManager({ + ...settings, + automaticSilentRenew: false, + monitorSession: false + }) + + let user = await userManager.getUser() + if (!user) { + this.clearPersistedSession() + return false + } + + if (user.expired) { + if (!user.refresh_token) { + debug('Persisted OIDC session expired with no refresh token') + this.clearPersistedSession() + return false + } + debug('Persisted OIDC session expired, attempting silent renew') + user = await userManager.signinSilent() + if (!user) { + return false + } + } + + // The backend is the source of truth: only treat the session as + // restored once Continuum.connect accepts the token. + await this.establishConnection(user, provider) + debug('OIDC session restored for provider %s', provider) + return true + } catch (error) { + debug('Session restore failed: %O', error) + // Only discard the persisted session on a definitive denial. Transient + // failures (backend restarting, network blip) keep it so the next + // navigation can retry the restore. + if (error instanceof FrontendRoleDeniedError) { + this.clearPersistedSession() + } + return false + } + } + + /** + * Connect to Continuum with the user's token, enforce the frontend role gate, + * and persist what is needed to survive a page refresh. + * Shared by the initial OIDC login and session restore paths. + */ + private async establishConnection(user: User, provider: string): Promise { + const connectionInfo: ConnectionInfo = createConnectionInfo() + connectionInfo.connectHeaders = { + Authorization: `Bearer ${selectToken(user)}` + } + + this.connectedInfo = await Continuum.connect(connectionInfo) + try { // Frontend role gate. The backend has already validated the token; this is a // UI-only admission check using frontEndRoles configured on the OIDC provider. const providerConfig = await configService.getOidcProviderByName(provider) @@ -85,60 +192,57 @@ export class UserState implements IUserState { const userRoles = this.connectedInfo.participant.roles ?? [] const hasRequiredRole = providerConfig.frontEndRoles.some(r => userRoles.includes(r)) if (!hasRequiredRole) { - try { await Continuum.disconnect() } catch { /* best effort */ } - this.connectedInfo = null this.accessDenied = true - throw new Error(`User does not have any required frontend role. Required one of: ${providerConfig.frontEndRoles.join(', ')}`) + throw new FrontendRoleDeniedError(`User does not have any required frontend role. Required one of: ${providerConfig.frontEndRoles.join(', ')}`) } } - this.authenticated = true - this.accessDenied = false - this.oidcUser = user - - const useSecureCookies = window.location.protocol === 'https:' - - Cookies.set('token', tokenToUse, { - sameSite: 'strict', - secure: useSecureCookies, - expires: new Date(user.expires_at! * 1000) - }) - - if (user.refresh_token) { - const refreshExpiry = this.parseJwtExpiry(user.refresh_token) - Cookies.set('oidc_refresh_token', user.refresh_token, { - sameSite: 'strict', - secure: true, - expires: refreshExpiry ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }) - } - - // Initialize session manager for automatic token refresh + // Initialize automatic token refresh before marking the session + // authenticated so a failure here leaves no half-authenticated state await oidcSessionManager.initialize(provider, async () => { console.warn('Token refresh failed, logging out') await this.logout() }) - } catch (reason: any) { - this.accessDenied = true - if (reason) { - throw new Error(reason) - } else { - throw new Error('OIDC authentication failed') - } + } catch (error) { + try { await Continuum.disconnect() } catch { /* best effort */ } + this.connectedInfo = null + throw error + } + + this.authenticated = true + this.accessDenied = false + this.oidcUser = user + + // Best-effort persistence: a storage failure must not tear down the + // established session, it only costs restore-on-refresh. + try { + localStorage.setItem(AUTH_PROVIDER_KEY, provider) + // The token cookie is only for the GraphQL/OpenAPI playgrounds; the session + // itself is restored from the oidc-client-ts user store in localStorage. + writeTokenCookie(user) + } catch (error) { + debug('Failed to persist session state: %O', error) } } /** - * Parse the expiry date from a JWT token + * Remove the persisted OIDC user and provider so a page refresh cannot restore + * the session. Deletes the oidc-client-ts store entries by key prefix so no + * async config load is needed and a config failure cannot strand tokens. */ - private parseJwtExpiry(token: string): Date | null { + private clearPersistedSession(): void { try { - const parts = token.split('.') - if (parts.length !== 3) return null - const payload = JSON.parse(atob(parts[1])) - return payload.exp ? new Date(payload.exp * 1000) : null - } catch { - return null + const keysToRemove: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith(OIDC_USER_KEY_PREFIX)) { + keysToRemove.push(key) + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)) + localStorage.removeItem(AUTH_PROVIDER_KEY) + } catch (error) { + debug('Failed to clear persisted session: %O', error) } } @@ -154,7 +258,10 @@ export class UserState implements IUserState { } } + this.clearPersistedSession() + Cookies.remove('token') + // Legacy cleanup: refresh tokens are no longer written to cookies Cookies.remove('oidc_refresh_token') this.connectedInfo = null @@ -171,22 +278,6 @@ export class UserState implements IUserState { // Check if we have an active Continuum connection return this.authenticated && this.connectedInfo !== null } - - private isValidJWT(token: string): boolean { - try { - const parts = token.split('.'); - if (parts.length !== 3) { - return false; - } - - const header = JSON.parse(atob(parts[0])); - const payload = JSON.parse(atob(parts[1])); - - return !!(header.alg && payload.iss && payload.aud); - } catch (error) { - return false; - } - } } export const USER_STATE: IUserState = reactive(new UserState()) diff --git a/structures-frontend/src/util/OidcSessionManager.ts b/structures-frontend/src/util/OidcSessionManager.ts index d8c43d9c..b5ff65f1 100644 --- a/structures-frontend/src/util/OidcSessionManager.ts +++ b/structures-frontend/src/util/OidcSessionManager.ts @@ -1,6 +1,6 @@ import { User, UserManager } from 'oidc-client-ts' import { createUserManagerSettings } from '@/pages/login/OidcConfiguration' -import Cookies from 'js-cookie' +import { writeTokenCookie } from '@/util/tokenCookie' /** * OidcSessionManager maintains a persistent UserManager instance with event listeners @@ -40,7 +40,7 @@ class OidcSessionManager { // Create bound handlers so we can properly remove them later this.boundHandlers.userLoaded = (user: User) => { console.log('Token refreshed automatically') - this.updateCookies(user) + writeTokenCookie(user) } this.boundHandlers.silentRenewError = (error: Error) => { @@ -96,60 +96,6 @@ class OidcSessionManager { return this.userManager } - /** - * Update cookies with fresh tokens from the refreshed user - */ - private updateCookies(user: User): void { - let tokenToUse = user.access_token - - // Some providers (like Microsoft) return opaque access tokens - // In that case, use the ID token instead - if (!this.isValidJWT(user.access_token) && user.id_token) { - console.log('Access token is not a valid JWT, using ID token') - tokenToUse = user.id_token - } - - Cookies.set('token', tokenToUse, { - sameSite: 'strict', - secure: true, - expires: new Date(user.expires_at! * 1000) - }) - - if (user.refresh_token) { - const refreshExpiry = this.parseJwtExpiry(user.refresh_token) - Cookies.set('oidc_refresh_token', user.refresh_token, { - sameSite: 'strict', - secure: true, - expires: refreshExpiry ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }) - } - } - - /** - * Check if a token is a valid JWT (has 3 parts separated by dots) - */ - private isValidJWT(token: string): boolean { - try { - const parts = token.split('.') - return parts.length === 3 - } catch { - return false - } - } - - /** - * Parse the expiry date from a JWT token - */ - private parseJwtExpiry(token: string): Date | null { - try { - const parts = token.split('.') - if (parts.length !== 3) return null - const payload = JSON.parse(atob(parts[1])) - return payload.exp ? new Date(payload.exp * 1000) : null - } catch { - return null - } - } } export const oidcSessionManager = new OidcSessionManager() diff --git a/structures-frontend/src/util/tokenCookie.ts b/structures-frontend/src/util/tokenCookie.ts new file mode 100644 index 00000000..c0d794cf --- /dev/null +++ b/structures-frontend/src/util/tokenCookie.ts @@ -0,0 +1,48 @@ +import Cookies from 'js-cookie' +import { User } from 'oidc-client-ts' +import { createDebug } from '@/util/debug' + +const debug = createDebug('token-cookie') + +/** + * Check if a token is a structurally valid JWT + */ +function isValidJWT(token: string): boolean { + try { + const parts = token.split('.') + if (parts.length !== 3) { + return false + } + + const header = JSON.parse(atob(parts[0])) + const payload = JSON.parse(atob(parts[1])) + + return !!(header.alg && payload.iss && payload.aud) + } catch { + return false + } +} + +/** + * Pick the token to present to the backend. Some providers (like Microsoft social + * login) return opaque access tokens; in that case fall back to the ID token. + */ +export function selectToken(user: User): string { + if (user.access_token && !isValidJWT(user.access_token)) { + debug('Access token is not a valid JWT, using ID token') + return user.id_token || user.access_token + } + return user.access_token +} + +/** + * Write the token cookie used by the GraphQL/OpenAPI playgrounds. + * The session itself is persisted by the oidc-client-ts user store, not this cookie. + */ +export function writeTokenCookie(user: User): void { + Cookies.set('token', selectToken(user), { + sameSite: 'strict', + secure: window.location.protocol === 'https:', + expires: new Date(user.expires_at! * 1000) + }) +} From 023aa9144a1980c46f629e2e3ffd1c198f9bb7d1 Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Tue, 28 Jul 2026 08:25:09 -0700 Subject: [PATCH 4/7] fix: run e2e against the image version published by this build The e2e docker-compose setup resolved structuresVersion from gradle.properties (3.5.8-SNAPSHOT), a tag that PR builds never publish, so compose failed pulling images. Export the computed build version so e2e always pulls the image this run just pushed. Also drop a stray URL left in the Check If Failure script. Co-Authored-By: Claude Fable 5 --- .github/workflows/gradle-build.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 4adddfa4..8ea9004b 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -121,6 +121,10 @@ jobs: - name: Run E2E Tests id: run_e2e_tests continue-on-error: true + env: + # Shell env overrides the gradle.properties env-file in compose + # interpolation, so e2e pulls the image this run just published + structuresVersion: ${{ steps.ver.outputs.version }} run: | cd structures-js/structures-e2e gradle pnpmInstall @@ -180,5 +184,3 @@ jobs: # Optionally mark the job as failed exit 1 - - https://mindsignited.github.io/structures/webdocs/guide/overview.html From 690f5ac3219b458343d8b7417bf27c3d523318b9 Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Tue, 28 Jul 2026 12:04:31 -0700 Subject: [PATCH 5/7] fix: complete Ignite --add-opens set and stop publishing on local builds Replace the hand-maintained BPE_APPEND_JAVA_TOOL_OPTIONS add-opens list with the full set recommended by Apache Ignite for JDK 11+. The missing java.lang.invoke entry caused InaccessibleObjectException during Ignite marshalling of lambdas (SerializedLambda.capturingClass) at runtime. Also default bootBuildImage publish to false so local builds no longer try to push to Docker Hub; CI publishes via the --publishImage flag. Co-Authored-By: Claude Fable 5 --- ...inotic.java-application-conventions.gradle | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle index 224c1333..dede9ec5 100644 --- a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle +++ b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle @@ -34,9 +34,41 @@ dependencies { // bootBuildImage task source https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java // Paketo options here https://paketo.io/docs/howto/java/#configure-the-jvm-at-runtime // https://paketo.io/docs/reference/java-reference/ + +// Full --add-opens set recommended by Apache Ignite for JDK 11+ +// https://ignite.apache.org/docs/latest/quick-start/java#running-ignite-with-java-11-or-later +// A missing entry surfaces at runtime as InaccessibleObjectException during Ignite marshalling +def igniteAddOpens = [ + 'java.base/jdk.internal.access', + 'java.base/jdk.internal.misc', + 'java.base/sun.nio.ch', + 'java.base/sun.util.calendar', + 'java.management/com.sun.jmx.mbeanserver', + 'jdk.internal.jvmstat/sun.jvmstat.monitor', + 'java.base/sun.reflect.generics.reflectiveObjects', + 'jdk.management/com.sun.management.internal', + 'java.base/java.io', + 'java.base/java.nio', + 'java.base/java.net', + 'java.base/java.util', + 'java.base/java.util.concurrent', + 'java.base/java.util.concurrent.locks', + 'java.base/java.util.concurrent.atomic', + 'java.base/java.lang', + 'java.base/java.lang.invoke', + 'java.base/java.lang.reflect', + 'java.base/java.math', + 'java.sql/java.sql', + 'java.base/java.time', + 'java.base/java.text', + 'java.management/sun.management', + 'java.desktop/java.awt.font' +].collect { "--add-opens=${it}=ALL-UNNAMED" }.join(' ') + bootBuildImage { network = "host" - publish = true + // Local builds only produce the image; CI publishes by passing --publishImage + publish = false imageName = "mindsignited/${project.name}:${project.version}" // Only release versions (no -SNAPSHOT / -prN.sha suffix) may claim the latest tag tags = (!version.contains('-') ? @@ -46,7 +78,7 @@ bootBuildImage { : []) environment = [ "BPE_DELIM_JAVA_TOOL_OPTIONS" : " ", - "BPE_APPEND_JAVA_TOOL_OPTIONS": "-XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED" + "BPE_APPEND_JAVA_TOOL_OPTIONS": "-XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC ${igniteAddOpens}".toString() ] docker { publishRegistry { From 7308a48e3d7610a3bff24f7cbe184f9051b735e8 Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Tue, 28 Jul 2026 12:04:32 -0700 Subject: [PATCH 6/7] feat: pull published images by default in kind tooling - deploy now pulls structures-server/migration from Docker Hub by default (pullPolicy IfNotPresent); building from source and loading into the cluster is opt-in via --build-local - add --tag to deploy a specific published tag (e.g. a PR image) - stop manually pre-loading the Elasticsearch image; cluster nodes pull public images directly, which also avoids the kind load multi-platform manifest failure and the 8.18.1/8.19.13 version skew - rewrite KIND_IMAGE_LOADING.md to reflect the actual model Co-Authored-By: Claude Fable 5 --- dev-tools/kind/KIND_IMAGE_LOADING.md | 212 +++--------------- .../kind/config/elasticsearch/values.yaml | 2 +- .../kind/config/structures-server/values.yaml | 16 +- dev-tools/kind/kind-cluster.sh | 121 ++++++---- dev-tools/kind/lib/deploy.sh | 54 +---- 5 files changed, 136 insertions(+), 269 deletions(-) diff --git a/dev-tools/kind/KIND_IMAGE_LOADING.md b/dev-tools/kind/KIND_IMAGE_LOADING.md index a591095b..7202e18e 100644 --- a/dev-tools/kind/KIND_IMAGE_LOADING.md +++ b/dev-tools/kind/KIND_IMAGE_LOADING.md @@ -1,199 +1,61 @@ # KinD Image Loading Strategy -**Issue**: KinD clusters cannot pull images directly from Docker registries (docker.io, gcr.io, etc.) because they're isolated from the internet. +KinD cluster nodes run containerd with normal outbound network access, so they +**pull public registry images directly** (docker.elastic.co, docker.io, ghcr.io, +etc.) exactly like any other Kubernetes cluster. No manual loading is needed for +published images — the Helm charts reference the image/tag in their values files +and the nodes pull with `imagePullPolicy: IfNotPresent`. -**Additional Issue**: Multi-platform images (like Elasticsearch) can fail with `kind load docker-image` due to manifest format issues. +`kind load` is only for images that exist **solely in the host Docker daemon** +— i.e. locally built images that no registry serves: -**Solution**: -1. Pre-pull images on the host machine with explicit platform -2. Use `docker save` to create a tarball -3. Load tarball into KinD cluster using `kind load image-archive` +| Image | Source | How it reaches the cluster | +|-------|--------|---------------------------| +| `mindsignited/structures-server` (local build) | `./gradlew :structures-server:bootBuildImage` | `kind load docker-image` + `pullPolicy: Never` | +| `mindsignited/structures-migration` (local build) | `bootBuildImage` | `kind load docker-image` + `pullPolicy: Never` | +| Elasticsearch, Keycloak, PostgreSQL, ingress-nginx, cert-manager | Public registries | Pulled by the nodes directly | -## Image Loading Methods +## Loading a locally built image -### Method 1: Direct Load (Simple Images) -For simple single-platform images: ```bash -docker pull docker.io/bitnami/postgresql:15.5.0 -kind load docker-image docker.io/bitnami/postgresql:15.5.0 --name structures-cluster +./gradlew :structures-server:bootBuildImage +kind load docker-image mindsignited/structures-server: --name structures-cluster ``` -**Use for**: PostgreSQL, Keycloak, structures-server +The structures-server values file sets `pullPolicy: Never` so the node uses the +loaded image and never tries to pull a tag that only exists locally. -### Method 2: Tarball Load (Multi-Platform Images) -For complex multi-platform images: -```bash -# Pull for specific platform -docker pull --platform linux/amd64 docker.elastic.co/elasticsearch/elasticsearch:8.18.1 - -# Save to tarball -docker save docker.elastic.co/elasticsearch/elasticsearch:8.18.1 -o /tmp/elasticsearch.tar - -# Load tarball into cluster -kind load image-archive /tmp/elasticsearch.tar --name structures-cluster - -# Clean up -rm /tmp/elasticsearch.tar -``` - -**Use for**: Elasticsearch (official Elastic images) - -## Why Two Methods? - -### Multi-Platform Image Problem - -Official Elasticsearch images are **multi-platform** (support amd64, arm64, etc.). When you run: -```bash -kind load docker-image docker.elastic.co/elasticsearch/elasticsearch:8.18.1 -``` - -KinD tries to load the image but encounters: -``` -ERROR: failed to load image: ctr: content digest sha256:xxx: not found -``` - -This happens because: -1. Docker stores multi-platform images with **manifests** pointing to platform-specific layers -2. `kind load docker-image` doesn't handle these manifests correctly -3. The containerd runtime in KinD nodes can't find the referenced content - -### Tarball Method Fixes This - -Using `docker save` + `kind load image-archive`: -1. ✅ `docker save` flattens the image into a single-platform tarball -2. ✅ `kind load image-archive` imports the complete image with all layers -3. ✅ No manifest issues - everything is in the tarball -4. ✅ Works reliably for all image types - -## Updated Functions - -All deployment functions now follow this pattern: - -### 1. **Elasticsearch** (v8.11.1) -```bash -# 1. Check if image exists locally -# 2. Pull if needed: docker pull docker.io/bitnami/elasticsearch:8.11.1 -# 3. Load into cluster: kind load docker-image --name structures-cluster -# 4. Deploy with Helm, specifying --set image.tag=8.11.1 -``` - -### 2. **PostgreSQL** (v15.5.0) -```bash -# Same pattern: -# docker.io/bitnami/postgresql:15.5.0 -``` - -### 3. **Keycloak** (v26.0.2) -```bash -# Same pattern: -# docker.io/bitnami/keycloak:26.0.2 -``` - -## How It Works +## History: why Elasticsearch used to be pre-loaded -```bash -# Example: Loading Elasticsearch - -# Step 1: Pull image from Docker Hub to local Docker -docker pull docker.io/bitnami/elasticsearch:8.11.1 - -# Step 2: Load image into KinD cluster nodes -kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster - -# Step 3: Verify image is available in cluster -docker exec structures-cluster-control-plane crictl images | grep elasticsearch - -# Step 4: Deploy with Helm (image.pullPolicy defaults to IfNotPresent) -helm upgrade --install elasticsearch bitnami/elasticsearch --set image.tag=8.11.1 -``` - -## Benefits - -✅ **Predictable versions** - Pin to known working versions -✅ **No network issues** - Images loaded from local Docker -✅ **Faster deployments** - No waiting for image pulls -✅ **Offline capable** - Works without internet once images are cached -✅ **Consistent behavior** - Same images used across deployments - -## Version Selection Rationale - -| Component | Version | Reason | -|-----------|---------|--------| -| Elasticsearch | 8.11.1 | Stable LTS version, compatible with Structures | -| PostgreSQL | 15.5.0 | Latest stable PG 15.x, matches production usage | -| Keycloak | 26.0.2 | Matches docker-compose setup exactly | +Earlier versions of these scripts pre-pulled the Elasticsearch image on the +host, re-tagged it as `localhost/elasticsearch:`, and pushed it into +the nodes with `kind load docker-image`. That existed to work around +multi-platform manifest issues (`ctr: content digest ... not found`) in the +`kind load` path — but `kind load` itself was the only reason the image needed +to touch the host Docker daemon at all. Deploying the chart and letting the +nodes pull the image avoids the entire problem, and also avoids version skew +between a hardcoded pre-load tag in the script and the tag in +`config/elasticsearch/values.yaml` (they had already drifted: 8.18.1 vs +8.19.13). ## Troubleshooting -### Image Pull Fails +### Pod stuck in `ImagePullBackOff` (public image) ```bash -# Check Docker Hub rate limits -docker pull docker.io/bitnami/elasticsearch:8.11.1 +# Check events for the actual pull error +kubectl get events --sort-by='.lastTimestamp' | tail -20 -# If rate limited, authenticate: -docker login +# Docker Hub rate limiting? Authenticate the nodes or pre-load as a workaround: +docker pull && kind load docker-image --name structures-cluster ``` -### Image Load Fails +### Pod stuck in `ErrImageNeverPull` (local image) +The image was not loaded into the nodes. Re-run: ```bash -# Check KinD cluster exists -kind get clusters - -# Check cluster is running -kubectl cluster-info --context kind-structures-cluster - -# Manual load test -kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster +kind load docker-image --name structures-cluster ``` -### Pod Still Shows ImagePullBackOff +### Verify what images a node has ```bash -# Check image is in cluster docker exec structures-cluster-control-plane crictl images - -# Check pod image specification -kubectl get pod -o yaml | grep image: - -# Make sure image.pullPolicy is not Always -kubectl get deployment -o yaml | grep pullPolicy ``` - -## Manual Image Loading - -If you need to pre-load images before deployment: - -```bash -# Pre-load all dependency images -docker pull docker.io/bitnami/elasticsearch:8.11.1 -docker pull docker.io/bitnami/postgresql:15.5.0 -docker pull docker.io/bitnami/keycloak:26.0.2 - -kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster -kind load docker-image docker.io/bitnami/postgresql:15.5.0 --name structures-cluster -kind load docker-image docker.io/bitnami/keycloak:26.0.2 --name structures-cluster - -# Verify -docker exec structures-cluster-control-plane crictl images | grep bitnami -``` - -## Automated in Deploy Script - -The `deploy.sh` functions now automatically: -1. Check if image exists locally (`docker image inspect`) -2. Pull if missing (`docker pull`) -3. Load into KinD (`kind load docker-image`) -4. Deploy with Helm (with pinned version) - -No manual intervention needed! Just run: -```bash -./dev-tools/kind/kind-cluster.sh deploy -``` - -## Future Improvements - -Consider adding: -- Image pre-caching script (`load-images.sh`) -- Version configuration in `config/versions.yaml` -- Multi-architecture support (arm64/amd64) -- Local registry for faster loading (optional) - diff --git a/dev-tools/kind/config/elasticsearch/values.yaml b/dev-tools/kind/config/elasticsearch/values.yaml index 6b1224da..d51d0c2c 100644 --- a/dev-tools/kind/config/elasticsearch/values.yaml +++ b/dev-tools/kind/config/elasticsearch/values.yaml @@ -9,7 +9,7 @@ replicas: 2 minimumMasterNodes: 1 image: "docker.elastic.co/elasticsearch/elasticsearch" -imageTag: "8.18.1" +imageTag: "8.19.13" imagePullPolicy: IfNotPresent # Disable all security for local dev (matching docker-compose) diff --git a/dev-tools/kind/config/structures-server/values.yaml b/dev-tools/kind/config/structures-server/values.yaml index f6da89e2..813303ad 100644 --- a/dev-tools/kind/config/structures-server/values.yaml +++ b/dev-tools/kind/config/structures-server/values.yaml @@ -32,10 +32,11 @@ nameOverride: "" image: # Repository matches bootBuildImage output repository: mindsignited/structures-server - # Tag from gradle.properties version (structuresVersion=3.5.3-SNAPSHOT) - tag: 3.5.7 - # Never pull - use images loaded into KinD cluster - pullPolicy: Never + # Published tag pulled from Docker Hub. Overridden by deploy --tag, or by + # deploy --build-local which uses the locally built gradle.properties version. + tag: 3.5.8-pr7.023aa91 + # Pull from Docker Hub by default; deploy --build-local overrides this to Never + pullPolicy: IfNotPresent sha: "" # Migration configuration @@ -46,9 +47,10 @@ migration: activeDeadlineSeconds: 300 image: repository: mindsignited/structures-migration - tag: 3.5.7 - # Never pull - use images loaded into KinD cluster - pullPolicy: Never + # Keep in sync with image.tag above; deploy --tag sets both + tag: 3.5.8-pr7.023aa91 + # Pull from Docker Hub by default; deploy --build-local overrides this to Never + pullPolicy: IfNotPresent sha: "" ## eviction-tracking uses the evictionTracking logic and paths for tracking eviction events diff --git a/dev-tools/kind/kind-cluster.sh b/dev-tools/kind/kind-cluster.sh index 0e697905..210ad546 100755 --- a/dev-tools/kind/kind-cluster.sh +++ b/dev-tools/kind/kind-cluster.sh @@ -294,6 +294,8 @@ cmd_deploy() { local helm_values_override="" local helm_sets=() local wait_timeout_override="" + local build_local="0" + local image_tag="" # Parse subcommand options while [[ $# -gt 0 ]]; do @@ -334,6 +336,14 @@ cmd_deploy() { DEPLOY_LOAD_GENERATOR="1" shift ;; + --build-local) + build_local="1" + shift + ;; + --tag) + image_tag="$2" + shift 2 + ;; --wait-timeout) wait_timeout_override="$2" shift 2 @@ -354,6 +364,10 @@ Options: --with-keycloak, -k Deploy Keycloak + PostgreSQL and enable OIDC authentication --with-observability Deploy observability stack (OTEL, Prometheus, Grafana) --with-load-generator Run load generator after deployment (generates schemas/test data) + --build-local Build structures-server/migration images from source and load + them into the cluster (default: pull published images from Docker Hub) + --tag Image tag to pull from Docker Hub (default: tag from values file; + ignored with --build-local, which uses the gradle.properties version) --wait-timeout Deployment timeout (default: 5m) --help, -h Show this help message @@ -383,6 +397,12 @@ Examples: # Deploy with inline override $(basename "$0") deploy --set replicaCount=3 + # Deploy a specific published tag (e.g. a PR image) + $(basename "$0") deploy --tag 3.5.8-pr7.023aa91 + + # Build from source and load into the cluster instead of pulling + $(basename "$0") deploy --build-local + EOF return "${EXIT_SUCCESS}" ;; @@ -498,54 +518,77 @@ EOF # Deploy structures-server section "Deploying structures-server" - - # Build additional sets string - local additional_sets="" - + # Add OIDC configuration if Keycloak is deployed if [[ "${DEPLOY_KEYCLOAK}" == "1" ]]; then progress "Enabling OIDC authentication (oidc.enabled=true)" helm_sets+=("--set" "oidc.enabled=true") fi - - if [[ ${#helm_sets[@]} -gt 0 ]]; then - additional_sets="${helm_sets[*]}" - fi - # Build and load structures-server - cmd_build "--load" - - # Build and load structures-migration (used by Helm pre-upgrade hook) - section "Building structures-migration" - progress "Running: ./gradlew :structures-migration:bootBuildImage" - progress "This may take a few minutes..." - blank_line - - export RUNNING_KIND_CLUSTER="true" - if ! execute ./gradlew ":structures-migration:bootBuildImage" 2>&1 | while IFS= read -r line; do - if [[ "${line}" == *"BUILD"* ]] || \ - [[ "${line}" == *"Successfully built"* ]] || \ - [[ "${line}" == *"Paketo"* ]] || \ - [[ "${line}" == *"Error"* ]] || \ - [[ "${line}" == *"FAIL"* ]] || \ - [[ "${VERBOSE}" == "1" ]]; then - echo " ${line}" + if [[ "${build_local}" == "1" ]]; then + # Opt-in: build the images from source and load them into the cluster + local built_version + built_version=$(get_structures_version) || return "${EXIT_DEPLOYMENT_FAILED}" + progress "Building images locally (version ${built_version})" + helm_sets+=("--set" "image.tag=${built_version}") + helm_sets+=("--set" "image.pullPolicy=Never") + helm_sets+=("--set" "migration.image.tag=${built_version}") + helm_sets+=("--set" "migration.image.pullPolicy=Never") + + # Build and load structures-server + if ! cmd_build "--load"; then + return "${EXIT_DEPLOYMENT_FAILED}" + fi + + # Build and load structures-migration (used by Helm pre-upgrade hook) + section "Building structures-migration" + progress "Running: ./gradlew :structures-migration:bootBuildImage" + progress "This may take a few minutes..." + blank_line + + export RUNNING_KIND_CLUSTER="true" + if ! execute ./gradlew ":structures-migration:bootBuildImage" 2>&1 | while IFS= read -r line; do + if [[ "${line}" == *"BUILD"* ]] || \ + [[ "${line}" == *"Successfully built"* ]] || \ + [[ "${line}" == *"Paketo"* ]] || \ + [[ "${line}" == *"Error"* ]] || \ + [[ "${line}" == *"FAIL"* ]] || \ + [[ "${VERBOSE}" == "1" ]]; then + echo " ${line}" + fi + done; then + error "Failed to build structures-migration image" + return "${EXIT_DEPLOYMENT_FAILED}" + fi + + local migration_image + migration_image=$(get_migration_image_name) || return "${EXIT_DEPLOYMENT_FAILED}" + + section "Loading Migration Image into Cluster" + if ! load_image_into_cluster "${CLUSTER_NAME}" "${migration_image}"; then + error "Failed to load migration image into cluster" + return "${EXIT_DEPLOYMENT_FAILED}" + fi + blank_line + else + # Default: cluster nodes pull the published images from Docker Hub + progress "Using published images from Docker Hub (use --build-local to build from source)" + helm_sets+=("--set" "image.pullPolicy=IfNotPresent") + helm_sets+=("--set" "migration.image.pullPolicy=IfNotPresent") + if [[ -n "${image_tag}" ]]; then + progress "Image tag: ${image_tag}" + helm_sets+=("--set" "image.tag=${image_tag}") + helm_sets+=("--set" "migration.image.tag=${image_tag}") fi - done; then - error "Failed to build structures-migration image" - return "${EXIT_DEPLOYMENT_FAILED}" fi - - local migration_image - migration_image=$(get_migration_image_name) || return "${EXIT_DEPLOYMENT_FAILED}" - - section "Loading Migration Image into Cluster" - if ! load_image_into_cluster "${CLUSTER_NAME}" "${migration_image}"; then - error "Failed to load migration image into cluster" - return "${EXIT_DEPLOYMENT_FAILED}" + + # Build additional sets string + local additional_sets="" + if [[ ${#helm_sets[@]} -gt 0 ]]; then + additional_sets="${helm_sets[*]}" fi - blank_line - + + if ! deploy_structures_server "${CLUSTER_NAME}" "${additional_sets}"; then error "Deployment failed" echo "" diff --git a/dev-tools/kind/lib/deploy.sh b/dev-tools/kind/lib/deploy.sh index ebc907b9..f1f1ed51 100755 --- a/dev-tools/kind/lib/deploy.sh +++ b/dev-tools/kind/lib/deploy.sh @@ -720,63 +720,23 @@ deploy_elasticsearch() { local context="kind-${cluster_name}" progress "Deploying Elasticsearch..." - - # Elasticsearch version (matching docker-compose compose.ek-stack.yml) - local es_version="8.18.1" - local es_image="docker.elastic.co/elasticsearch/elasticsearch:${es_version}" - local local_tag="localhost/elasticsearch:${es_version}" - - # Detect host platform for image pulling - local platform="linux/amd64" - if [[ "$(uname -m)" == "arm64" ]] || [[ "$(uname -m)" == "aarch64" ]]; then - platform="linux/arm64" - fi - - # Pre-pull image for current platform and re-tag to avoid multi-platform issues - progress "Pre-loading Elasticsearch image into cluster..." - if ! docker image inspect "${local_tag}" &>/dev/null; then - progress "Pulling ${es_image} for ${platform}..." - # Pull for specific platform - if ! docker pull --platform "${platform}" "${es_image}"; then - error "Failed to pull Elasticsearch image" - return 1 - fi - - # Re-tag to local name to create clean single-platform reference - progress "Re-tagging image to local reference..." - if ! docker tag "${es_image}" "${local_tag}"; then - error "Failed to tag Elasticsearch image" - return 1 - fi - fi - - # Load the locally-tagged image into KinD (avoids multi-platform issues) - progress "Loading image into KinD cluster..." - if ! kind load docker-image "${local_tag}" --name "${cluster_name}"; then - error "Failed to load Elasticsearch image into cluster" - return 1 - fi - - # Also tag in the cluster as the original name so pods can find it - progress "Tagging image in cluster nodes..." - for node in $(kind get nodes --name "${cluster_name}"); do - docker exec "${node}" ctr -n k8s.io images tag "${local_tag}" "${es_image}" || true - done - + # Get Elasticsearch values file from config directory local values_flags values_flags=$(get_service_helm_flags "elasticsearch") || return 1 - + progress "Using Elasticsearch configuration from: $(get_service_values_path elasticsearch)" - - # Deploy using external values file + + # Deploy using external values file. The image/tag come from the values file + # and cluster nodes pull directly from the registry; manual kind-load is only + # needed for locally built images. Timeout allows for the initial image pull. local helm_output # shellcheck disable=SC2086 helm_output=$(helm upgrade --install elasticsearch elastic/elasticsearch \ --kube-context "${context}" \ --version 8.5.1 \ ${values_flags} \ - --wait --timeout 5m 2>&1) + --wait --timeout 10m 2>&1) local exit_code=$? From 870c7ff1069178723f205c9b7929614a9a4f1990 Mon Sep 17 00:00:00 2001 From: Nicholas Padilla Date: Tue, 28 Jul 2026 12:32:15 -0700 Subject: [PATCH 7/7] feat: automatic SNAPSHOT versioning and release version guard Adopt the standard release convention: gradle.properties holds the plain version, development builds get -SNAPSHOT appended automatically, and main publishes the version as-is via -Prelease. PR builds keep their explicit -pr. override. - append -SNAPSHOT in java-common-conventions unless -Prelease is set or the version already carries a suffix - compute the effective version per event in the build workflow (PR tag / SNAPSHOT on develop / plain release on main) - add version-check workflow that blocks PRs to main unless structuresVersion is bumped above the base branch (releases are immutable in Maven Central) - mirror the SNAPSHOT defaulting in kind tooling and the e2e docker-compose setup so local flows resolve the right image tag Co-Authored-By: Claude Fable 5 --- .github/workflows/gradle-build.yml | 16 ++++-- .github/workflows/version-check.yml | 54 +++++++++++++++++++ ...org.kinotic.java-common-conventions.gradle | 13 ++++- dev-tools/kind/lib/config.sh | 22 ++++++-- gradle.properties | 2 +- structures-js/structures-e2e/test/setup.ts | 33 +++++++++++- 6 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/version-check.yml diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 8ea9004b..8c7a3733 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -44,18 +44,24 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - # PR builds get a unique version (-pr.) so the resulting - # Docker image can be pulled locally to test the PR before merging. + # gradle.properties holds the plain version. Effective version by event: + # PR -> -pr. (pullable image to test the PR) + # develop push -> -SNAPSHOT (development build) + # main push -> (release, published as-is) - name: Determine build version id: ver run: | BASE=$(grep '^structuresVersion=' gradle.properties | cut -d= -f2) + BASE=${BASE%-SNAPSHOT} if [[ "${{ github.event_name }}" == "pull_request" ]]; then SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) - VERSION="${BASE%-SNAPSHOT}-pr${{ github.event.pull_request.number }}.${SHORT_SHA}" + VERSION="${BASE}-pr${{ github.event.pull_request.number }}.${SHORT_SHA}" echo "gradle_args=-PstructuresVersion=${VERSION}" >> "$GITHUB_OUTPUT" - else + elif [[ "${{ github.ref_name }}" == "main" ]]; then VERSION="${BASE}" + echo "gradle_args=-Prelease" >> "$GITHUB_OUTPUT" + else + VERSION="${BASE}-SNAPSHOT" echo "gradle_args=" >> "$GITHUB_OUTPUT" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" @@ -70,7 +76,7 @@ jobs: - name: Publish to Maven Central with JReleaser if: ${{ github.event_name == 'push' && steps.gradle_build.outcome == 'success' }} - run: ./gradlew publish && ./gradlew jreleaserDeploy --info + run: ./gradlew publish ${{ steps.ver.outputs.gradle_args }} && ./gradlew jreleaserDeploy --info ${{ steps.ver.outputs.gradle_args }} env: JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.MAVEN_TOKEN_USERNAME }} JRELEASER_MAVENCENTRAL_TOKEN: ${{ secrets.MAVEN_TOKEN_PASSWORD }} diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 00000000..47b3216d --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,54 @@ +name: Version Check + +on: + pull_request: + branches: + - main + +jobs: + require_version_bump: + name: Require version bump + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compare version with base branch + run: | + HEAD_VERSION=$(grep '^structuresVersion=' gradle.properties | cut -d= -f2) + + git fetch origin "${{ github.base_ref }}" --depth=1 + BASE_VERSION=$(git show "origin/${{ github.base_ref }}:gradle.properties" | grep '^structuresVersion=' | cut -d= -f2) + + # The numeric core ignores any pre-release suffix (-SNAPSHOT, -rc1, ...) + HEAD_CORE=${HEAD_VERSION%%-*} + BASE_CORE=${BASE_VERSION%%-*} + + echo "Base (${{ github.base_ref }}): ${BASE_VERSION}" + echo "Head: ${HEAD_VERSION}" + + fail() { echo "::error::$1"; exit 1; } + + if [[ "${HEAD_CORE}" == "${BASE_CORE}" ]]; then + if [[ "${HEAD_VERSION}" == "${BASE_VERSION}" ]]; then + fail "structuresVersion is still ${HEAD_VERSION}; bump it in gradle.properties before merging to ${{ github.base_ref }} (releases are immutable in Maven Central)" + elif [[ "${HEAD_VERSION}" == "${HEAD_CORE}" ]]; then + # pre-release -> release promotion of the same version (e.g. 3.6.0-rc1 -> 3.6.0) + echo "Version promotion OK: ${BASE_VERSION} -> ${HEAD_VERSION}" + elif [[ "${BASE_VERSION}" == "${BASE_CORE}" ]]; then + fail "structuresVersion ${HEAD_VERSION} is a pre-release of ${BASE_VERSION}, which is already released" + else + # both pre-releases of the same version: head must sort after base + HIGHEST=$(printf '%s\n%s\n' "${BASE_VERSION}" "${HEAD_VERSION}" | sort -V | tail -1) + if [[ "${HIGHEST}" != "${HEAD_VERSION}" ]]; then + fail "structuresVersion ${HEAD_VERSION} is lower than ${BASE_VERSION} on ${{ github.base_ref }}" + fi + echo "Version bump OK: ${BASE_VERSION} -> ${HEAD_VERSION}" + fi + else + HIGHEST=$(printf '%s\n%s\n' "${BASE_CORE}" "${HEAD_CORE}" | sort -V | tail -1) + if [[ "${HIGHEST}" != "${HEAD_CORE}" ]]; then + fail "structuresVersion ${HEAD_VERSION} is lower than ${BASE_VERSION} on ${{ github.base_ref }}" + fi + echo "Version bump OK: ${BASE_VERSION} -> ${HEAD_VERSION}" + fi diff --git a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle index 7a3b3000..7e0a4a2a 100644 --- a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle +++ b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle @@ -5,7 +5,18 @@ plugins { } group 'org.kinotic' -version "${structuresVersion}" + +// gradle.properties holds the plain version (e.g. 3.5.8). Development builds get +// -SNAPSHOT appended automatically; CI passes -Prelease on main to publish the +// version as-is (stripping a leftover -SNAPSHOT so the guard and the build agree), +// and PR builds pass an explicit -PstructuresVersion override. +def effectiveVersion = "${structuresVersion}" +if (hasProperty('release')) { + effectiveVersion = effectiveVersion - '-SNAPSHOT' +} else if (!effectiveVersion.contains('-')) { + effectiveVersion += '-SNAPSHOT' +} +version effectiveVersion sourceCompatibility = '21' repositories { diff --git a/dev-tools/kind/lib/config.sh b/dev-tools/kind/lib/config.sh index 6d94edf6..94258c96 100755 --- a/dev-tools/kind/lib/config.sh +++ b/dev-tools/kind/lib/config.sh @@ -246,21 +246,35 @@ get_coredns_template_path() { # version=$(get_structures_version) # get_structures_version() { + # Explicit override wins, e.g. deploying the released image from a release + # checkout: structuresVersion=3.5.8 ./kind-cluster.sh deploy + if [[ -n "${structuresVersion:-}" ]]; then + echo "${structuresVersion}" + return 0 + fi + local gradle_props="./gradle.properties" - + if [[ ! -f "${gradle_props}" ]]; then error "gradle.properties not found" return 1 fi - + local version version=$(grep '^structuresVersion=' "${gradle_props}" | cut -d'=' -f2) - + if [[ -z "${version}" ]]; then error "structuresVersion not found in gradle.properties" return 1 fi - + + # Mirror the build convention: plain versions are development builds and get + # -SNAPSHOT appended (see org.kinotic.java-common-conventions.gradle); this + # matches what a local ./gradlew build on the same checkout produces + if [[ "${version}" != *-* ]]; then + version="${version}-SNAPSHOT" + fi + echo "${version}" } diff --git a/gradle.properties b/gradle.properties index 80b1d6ea..2ccc3b6a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -structuresVersion=3.5.8-SNAPSHOT +structuresVersion=3.5.8 allureVersion=2.32.0 antlrVersion=4.13.1 diff --git a/structures-js/structures-e2e/test/setup.ts b/structures-js/structures-e2e/test/setup.ts index ac50bc13..c4ca575f 100644 --- a/structures-js/structures-e2e/test/setup.ts +++ b/structures-js/structures-e2e/test/setup.ts @@ -2,9 +2,40 @@ import path from 'node:path' // @ts-ignore import os from 'node:os' +// @ts-ignore +import fs from 'node:fs' import {StartedDockerComposeEnvironment, DockerComposeEnvironment, Wait} from 'testcontainers' import {TestProject} from 'vitest/node.js' +/** + * Load gradle.properties as the docker-compose environment, with + * structuresVersion resolved to the effective image tag: an explicit + * env override wins (CI exports a PR tag); otherwise a plain version is a + * development build and gets -SNAPSHOT appended to mirror + * org.kinotic.java-common-conventions.gradle. Passing everything through a + * single withEnvironment call avoids depending on compose env-file precedence. + */ +function composeEnvironment(): Record { + const env: Record = {} + try { + const content = fs.readFileSync(path.resolve('../../', 'gradle.properties'), 'utf8') + for (const line of content.split('\n')) { + const match = line.match(/^([\w.]+)=(.*)$/) + if (match) { + env[match[1]] = match[2].trim() + } + } + } catch { + // fall through to compose defaults + } + if (process.env.structuresVersion) { + env.structuresVersion = process.env.structuresVersion + } else if (env.structuresVersion && !env.structuresVersion.includes('-')) { + env.structuresVersion += '-SNAPSHOT' + } + return env +} + let environment: StartedDockerComposeEnvironment @@ -28,7 +59,7 @@ export async function setup(project: TestProject) { environment = await new DockerComposeEnvironment(resolvedPath, files) .withWaitStrategy('structures-elasticsearch', Wait.forHttp('/_cluster/health', 9200)) .withWaitStrategy('structures-server', Wait.forHttp('/health', 9090)) - .withEnvironmentFile(path.resolve('../../', 'gradle.properties')) + .withEnvironment(composeEnvironment()) .up(['structures-elasticsearch', 'structures-server']) const container = environment.getContainer('structures-server')