From e15989c347ddd8398e4adf1ac5c91abbf8bdce25 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 10:59:08 -0400 Subject: [PATCH 01/16] spec(release): define source release strategy --- specs/index.spec.md | 1 + specs/platform/source-release.spec.md | 289 ++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 specs/platform/source-release.spec.md diff --git a/specs/index.spec.md b/specs/index.spec.md index 05bbd639..fcbfc84e 100644 --- a/specs/index.spec.md +++ b/specs/index.spec.md @@ -52,6 +52,7 @@ Machine-readable index for autonomous reconciliation (`/reconcile` skill). | `platform/local-development.spec.md` | platform | Kind cluster, images, Make targets | ALL | cross-cutting, security | | `platform/oidc-integration.spec.md` | platform | API JWT validation, BFF OIDC session, IdP client config, Kind opt-in | API, WEB, CP | local-development, openshell-gateway-oidc, web-console/architecture | | `platform/e2e-testing.spec.md` | platform | Infra drivers, e2e test suite, CI workflow, deploy overlays | ALL | local-development, control-plane, openshell-gateway-routing | +| `platform/source-release.spec.md` | platform | Release PR, source release, build identity | API, CP, WEB, CI | cross-cutting, local-development, e2e-testing, web-console/architecture | | `platform/api-server-observability.spec.md` | platform | API OTel SDK bootstrap, HTTP/gRPC server spans, W3C trace continuation, request metrics | API | web-console/tracing, security, local-development, e2e-testing | | `platform/control-plane-observability.spec.md` | platform | CP OTel SDK bootstrap, reconcile spans, gRPC client spans, watch lifecycle, K8s API spans, reconcile metrics | CP | api-server-observability, control-plane, security, local-development | | `standards/ui/foundations.spec.md` | standards | UI foundations | WEB | - | diff --git a/specs/platform/source-release.spec.md b/specs/platform/source-release.spec.md new file mode 100644 index 00000000..849a1f28 --- /dev/null +++ b/specs/platform/source-release.spec.md @@ -0,0 +1,289 @@ +# Source Release Specification + +## Purpose + +This specification defines source releases for the HyperShell repository. It +defines conventional commit input, the managed release pull request, release +files, GitHub releases, and container build identity. In this specification, a +"release" is a source release of the HyperShell repository. It is not a +`GatewayRelease` API resource. Image promotion and deployment are outside this +specification. Argo image bump continues to control deployment image updates. + +## Definitions + +- **Release PR**: The long-lived pull request that proposes the next repository + release. +- **Release version**: The SemVer value in the root `VERSION` file. The file does + not include the `v` tag prefix. +- **Build version**: The version string embedded in one component image. +- **Revision**: The full Git commit SHA from which an image is built. +- **Short revision**: The first seven lowercase hexadecimal characters of the + revision. + +## Requirements + +### Requirement REL-01: Conventional pull request titles + +Each pull request that targets `main` SHALL have a title that follows the +Conventional Commits form `(): `. +The type and scope SHALL use lowercase characters. The repository SHALL check +the title in a required CI gate. A squash merge SHALL use the pull request title +as the commit subject on `main`. + +`fix` SHALL request a patch increment. `feat` SHALL request a minor increment. +The `!` marker or a `BREAKING CHANGE` footer SHALL request a major increment. +Before version `1.0.0`, a breaking change SHALL request a minor increment. +Other valid types MAY add changelog content, but they SHALL NOT request a +version increment by themselves. + +#### Scenario: Invalid pull request title + +- GIVEN a pull request targets `main` +- WHEN its title does not follow the required form +- THEN the required lint gate SHALL fail +- AND the failure SHALL show the required title form + +#### Scenario: Feature increment + +- GIVEN the current release version is `1.2.3` +- WHEN `feat(api): add service status` enters `main` +- THEN the Release PR SHALL propose version `1.3.0` + +### Requirement REL-02: Managed release pull request + +Release Please SHALL maintain one Release PR against `main`. It SHALL open the +Release PR after a commit that requests a version increment enters `main`. It +SHALL update the open Release PR after later commits enter `main`. The Release +PR SHALL use a conventional commit title. It SHALL remain open until a person +selects it for release. The automation SHALL NOT enable automatic merge. + +The Release PR is a release decision surface. It is not a SemVer prerelease, +and it SHALL NOT create an `-rc` tag. + +#### Scenario: Work enters main while the Release PR is open + +- GIVEN an open Release PR proposes version `0.4.0` +- WHEN another conforming commit enters `main` +- THEN Release Please SHALL update the same Release PR +- AND it SHALL update the proposed changelog +- AND it SHALL recalculate the proposed version when necessary +- AND it SHALL NOT create a GitHub release + +#### Scenario: Maintenance-only work + +- GIVEN no Release PR is open +- WHEN only commits that do not request a version increment enter `main` +- THEN Release Please SHALL NOT open a Release PR + +### Requirement REL-03: Release files + +The repository SHALL have root `VERSION` and `CHANGELOG.md` files. `VERSION` +SHALL contain one SemVer value without a `v` prefix. The Release PR SHALL update +`VERSION` to the proposed release version. It SHALL update `CHANGELOG.md` with +all conforming commits since the previous release. Changelog entries SHALL be +grouped by commit type. + +Release Please SHALL also update the checked-in CI build prefix from the same +release version. A repository policy check SHALL fail when the CI build prefix +and `VERSION` do not identify the same release version. + +#### Scenario: Release file consistency + +- GIVEN a Release PR proposes version `1.5.0` +- WHEN its files are inspected +- THEN `VERSION` SHALL contain `1.5.0` +- AND the CI build prefix SHALL contain `v1.5.0` +- AND `CHANGELOG.md` SHALL contain the proposed `1.5.0` entries + +### Requirement REL-04: Release publication + +Only the merge of a Release PR SHALL authorize a repository release. After the +required `main` CI workflow succeeds, Release Please SHALL create a `vX.Y.Z` Git +tag and a published GitHub release. The tag SHALL identify the merged Release +PR commit. The GitHub release notes SHALL use the matching `CHANGELOG.md` +section. The repository SHALL have immutable GitHub releases enabled. + +Release automation SHALL be safe to run again after a failure. It SHALL use a +dedicated GitHub App token so that automation-created pull requests run normal +CI. Each external GitHub Action SHALL use a full commit SHA. + +#### Scenario: Ordinary merge + +- GIVEN an ordinary pull request merges to `main` +- WHEN the release automation runs +- THEN it MAY create or update the Release PR +- AND it SHALL NOT create a GitHub release for that merge + +#### Scenario: Release merge + +- GIVEN the Release PR proposes version `1.5.0` +- WHEN the Release PR merges and the required `main` CI workflow succeeds +- THEN Release Please SHALL create tag `v1.5.0` +- AND it SHALL create one published immutable GitHub release for `v1.5.0` +- AND `VERSION` SHALL contain `1.5.0` + +### Requirement REL-05: First release baseline + +Release Please SHALL start from version `0.0.0`. The first proposed release +SHALL be `0.1.0` when the first included release change is a feature. The first +generated changelog SHALL include only commits after the release strategy +baseline. It SHALL NOT import the earlier repository history. + +#### Scenario: First release proposal + +- GIVEN no repository release tag exists +- AND the manifest records version `0.0.0` +- WHEN the first included `feat` commit enters `main` +- THEN the Release PR SHALL propose `0.1.0` +- AND its changelog SHALL stop at the configured baseline commit + +### Requirement REL-06: Component-selective CI builds + +A normal push to `main` SHALL build only the components whose build inputs +changed. An update to the long-lived Release PR SHALL NOT build every component +only because `VERSION`, `CHANGELOG.md`, or the CI build prefix changed. A +version-only merge-queue run SHALL NOT build every component. + +When the Release PR merges to `main`, the changed `VERSION` file SHALL cause one +API server build, one control-plane build, and one web-console build from the +final `main` revision. The corresponding `main` end-to-end run SHALL wait for +these three builds. This rule SHALL apply only to a `main` push. + +#### Scenario: Normal component change + +- GIVEN the current release version is `1.5.0` +- WHEN a normal `main` commit changes only the API server +- THEN CI SHALL build the API server image +- AND it SHALL NOT build the control-plane or web-console image + +#### Scenario: Release PR update + +- GIVEN the Release PR remains open +- WHEN Release Please updates `VERSION`, `CHANGELOG.md`, or the CI build prefix +- THEN the release PR event SHALL NOT build all component images + +#### Scenario: Release merge build + +- GIVEN the Release PR changes `VERSION` from `1.5.0` to `1.6.0` +- WHEN that pull request merges to `main` +- THEN CI SHALL build the API server, control-plane, and web-console images once +- AND all three builds SHALL use the final release commit as their revision + +### Requirement REL-07: Build version format + +A supported local image build SHALL use build version +`dev-`. A CI image build SHALL use build version +`v-`. CI SHALL read the release version from +the checked-in release files. It SHALL NOT query for a live Git tag during the +image build. A build SHALL use the triggering full revision and SHALL derive the +short revision from it. + +A normal CI build after a release SHALL continue to use that release version +until the next Release PR merges. Thus, different component builds MAY have +different short revisions in the same release version. The three builds caused +by a Release PR merge SHALL have the same build version. + +#### Scenario: Local image build + +- GIVEN local `HEAD` is revision `abcdef0123456789abcdef0123456789abcdef01` +- WHEN a developer uses a supported local image build command +- THEN the image build version SHALL be `dev-abcdef0` + +#### Scenario: CI image build + +- GIVEN `VERSION` contains `1.6.0` +- AND the triggering revision is `1234567890abcdef1234567890abcdef12345678` +- WHEN CI builds a component image +- THEN its build version SHALL be `v1.6.0-1234567` + +### Requirement REL-08: Container metadata + +Each API server, control-plane, and web-console image SHALL set +`org.opencontainers.image.version` to its build version. It SHALL set +`org.opencontainers.image.revision` to its full revision. The image SHALL also +contain the build version as a runtime environment value. Static placeholder +versions SHALL NOT remain in these Containerfiles. + +#### Scenario: Inspect a CI image + +- GIVEN CI built an image with build version `v1.6.0-1234567` +- WHEN an operator inspects the image configuration +- THEN `org.opencontainers.image.version` SHALL be `v1.6.0-1234567` +- AND `org.opencontainers.image.revision` SHALL be the full triggering revision +- AND the runtime build-version value SHALL be `v1.6.0-1234567` + +### Requirement REL-09: API service metadata + +The existing `GET /api/hypershell/v1/metadata` endpoint SHALL return the API +server build version and build time. The `version` value SHALL equal the API +server image build version. The endpoint SHALL be available without user +authentication and SHALL NOT query the database. The OpenAPI contract SHALL +describe its response. Existing liveness and readiness probes SHALL keep their +current paths and meanings. + +#### Scenario: Read API build identity + +- GIVEN the API server image build version is `v1.6.0-1234567` +- WHEN a client sends an unauthenticated `GET` request to + `/api/hypershell/v1/metadata` +- THEN the response status SHALL be `200` +- AND the response `version` SHALL be `v1.6.0-1234567` +- AND the response SHALL include `build_time` + +### Requirement REL-10: Web-console version display + +The web-console image SHALL give its build version to the BFF as runtime +configuration. The BFF SHALL include only this non-secret value in the existing +browser runtime-configuration allowlist. The authenticated user menu SHALL show +the localized text `Console version ` as non-action content. The +menu SHALL remain usable when the value is unavailable and SHALL show a +localized unknown value. + +The console SHALL show its own image build version. It SHALL NOT state or imply +that the console and API server use the same revision. + +#### Scenario: Show the console build version + +- GIVEN an authenticated user uses console build `v1.6.0-1234567` +- WHEN the user opens the user menu +- THEN the menu SHALL show `Console version v1.6.0-1234567` +- AND the version row SHALL not start navigation or another action + +#### Scenario: Build version is unavailable + +- GIVEN browser runtime configuration has no valid build version +- WHEN an authenticated user opens the user menu +- THEN the menu SHALL show a localized unknown version +- AND the Log out action SHALL remain available + +### Requirement REL-11: Registry and deployment scope + +CI SHALL keep the existing full-SHA image tags that current tests and Argo image +bump consume. This release strategy SHALL NOT create semantic registry aliases, +promote images, change a Konflux ReleasePlan, or change Argo configuration. +Image promotion and deployment approval SHALL remain separate processes. + +#### Scenario: Release images become available + +- GIVEN a Release PR merges to `main` +- WHEN the three component builds finish +- THEN each existing component repository SHALL contain an image with the full + release commit SHA tag +- AND this change SHALL NOT update an Argo deployment reference + +### Requirement REL-12: Verification and documentation + +Repository checks SHALL verify conventional title validation, release-file +consistency, build-version calculation, API metadata, browser runtime +configuration, user-menu presentation, and event-specific image selection. +Release documentation SHALL describe commit types, release PR operation, +required GitHub App permissions, the manual merge step, first-release behavior, +failure recovery, and the boundary with Argo image bump. + +#### Scenario: Release automation fails after merge + +- GIVEN a Release PR has merged +- AND release automation fails before it creates the tag or GitHub release +- WHEN an operator corrects the cause and runs the automation again +- THEN the automation SHALL create at most one tag and one GitHub release +- AND it SHALL use the version already committed in `VERSION` From 0af94c345bcb6e032b951f61f9aafab72dbaebe0 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 11:03:20 -0400 Subject: [PATCH 02/16] feat(release): add managed source releases --- .github/workflows/lint.yml | 16 +- .github/workflows/release-please.yml | 67 ++++++++ .release-please-manifest.json | 3 + CHANGELOG.md | 5 + Makefile | 11 +- VERSION | 1 + build-version.env | 3 + docs/releasing.md | 73 +++++++++ release-please-config.json | 87 +++++++++++ scripts/release_policy.py | 226 +++++++++++++++++++++++++++ scripts/test_release_policy.py | 119 ++++++++++++++ skills/RECONCILE.md | 24 +++ 12 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 VERSION create mode 100644 build-version.env create mode 100644 docs/releasing.md create mode 100644 release-please-config.json create mode 100644 scripts/release_policy.py create mode 100644 scripts/test_release_policy.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6f8b14ea..5e6f073f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,6 +18,18 @@ concurrency: cancel-in-progress: true jobs: + lint-pull-request-title: + name: Conventional pull request title + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check pull request title + env: + PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} + run: python3 scripts/release_policy.py check-title "${PULL_REQUEST_TITLE}" + detect-changes: name: Detect changed components runs-on: ubuntu-24.04 @@ -249,6 +261,7 @@ jobs: name: Lint CI gate if: ${{ always() && !cancelled() }} needs: + - lint-pull-request-title - detect-changes - lint-api-server - lint-cli @@ -262,6 +275,7 @@ jobs: - name: Check lint results env: DETECTION_RESULT: ${{ needs.detect-changes.result }} + PULL_REQUEST_TITLE_RESULT: ${{ needs.lint-pull-request-title.result }} API_SERVER_RESULT: ${{ needs.lint-api-server.result }} CLI_RESULT: ${{ needs.lint-cli.result }} CONTROL_PLANE_RESULT: ${{ needs.lint-control-plane.result }} @@ -270,7 +284,7 @@ jobs: SDK_TYPESCRIPT_RESULT: ${{ needs.lint-sdk-typescript.result }} WEB_CONSOLE_RESULT: ${{ needs.lint-web-console.result }} run: | - for result in "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do + for result in "${PULL_REQUEST_TITLE_RESULT}" "${DETECTION_RESULT}" "${API_SERVER_RESULT}" "${CLI_RESULT}" "${CONTROL_PLANE_RESULT}" "${GATEWAY_MANAGEMENT_UI_RESULT}" "${PR_TEST_RESULT}" "${SDK_TYPESCRIPT_RESULT}" "${WEB_CONSOLE_RESULT}"; do if [[ "${result}" == failure || "${result}" == cancelled ]]; then echo "One or more lint jobs failed or were cancelled." exit 1 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..b9973f3a --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,67 @@ +name: Release Please + +on: + workflow_run: + workflows: + - E2E + types: + - completed + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-please-${{ github.repository }} + cancel-in-progress: false + +jobs: + release-please: + name: Update or publish the source release + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main') + runs-on: ubuntu-24.04 + steps: + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-issues: write + permission-pull-requests: write + + - name: Check out the tested revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + token: ${{ steps.app-token.outputs.token }} + + - name: Decide whether Release Please must run + id: gate + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + HEAD_REF: ${{ github.event.workflow_run.head_sha || github.sha }} + run: | + open_count="$(gh pr list --base main --label 'autorelease: pending' --state open --json number --jq 'length')" + if [[ "${open_count}" -gt 0 ]]; then + open_release_pr=true + else + open_release_pr=false + fi + decision="$(python3 scripts/release_policy.py should-run --head-ref "${HEAD_REF}" --open-release-pr "${open_release_pr}")" + echo "run=${decision}" >> "${GITHUB_OUTPUT}" + + - name: Update the Release PR or publish the release + if: steps.gate.outputs.run == 'true' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..e18ee077 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c80939ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.0 + +This version is the source-release automation baseline. diff --git a/Makefile b/Makefile index f4225254..8006bf56 100644 --- a/Makefile +++ b/Makefile @@ -128,6 +128,7 @@ help: @echo " check-dependency-pins Verify dependency version pins" @echo " check-dependency-age Verify dependency minimum age" @echo " check-ci-components Verify CI component registration" + @echo " check-release-policy Verify source-release controls and files" @echo "" @echo " Hooks" @echo " hooks-install Install Git hooks (lefthook)" @@ -204,8 +205,16 @@ test-dependency-age-policy: check-dependency-age: test-dependency-age-policy PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_dependency_age.py --min-age-days $(DEPENDENCY_MIN_AGE_DAYS) +.PHONY: test-release-policy +test-release-policy: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_release_policy.py + +.PHONY: check-release-policy +check-release-policy: test-release-policy + PYTHONDONTWRITEBYTECODE=1 python3 scripts/release_policy.py check-files + .PHONY: check -check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age +check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age check-release-policy # ============================================================================ # Git hooks diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..77d6f4ca --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.0 diff --git a/build-version.env b/build-version.env new file mode 100644 index 00000000..c4571f3f --- /dev/null +++ b/build-version.env @@ -0,0 +1,3 @@ +# x-release-please-start-version +BUILD_PREFIX=v0.0.0 +# x-release-please-end diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..8fd78326 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,73 @@ +# Source Releases + +HyperShell uses Conventional Commits and Release Please. Release Please keeps +one Release PR open against `main`. The PR is not a release candidate tag. It +is the list of changes for the next source release. + +## Pull Request Titles + +Use this form for each pull request title: + +```text +(): +``` + +The allowed types are `feat`, `fix`, `perf`, `refactor`, `docs`, `spec`, +`deps`, `build`, `ci`, `test`, `style`, `chore`, and `revert`. The `feat` type +increments the minor version. The `fix` type increments the patch version. A +`!` marker or a `BREAKING CHANGE` footer increments the major version. Before +`1.0.0`, a breaking change increments the minor version. Other types enter the +next changelog, but they do not open a Release PR by themselves. + +GitHub uses the pull request title as the squash commit subject. Do not change +the subject during merge. + +## Release Operation + +The Release Please workflow runs after a successful `main` E2E workflow. It +opens a Release PR after a feature, fix, or breaking change enters `main`. If a +Release PR is open, later `main` commits update the same PR. The PR updates +`CHANGELOG.md`, `VERSION`, `.release-please-manifest.json`, and +`build-version.env`. + +Keep the Release PR open until the contents are ready. Merge it through the +normal merge queue to start a release. Do not enable automatic merge for this +PR. After the final `main` E2E workflow succeeds, Release Please creates the +`vX.Y.Z` tag and the published GitHub release. Immutable releases must remain +enabled in the repository settings. + +The first manifest version is `0.0.0`. The history baseline is the commit +before this release strategy. Thus, the first feature proposes `0.1.0`, and the +first changelog does not contain older repository history. + +## GitHub App Setup + +Create and install a GitHub App for Release Please. Give the installation these +repository permissions: + +- Contents: Read and write +- Issues: Read and write +- Pull requests: Read and write + +Set the app client ID in the `RELEASE_PLEASE_APP_CLIENT_ID` repository +variable. Set the private key in the `RELEASE_PLEASE_APP_PRIVATE_KEY` +repository secret. The app token lets checks run on a Release PR that the app +creates or updates. + +## Failure Recovery + +If release automation fails, correct the cause and run the Release Please +workflow from the Actions page. The workflow uses the committed release files +and can run again safely. Do not create the tag or GitHub release by hand while +this recovery is in progress. + +## Image and Deployment Boundary + +Normal `main` pushes build only components with changed build inputs. Release +PR updates do not build all images. When the Release PR merges, its `VERSION` +change makes the final `main` push build the API server, control plane, and web +console once. + +The build keeps the existing full-SHA registry tags. Argo image bump continues +to select deployment images. This process does not promote images, create +semantic registry aliases, or change Argo configuration. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..9a843da8 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "8c2e9fb4ce76d320e399cbc7cf6a2f338308d746", + "always-update": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "include-component-in-tag": false, + "include-v-in-tag": true, + "include-v-in-release-name": true, + "group-pull-request-title-pattern": "chore${scope}: release${component} ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features", + "hidden": false + }, + { + "type": "fix", + "section": "Bug Fixes", + "hidden": false + }, + { + "type": "perf", + "section": "Performance", + "hidden": false + }, + { + "type": "refactor", + "section": "Code Refactoring", + "hidden": false + }, + { + "type": "docs", + "section": "Documentation", + "hidden": false + }, + { + "type": "spec", + "section": "Specifications", + "hidden": false + }, + { + "type": "deps", + "section": "Dependencies", + "hidden": false + }, + { + "type": "build", + "section": "Build System", + "hidden": false + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": false + }, + { + "type": "test", + "section": "Tests", + "hidden": false + }, + { + "type": "style", + "section": "Styles", + "hidden": false + }, + { + "type": "chore", + "section": "Maintenance", + "hidden": false + }, + { + "type": "revert", + "section": "Reverts", + "hidden": false + } + ], + "packages": { + ".": { + "release-type": "simple", + "version-file": "VERSION", + "extra-files": [ + "build-version.env" + ] + } + } +} diff --git a/scripts/release_policy.py b/scripts/release_policy.py new file mode 100644 index 00000000..2bb04721 --- /dev/null +++ b/scripts/release_policy.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Check the repository source-release policy.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess +import sys + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +TITLE_FORM = "(): " +ALLOWED_TYPES = ( + "build", + "chore", + "ci", + "deps", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "spec", + "style", + "test", +) +_TYPE_PATTERN = "|".join(ALLOWED_TYPES) +_TITLE_PATTERN = re.compile( + rf"^(?P{_TYPE_PATTERN})" + r"(?:\((?P[a-z0-9][a-z0-9._/-]*)\))?" + r"(?P!)?: (?P\S(?:.*\S)?)$" +) +_SEMVER_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") +_RELEASE_TITLE_PATTERN = re.compile(r"^chore\(main\): release (?P" + _SEMVER_PATTERN.pattern + r")$") +_BREAKING_FOOTER_PATTERN = re.compile( + r"(?m)^BREAKING(?: |-)+CHANGE:\s+\S" +) + + +class ReleasePolicyError(Exception): + """Report a release policy input error.""" + + +def title_error(title: str) -> str | None: + """Return an error for an invalid pull request title.""" + if _TITLE_PATTERN.fullmatch(title): + return None + allowed = ", ".join(ALLOWED_TYPES) + return f"title must use {TITLE_FORM}; allowed types: {allowed}" + + +def is_releasing_commit(message: str) -> bool: + """Return true when one commit must run Release Please.""" + header = message.splitlines()[0] if message else "" + if _RELEASE_TITLE_PATTERN.fullmatch(header): + return True + match = _TITLE_PATTERN.fullmatch(header) + if match and ( + match.group("type") in {"feat", "fix"} or match.group("breaking") + ): + return True + return _BREAKING_FOOTER_PATTERN.search(message) is not None + + +def should_run_release(open_release_pr: bool, messages: list[str]) -> bool: + """Return true when the release workflow must run.""" + return open_release_pr or any(is_releasing_commit(message) for message in messages) + + +def _git_output(*arguments: str) -> str: + result = subprocess.run( + ("git", *arguments), + cwd=REPOSITORY_ROOT, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "git command failed" + raise ReleasePolicyError(detail) + return result.stdout + + +def _load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleasePolicyError(f"cannot read {path}: {error}") from error + + +def _release_base(head_ref: str) -> str: + tags = _git_output( + "tag", "--merged", head_ref, "--sort=-version:refname", "--list", "v[0-9]*" + ).splitlines() + for tag in tags: + if re.fullmatch(r"v" + _SEMVER_PATTERN.pattern, tag): + return tag + + config = _load_json(REPOSITORY_ROOT / "release-please-config.json") + if not isinstance(config, dict): + raise ReleasePolicyError("release-please-config.json must contain an object") + baseline = config.get("bootstrap-sha") + if not isinstance(baseline, str) or not re.fullmatch(r"[0-9a-f]{40}", baseline): + raise ReleasePolicyError("release-please-config.json has no valid bootstrap-sha") + return baseline + + +def _messages_since_release(head_ref: str) -> list[str]: + base_ref = _release_base(head_ref) + output = _git_output("log", "--format=%B%x00", f"{base_ref}..{head_ref}") + return [message.strip() for message in output.split("\0") if message.strip()] + + +def release_file_errors(root: Path) -> list[str]: + """Return all release-file consistency errors.""" + errors: list[str] = [] + try: + version = (root / "VERSION").read_text(encoding="utf-8").strip() + except OSError as error: + return [f"cannot read VERSION: {error}"] + if not _SEMVER_PATTERN.fullmatch(version): + errors.append("VERSION must contain one stable semantic version") + + try: + build_lines = (root / "build-version.env").read_text( + encoding="utf-8" + ).splitlines() + except OSError as error: + errors.append(f"cannot read build-version.env: {error}") + build_lines = [] + prefixes = [line.removeprefix("BUILD_PREFIX=") for line in build_lines if line.startswith("BUILD_PREFIX=")] + if prefixes != [f"v{version}"]: + errors.append("build-version.env BUILD_PREFIX must equal v plus VERSION") + + try: + manifest = _load_json(root / ".release-please-manifest.json") + except ReleasePolicyError as error: + errors.append(str(error)) + manifest = None + if not isinstance(manifest, dict) or manifest.get(".") != version: + errors.append("the release manifest root version must equal VERSION") + + try: + config = _load_json(root / "release-please-config.json") + except ReleasePolicyError as error: + errors.append(str(error)) + config = None + if isinstance(config, dict): + packages = config.get("packages") + root_package = packages.get(".") if isinstance(packages, dict) else None + if not isinstance(root_package, dict): + errors.append("the Release Please root package is missing") + else: + if root_package.get("release-type") != "simple": + errors.append("the Release Please root package must use the simple type") + if root_package.get("version-file") != "VERSION": + errors.append("Release Please must manage VERSION") + if "build-version.env" not in root_package.get("extra-files", []): + errors.append("Release Please must manage build-version.env") + if config.get("bump-minor-pre-major") is not True: + errors.append("breaking changes before 1.0.0 must increment the minor version") + if config.get("bump-patch-for-minor-pre-major") is not False: + errors.append("features before 1.0.0 must increment the minor version") + if config.get("always-update") is not True: + errors.append("Release Please must update an open Release PR") + if config.get("include-v-in-tag") is not True: + errors.append("release tags must have a v prefix") + sections = config.get("changelog-sections") + visible_types = { + item.get("type") + for item in sections + if isinstance(item, dict) and item.get("hidden") is False + } if isinstance(sections, list) else set() + if visible_types != set(ALLOWED_TYPES): + errors.append("each allowed commit type must have a visible changelog section") + elif config is not None: + errors.append("release-please-config.json must contain an object") + return errors + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + title = commands.add_parser("check-title", help="check one pull request title") + title.add_argument("title") + + gate = commands.add_parser("should-run", help="check whether Release Please must run") + gate.add_argument("--head-ref", default="HEAD") + gate.add_argument("--open-release-pr", choices=("true", "false"), default="false") + + files = commands.add_parser("check-files", help="check managed release files") + files.add_argument("--root", type=Path, default=REPOSITORY_ROOT) + return parser + + +def main() -> int: + arguments = _build_parser().parse_args() + try: + if arguments.command == "check-title": + error = title_error(arguments.title) + if error: + print(error, file=sys.stderr) + return 1 + return 0 + if arguments.command == "should-run": + messages = _messages_since_release(arguments.head_ref) + decision = should_run_release(arguments.open_release_pr == "true", messages) + print(str(decision).lower()) + return 0 + errors = release_file_errors(arguments.root) + for error in errors: + print(f"release policy error: {error}", file=sys.stderr) + return 1 if errors else 0 + except ReleasePolicyError as error: + print(f"release policy error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_release_policy.py b/scripts/test_release_policy.py new file mode 100644 index 00000000..2c05548d --- /dev/null +++ b/scripts/test_release_policy.py @@ -0,0 +1,119 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +SCRIPT_PATH = Path(__file__).with_name("release_policy.py") +SPEC = importlib.util.spec_from_file_location("release_policy", SCRIPT_PATH) +POLICY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(POLICY) + + +class PullRequestTitleTest(unittest.TestCase): + def test_accepts_supported_titles(self): + for title in ( + "feat: add releases", + "fix(api): return the version", + "feat(web-console)!: change the menu", + "chore(main): release 0.1.0", + ): + with self.subTest(title=title): + self.assertIsNone(POLICY.title_error(title)) + + def test_rejects_invalid_titles(self): + for title in ( + "Add releases", + "Feature: add releases", + "feat(API): add releases", + "feat: ", + "feat add releases", + "merge: add releases", + ): + with self.subTest(title=title): + self.assertIsNotNone(POLICY.title_error(title)) + + +class ReleaseGateTest(unittest.TestCase): + def test_runs_for_release_changes(self): + for message in ( + "feat: add releases", + "fix(api): return the version", + "refactor(api)!: change the response", + "refactor(api): change the response\n\nBREAKING CHANGE: clients must update", + "chore(main): release 1.2.3", + ): + with self.subTest(message=message): + self.assertTrue(POLICY.is_releasing_commit(message)) + + def test_does_not_run_for_maintenance_only(self): + messages = ["docs: explain releases", "chore(deps): update a tool"] + self.assertFalse(POLICY.should_run_release(False, messages)) + + def test_updates_an_open_release_pull_request(self): + self.assertTrue(POLICY.should_run_release(True, ["docs: explain releases"])) + + +class ReleaseFilesTest(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + (self.root / "VERSION").write_text("1.2.3\n", encoding="utf-8") + (self.root / "build-version.env").write_text( + "# x-release-please-start-version\n" + "BUILD_PREFIX=v1.2.3\n" + "# x-release-please-end\n", + encoding="utf-8", + ) + (self.root / ".release-please-manifest.json").write_text( + json.dumps({".": "1.2.3"}), encoding="utf-8" + ) + config = { + "always-update": True, + "bump-minor-pre-major": True, + "bump-patch-for-minor-pre-major": False, + "include-v-in-tag": True, + "changelog-sections": [ + {"type": commit_type, "section": commit_type, "hidden": False} + for commit_type in POLICY.ALLOWED_TYPES + ], + "packages": { + ".": { + "release-type": "simple", + "version-file": "VERSION", + "extra-files": ["build-version.env"], + } + }, + } + (self.root / "release-please-config.json").write_text( + json.dumps(config), encoding="utf-8" + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_accepts_consistent_files(self): + self.assertEqual([], POLICY.release_file_errors(self.root)) + + def test_rejects_a_build_prefix_mismatch(self): + (self.root / "build-version.env").write_text( + "BUILD_PREFIX=v1.2.2\n", encoding="utf-8" + ) + self.assertIn( + "build-version.env BUILD_PREFIX must equal v plus VERSION", + POLICY.release_file_errors(self.root), + ) + + def test_rejects_a_manifest_mismatch(self): + (self.root / ".release-please-manifest.json").write_text( + json.dumps({".": "1.2.2"}), encoding="utf-8" + ) + self.assertIn( + "the release manifest root version must equal VERSION", + POLICY.release_file_errors(self.root), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 7228737a..a8314698 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -99,6 +99,30 @@ Layer 7: web-console/architecture (depends on data-model, security, UI ## Gap Table +### source-release.spec.md + +| # | Requirement | Status | Gap | Code Location | Wave | +|---|-------------|--------|-----|---------------|------| +| REL-01 | Conventional pull request titles | Present | - | `scripts/release_policy.py`, `.github/workflows/lint.yml` | REL-W1 | +| REL-02 | Managed Release PR | Present | - | `.github/workflows/release-please.yml`, `release-please-config.json` | REL-W1 | +| REL-03 | Release files | Present | - | `VERSION`, `CHANGELOG.md`, `build-version.env`, `.release-please-manifest.json` | REL-W1 | +| REL-04 | Release publication | Present | The repository owner must set the documented GitHub App variable and secret. | `.github/workflows/release-please.yml`, `docs/releasing.md` | REL-W1 | +| REL-05 | First release baseline | Present | - | `release-please-config.json`, `.release-please-manifest.json` | REL-W1 | +| REL-06 | Component-selective CI builds | Missing | `VERSION` does not yet start the three final push builds. | `.tekton/`, `.github/workflows/e2e.yml` | REL-W2 | +| REL-07 | Build version format | Missing | Component builds do not yet use the managed build prefix. | `Makefile`, `scripts/kind/`, `.tekton/` | REL-W2 | +| REL-08 | Container metadata | Missing | The three image files still use static versions. | `components/*/Dockerfile` | REL-W2 | +| REL-09 | API service metadata | Partial | The route exists, but its contract and build stamp are not complete. | `components/api-server/` | REL-W3 | +| REL-10 | Web-console version display | Missing | Runtime configuration and the user-menu row are absent. | `components/web-console/` | REL-W4 | +| REL-11 | Registry and deployment scope | Present | - | `.tekton/`, `docs/releasing.md` | REL-W1 | +| REL-12 | Verification and documentation | Partial | Release controls have tests and documentation. Image, API, and console checks remain. | `scripts/test_release_policy.py`, `docs/releasing.md` | REL-W1..REL-W4 | + +**Wave plan:** + +- REL-W1 adds the managed source-release control and policy checks. +- REL-W2 adds deterministic image identity and final-release build selection. +- REL-W3 makes the existing API metadata route report the image identity. +- REL-W4 adds the console identity to runtime configuration and the user menu. + ### openshell-gateway-console.spec.md | # | Requirement | Status | Gap | Code Location | Wave | From b41cb8cc13dd83905bb1284e4b163221cb531ccf Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 11:08:52 -0400 Subject: [PATCH 03/16] build(version): embed component image identity --- .github/workflows/e2e.yml | 14 +++- ...ypershell-api-server-main-merge-queue.yaml | 9 +- ...pershell-api-server-main-pull-request.yaml | 9 +- .tekton/hypershell-api-server-main-push.yaml | 11 ++- ...rshell-control-plane-main-merge-queue.yaml | 5 ++ ...shell-control-plane-main-pull-request.yaml | 5 ++ .../hypershell-control-plane-main-push.yaml | 7 +- ...pershell-web-console-main-merge-queue.yaml | 5 ++ ...ershell-web-console-main-pull-request.yaml | 5 ++ .tekton/hypershell-web-console-main-push.yaml | 8 +- Makefile | 28 +++++-- components/api-server/Dockerfile | 35 +++++--- components/api-server/Makefile | 7 +- components/control-plane/Dockerfile | 12 ++- components/web-console/Dockerfile | 11 ++- scripts/build-version.sh | 34 ++++++++ scripts/check_image_build_policy.py | 84 +++++++++++++++++++ scripts/kind/build-images.sh | 18 +++- scripts/kind/swap-component.sh | 8 +- scripts/test_build_version.py | 61 ++++++++++++++ skills/RECONCILE.md | 6 +- 21 files changed, 334 insertions(+), 48 deletions(-) create mode 100755 scripts/build-version.sh create mode 100644 scripts/check_image_build_policy.py create mode 100644 scripts/test_build_version.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9690043e..8edf1475 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -77,18 +77,26 @@ jobs: api_server=false control_plane=false web_console=false + release_version_changed=false + + # A release version selects all three component builds only after the + # Release PR merges to main. Pull request and merge queue events do not + # use this rule. + if [[ "${EVENT_NAME}" == "push" ]] && grep -qx "VERSION" <<<"${changed_files}"; then + release_version_changed=true + fi # These patterns MUST mirror each component's event-specific Konflux # CEL trigger in .tekton/hypershell--main-${pipeline_suffix}.yaml. # Only components whose path-filtered pipelines actually run may use # the event's commit-tagged image and wait for the corresponding check. - if grep -qE "^components/api-server/|^\\.tekton/hypershell-api-server-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then + if [[ "${release_version_changed}" == "true" ]] || grep -qE "^components/api-server/|^\\.tekton/hypershell-api-server-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then api_server=true fi - if grep -qE "^components/control-plane/|^\\.tekton/hypershell-control-plane-main-${pipeline_suffix}\\.yaml$|^Dockerfile$" <<<"${changed_files}"; then + if [[ "${release_version_changed}" == "true" ]] || grep -qE "^components/control-plane/|^\\.tekton/hypershell-control-plane-main-${pipeline_suffix}\\.yaml$|^Dockerfile$" <<<"${changed_files}"; then control_plane=true fi - if grep -qE "^components/web-console/|^packages/gateway-management-ui/|^\\.tekton/hypershell-web-console-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then + if [[ "${release_version_changed}" == "true" ]] || grep -qE "^components/web-console/|^packages/gateway-management-ui/|^\\.tekton/hypershell-web-console-main-${pipeline_suffix}\\.yaml$" <<<"${changed_files}"; then web_console=true fi diff --git a/.tekton/hypershell-api-server-main-merge-queue.yaml b/.tekton/hypershell-api-server-main-merge-queue.yaml index ce00b8c7..370c37f1 100644 --- a/.tekton/hypershell-api-server-main-merge-queue.yaml +++ b/.tekton/hypershell-api-server-main-merge-queue.yaml @@ -28,9 +28,14 @@ spec: - name: image-expires-after value: 5d - name: dockerfile - value: Dockerfile + value: components/api-server/Dockerfile - name: path-context - value: components/api-server + value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-api-server-main-pull-request.yaml b/.tekton/hypershell-api-server-main-pull-request.yaml index b8d43297..6acec65d 100644 --- a/.tekton/hypershell-api-server-main-pull-request.yaml +++ b/.tekton/hypershell-api-server-main-pull-request.yaml @@ -28,9 +28,14 @@ spec: - name: image-expires-after value: 5d - name: dockerfile - value: Dockerfile + value: components/api-server/Dockerfile - name: path-context - value: components/api-server + value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-api-server-main-push.yaml b/.tekton/hypershell-api-server-main-push.yaml index a12ce078..7c388b86 100644 --- a/.tekton/hypershell-api-server-main-push.yaml +++ b/.tekton/hypershell-api-server-main-push.yaml @@ -9,7 +9,7 @@ metadata: pipelinesascode.tekton.dev/max-keep-runs: "3" pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" && ( "components/api-server/***".pathChanged() || ".tekton/hypershell-api-server-main-push.yaml".pathChanged() - ) + || "VERSION".pathChanged() ) labels: appstudio.openshift.io/application: hypershell-main appstudio.openshift.io/component: hypershell-api-server-main @@ -25,9 +25,14 @@ spec: - name: output-image value: quay.io/redhat-user-workloads/hcm-eng-prod-tenant/hypershell-main/hypershell-api-server-main:{{revision}} - name: dockerfile - value: Dockerfile + value: components/api-server/Dockerfile - name: path-context - value: components/api-server + value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-control-plane-main-merge-queue.yaml b/.tekton/hypershell-control-plane-main-merge-queue.yaml index f5348de5..f1d58277 100644 --- a/.tekton/hypershell-control-plane-main-merge-queue.yaml +++ b/.tekton/hypershell-control-plane-main-merge-queue.yaml @@ -31,6 +31,11 @@ spec: value: components/control-plane/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-control-plane-main-pull-request.yaml b/.tekton/hypershell-control-plane-main-pull-request.yaml index 0011db64..bfa85096 100644 --- a/.tekton/hypershell-control-plane-main-pull-request.yaml +++ b/.tekton/hypershell-control-plane-main-pull-request.yaml @@ -31,6 +31,11 @@ spec: value: components/control-plane/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-control-plane-main-push.yaml b/.tekton/hypershell-control-plane-main-push.yaml index f0644b30..6341b14e 100644 --- a/.tekton/hypershell-control-plane-main-push.yaml +++ b/.tekton/hypershell-control-plane-main-push.yaml @@ -9,7 +9,7 @@ metadata: pipelinesascode.tekton.dev/max-keep-runs: "3" pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" && ( "components/control-plane/***".pathChanged() || ".tekton/hypershell-control-plane-main-push.yaml".pathChanged() - || "Dockerfile".pathChanged() ) + || "Dockerfile".pathChanged() || "VERSION".pathChanged() ) labels: appstudio.openshift.io/application: hypershell-main appstudio.openshift.io/component: hypershell-control-plane-main @@ -28,6 +28,11 @@ spec: value: components/control-plane/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-web-console-main-merge-queue.yaml b/.tekton/hypershell-web-console-main-merge-queue.yaml index 57f86c5f..7651daca 100644 --- a/.tekton/hypershell-web-console-main-merge-queue.yaml +++ b/.tekton/hypershell-web-console-main-merge-queue.yaml @@ -31,6 +31,11 @@ spec: value: components/web-console/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-web-console-main-pull-request.yaml b/.tekton/hypershell-web-console-main-pull-request.yaml index d8be1d7e..fdf3e3dc 100644 --- a/.tekton/hypershell-web-console-main-pull-request.yaml +++ b/.tekton/hypershell-web-console-main-pull-request.yaml @@ -31,6 +31,11 @@ spec: value: components/web-console/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/.tekton/hypershell-web-console-main-push.yaml b/.tekton/hypershell-web-console-main-push.yaml index f92d8bb8..00eea1a7 100644 --- a/.tekton/hypershell-web-console-main-push.yaml +++ b/.tekton/hypershell-web-console-main-push.yaml @@ -9,7 +9,8 @@ metadata: pipelinesascode.tekton.dev/max-keep-runs: "3" pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" && ( "components/web-console/***".pathChanged() || "packages/gateway-management-ui/***".pathChanged() - || ".tekton/hypershell-web-console-main-push.yaml".pathChanged() ) + || ".tekton/hypershell-web-console-main-push.yaml".pathChanged() || "VERSION".pathChanged() + ) labels: appstudio.openshift.io/application: hypershell-main appstudio.openshift.io/component: hypershell-web-console-main @@ -28,6 +29,11 @@ spec: value: components/web-console/Dockerfile - name: path-context value: . + - name: build-args + value: + - VCS_REF={{revision}} + - name: build-args-file + value: build-version.env pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while reducing network traffic. diff --git a/Makefile b/Makefile index 8006bf56..b1000d8e 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,10 @@ PNPM?=pnpm IMAGE_REGISTRY?=quay.io/redhat-services-prod/hcm-eng-prod-tenant/hypershell-main IMAGE_TAG?=latest -# Build version (embedded in api-server binary via ldflags) -git_sha:=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) -git_dirty:=$(shell git diff --quiet 2>/dev/null || echo -modified) -build_version:=$(git_sha)$(git_dirty) +# Build identity for supported local image builds +vcs_ref:=$(shell git rev-parse HEAD 2>/dev/null || echo unknown) +build_prefix:=dev +build_version:=$(shell HYPERSHELL_VCS_REF=$(vcs_ref) scripts/build-version.sh local 2>/dev/null || echo dev-unknown) build_time:=$(shell date -u '+%Y-%m-%d %H:%M:%S UTC') # Computed baseline references (registry images used in Kind manifests) @@ -129,6 +129,7 @@ help: @echo " check-dependency-age Verify dependency minimum age" @echo " check-ci-components Verify CI component registration" @echo " check-release-policy Verify source-release controls and files" + @echo " check-image-build-policy Verify image identity and selective build rules" @echo "" @echo " Hooks" @echo " hooks-install Install Git hooks (lefthook)" @@ -156,12 +157,14 @@ install-js: verify-pnpm .PHONY: build-api-server build-api-server: $(CONTAINER_ENGINE) build -t $(api_server_local) \ - --build-arg GIT_VERSION=$(build_version) --build-arg BUILD_TIME="$(build_time)" \ - components/api-server + --build-arg BUILD_PREFIX=$(build_prefix) --build-arg VCS_REF=$(vcs_ref) \ + --build-arg BUILD_TIME="$(build_time)" \ + -f components/api-server/Dockerfile . .PHONY: build-controller build-controller: $(CONTAINER_ENGINE) build -t $(control_plane_local) \ + --build-arg BUILD_PREFIX=$(build_prefix) --build-arg VCS_REF=$(vcs_ref) \ -f components/control-plane/Dockerfile . .PHONY: build-cli @@ -171,6 +174,7 @@ build-cli: .PHONY: build-web-console build-web-console: $(CONTAINER_ENGINE) build -t $(web_console_local) \ + --build-arg BUILD_PREFIX=$(build_prefix) --build-arg VCS_REF=$(vcs_ref) \ -f components/web-console/Dockerfile . # ============================================================================ @@ -213,8 +217,16 @@ test-release-policy: check-release-policy: test-release-policy PYTHONDONTWRITEBYTECODE=1 python3 scripts/release_policy.py check-files +.PHONY: test-build-version +test-build-version: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_build_version.py + +.PHONY: check-image-build-policy +check-image-build-policy: test-build-version + PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_image_build_policy.py + .PHONY: check -check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age check-release-policy +check: check-forbidden-terms check-dependency-pins check-ci-components check-dependency-age check-release-policy check-image-build-policy # ============================================================================ # Git hooks @@ -308,7 +320,7 @@ export IMAGE_REGISTRY IMAGE_TAG KIND_CONFIG export api_server_ref control_plane_ref web_console_ref export API_SERVER_IMAGE CONTROL_PLANE_IMAGE WEB_CONSOLE_IMAGE export api_server_local control_plane_local web_console_local -export build_version build_time +export vcs_ref build_prefix build_version build_time export API_HOSTNAME CONSOLE_HOSTNAME HEALTH_HOSTNAME KEYCLOAK_HOSTNAME KEYCLOAK_OIDC_ISSUER export KIND_DNS_PORT diff --git a/components/api-server/Dockerfile b/components/api-server/Dockerfile index 624a6945..0ffc0ae0 100644 --- a/components/api-server/Dockerfile +++ b/components/api-server/Dockerfile @@ -1,27 +1,39 @@ # syntax=docker/dockerfile:1 +ARG BUILD_PREFIX=dev +ARG VCS_REF=unknown + FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder WORKDIR /workspace -COPY go.mod go.sum ./ +COPY components/api-server/go.mod components/api-server/go.sum ./ RUN go mod edit -dropreplace github.com/openshift-online/rh-trex-ai && go mod download -COPY cmd/ cmd/ -COPY pkg/ pkg/ -COPY plugins/ plugins/ -COPY openapi/ openapi/ +COPY components/api-server/cmd/ cmd/ +COPY components/api-server/pkg/ pkg/ +COPY components/api-server/plugins/ plugins/ +COPY components/api-server/openapi/ openapi/ -ARG GIT_VERSION=unknown +ARG BUILD_PREFIX +ARG VCS_REF ARG BUILD_TIME=unknown -RUN CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w \ - -X github.com/openshift-online/hypershell/components/api-server/pkg/api.Version=${GIT_VERSION} \ - -X 'github.com/openshift-online/hypershell/components/api-server/pkg/api.BuildTime=${BUILD_TIME}'" \ - -o hypershell ./cmd/hypershell +RUN short_ref="${VCS_REF%?????????????????????????????????}" \ + && build_version="${BUILD_PREFIX}-${short_ref}" \ + && CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w \ + -X github.com/openshift-online/rh-trex-ai/pkg/api.Version=${build_version} \ + -X 'github.com/openshift-online/rh-trex-ai/pkg/api.BuildTime=${BUILD_TIME}'" \ + -o hypershell ./cmd/hypershell FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 +ARG BUILD_PREFIX +ARG VCS_REF + COPY --from=builder /workspace/hypershell /usr/local/bin/ +ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + HYPERSHELL_BUILD_REVISION="${VCS_REF}" + EXPOSE 8000 EXPOSE 9000 @@ -30,4 +42,5 @@ CMD ["serve"] LABEL org.opencontainers.image.title="HyperShell API Server" \ org.opencontainers.image.description="REST + gRPC API server for HyperShell fleet management" \ - org.opencontainers.image.version="0.0.1" + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + org.opencontainers.image.revision="${VCS_REF}" diff --git a/components/api-server/Makefile b/components/api-server/Makefile index e8f0be3b..b589697a 100644 --- a/components/api-server/Makefile +++ b/components/api-server/Makefile @@ -12,11 +12,10 @@ SDK_GO_MODULE=github.com/openshift-online/hypershell/components/sdk-go CLI_MODULE=github.com/openshift-online/hypershell/components/cli # Version information for ldflags -git_sha:=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") -git_dirty:=$(shell git diff --quiet 2>/dev/null || echo "-modified") -build_version:=$(git_sha)$(git_dirty) +git_sha:=$(shell git rev-parse HEAD 2>/dev/null || echo "unknown") +build_version:=$(shell HYPERSHELL_VCS_REF=$(git_sha) ../../scripts/build-version.sh local 2>/dev/null || echo "dev-unknown") build_time:=$(shell date -u '+%Y-%m-%d %H:%M:%S UTC') -ldflags=-X github.com/openshift-online/hypershell/components/api-server/pkg/api.Version=$(build_version) -X 'github.com/openshift-online/hypershell/components/api-server/pkg/api.BuildTime=$(build_time)' +ldflags=-X github.com/openshift-online/rh-trex-ai/pkg/api.Version=$(build_version) -X 'github.com/openshift-online/rh-trex-ai/pkg/api.BuildTime=$(build_time)' .PHONY: binary binary: diff --git a/components/control-plane/Dockerfile b/components/control-plane/Dockerfile index aa43f90d..5d35a8ac 100644 --- a/components/control-plane/Dockerfile +++ b/components/control-plane/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1 +ARG BUILD_PREFIX=dev +ARG VCS_REF=unknown + FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder WORKDIR /workspace @@ -20,11 +23,18 @@ RUN cd components/control-plane && CGO_ENABLED=0 go build -mod=mod -ldflags="-s FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 +ARG BUILD_PREFIX +ARG VCS_REF + COPY --from=builder /workspace/hypershell-controller /usr/local/bin/ COPY components/control-plane/manifests/ /manifests/ +ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + HYPERSHELL_BUILD_REVISION="${VCS_REF}" + ENTRYPOINT ["/usr/local/bin/hypershell-controller"] LABEL org.opencontainers.image.title="HyperShell Controller" \ org.opencontainers.image.description="Control plane that reconciles HyperShell resources via gRPC watch streams" \ - org.opencontainers.image.version="0.0.1" + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + org.opencontainers.image.revision="${VCS_REF}" diff --git a/components/web-console/Dockerfile b/components/web-console/Dockerfile index 52f9c821..fadbdf14 100644 --- a/components/web-console/Dockerfile +++ b/components/web-console/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1 +ARG BUILD_PREFIX=dev +ARG VCS_REF=unknown + FROM registry.access.redhat.com/hi/nodejs:24.18.1-builder@sha256:e2a04f7443db3d27751ed6e9d74ee5c3a02217dbf6c8769e468ef3b1b2a3e6fa AS build USER root @@ -30,11 +33,17 @@ RUN pnpm run build:web \ FROM registry.access.redhat.com/hi/nodejs:24.18.1@sha256:07b0f6cf5dabef30b1efa030eea020095b1ea227c088dbcbe0ebd35bb24b7ad9 +ARG BUILD_PREFIX +ARG VCS_REF + LABEL org.opencontainers.image.title="HyperShell web console" \ org.opencontainers.image.description="HyperShell web console BFF and static application" \ - org.opencontainers.image.version="0.0.1" + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + org.opencontainers.image.revision="${VCS_REF}" ENV HOST=0.0.0.0 \ + HYPERSHELL_BUILD_REVISION="${VCS_REF}" \ + HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ HYPERSHELL_API_ORIGIN=http://127.0.0.1:8000 \ HYPERSHELL_API_TIMEOUT_MS=30000 \ NODE_ENV=production \ diff --git a/scripts/build-version.sh b/scripts/build-version.sh new file mode 100755 index 00000000..f6aa6220 --- /dev/null +++ b/scripts/build-version.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-local}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "${script_dir}/.." && pwd)" +revision="${HYPERSHELL_VCS_REF:-$(git -C "${repository_root}" rev-parse HEAD 2>/dev/null || true)}" +revision="${revision,,}" + +if [[ ! "${revision}" =~ ^[0-9a-f]{40}$ ]]; then + echo "build version error: the revision must be a full 40-character Git SHA" >&2 + exit 1 +fi + +case "${mode}" in + local) + prefix=dev + ;; + ci) + version_file="${HYPERSHELL_VERSION_FILE:-${repository_root}/VERSION}" + version="$(tr -d '\r\n' < "${version_file}")" + if [[ ! "${version}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "build version error: VERSION must contain one stable semantic version" >&2 + exit 1 + fi + prefix="v${version}" + ;; + *) + echo "build version error: mode must be local or ci" >&2 + exit 1 + ;; +esac + +printf '%s-%s\n' "${prefix}" "${revision:0:7}" diff --git a/scripts/check_image_build_policy.py b/scripts/check_image_build_policy.py new file mode 100644 index 00000000..3b84a892 --- /dev/null +++ b/scripts/check_image_build_policy.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Check image identity and event-specific component build rules.""" + +from pathlib import Path +import sys + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +COMPONENTS = { + "api-server": { + "dockerfile": "components/api-server/Dockerfile", + "image": "hypershell-api-server-main", + }, + "control-plane": { + "dockerfile": "components/control-plane/Dockerfile", + "image": "hypershell-control-plane-main", + }, + "web-console": { + "dockerfile": "components/web-console/Dockerfile", + "image": "hypershell-web-console-main", + }, +} +NON_PUSH_RELEASE_FILES = ("VERSION", "CHANGELOG.md", "build-version.env") + + +def check_repository(root: Path) -> list[str]: + """Return image build policy errors.""" + errors: list[str] = [] + for component, values in COMPONENTS.items(): + for event in ("push", "pull-request", "merge-queue"): + relative_path = f".tekton/hypershell-{component}-main-{event}.yaml" + text = (root / relative_path).read_text(encoding="utf-8") + if "VCS_REF={{revision}}" not in text: + errors.append(f"{relative_path} must pass the full revision") + if "value: build-version.env" not in text: + errors.append(f"{relative_path} must use build-version.env") + if event == "push": + if '"VERSION".pathChanged()' not in text: + errors.append(f"{relative_path} must build for a VERSION change") + expected_tag = f"{values['image']}:{{{{revision}}}}" + else: + for release_file in NON_PUSH_RELEASE_FILES: + if f'"{release_file}".pathChanged()' in text: + errors.append( + f"{relative_path} must not build for {release_file}" + ) + tag_prefix = ( + "on-pr-" if event == "pull-request" else "on-merge-queue-" + ) + expected_tag = f"{values['image']}:{tag_prefix}{{{{revision}}}}" + if expected_tag not in text: + errors.append(f"{relative_path} must keep its current revision tag") + + dockerfile_path = root / values["dockerfile"] + dockerfile = dockerfile_path.read_text(encoding="utf-8") + if 'org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}"' not in dockerfile: + errors.append(f"{values['dockerfile']} must set the OCI build version") + if 'org.opencontainers.image.revision="${VCS_REF}"' not in dockerfile: + errors.append(f"{values['dockerfile']} must set the OCI revision") + if 'HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}"' not in dockerfile: + errors.append(f"{values['dockerfile']} must set the runtime build version") + + e2e = (root / ".github/workflows/e2e.yml").read_text(encoding="utf-8") + if "release_version_changed=false" not in e2e: + errors.append("the E2E plan must identify a release-version push") + if '[[ "${EVENT_NAME}" == "push" ]]' not in e2e: + errors.append("the release image rule must check for a push event") + if 'grep -qx "VERSION"' not in e2e: + errors.append("the release image rule must check the VERSION file") + return errors + + +def main() -> int: + errors = check_repository(REPOSITORY_ROOT) + for error in errors: + print(f"image build policy error: {error}", file=sys.stderr) + if errors: + return 1 + print("Image build policy is valid.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/kind/build-images.sh b/scripts/kind/build-images.sh index 37e75416..2a7768ec 100755 --- a/scripts/kind/build-images.sh +++ b/scripts/kind/build-images.sh @@ -33,20 +33,30 @@ else info "Building from working tree ($(git rev-parse --short HEAD))" fi +build_vcs_ref="$(git -C "${BUILD_DIR}" rev-parse HEAD)" +build_prefix=dev + info "Building API server..." ${CONTAINER_ENGINE} build -t "${api_server_local}" \ -f "${BUILD_DIR}/components/api-server/Dockerfile" \ - --build-arg GIT_VERSION="${build_version}" \ + --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg VCS_REF="${build_vcs_ref}" \ --build-arg BUILD_TIME="${build_time}" \ - "${BUILD_DIR}/components/api-server" + "${BUILD_DIR}" info "Building control plane..." ${CONTAINER_ENGINE} build -t "${control_plane_local}" \ - -f "${BUILD_DIR}/components/control-plane/Dockerfile" "${BUILD_DIR}" + -f "${BUILD_DIR}/components/control-plane/Dockerfile" \ + --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg VCS_REF="${build_vcs_ref}" \ + "${BUILD_DIR}" info "Building web console..." ${CONTAINER_ENGINE} build -t "${web_console_local}" \ - -f "${BUILD_DIR}/components/web-console/Dockerfile" "${BUILD_DIR}" + -f "${BUILD_DIR}/components/web-console/Dockerfile" \ + --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg VCS_REF="${build_vcs_ref}" \ + "${BUILD_DIR}" success "All images built" diff --git a/scripts/kind/swap-component.sh b/scripts/kind/swap-component.sh index 226c6ffe..cb8fb814 100755 --- a/scripts/kind/swap-component.sh +++ b/scripts/kind/swap-component.sh @@ -9,6 +9,8 @@ require_cluster ACTION="${1:-}" COMPONENT="${2:-}" +VCS_REF="$(git -C "${REPO_ROOT}" rev-parse HEAD)" +BUILD_ARGS=(--build-arg "BUILD_PREFIX=dev" --build-arg "VCS_REF=${VCS_REF}") if [[ -z "${ACTION}" ]] || [[ -z "${COMPONENT}" ]]; then error "Usage: swap-component.sh up|down " @@ -23,8 +25,8 @@ case "${COMPONENT}" in LOCAL_IMAGE="${api_server_local}" BASELINE_IMAGE="${api_server_ref}" DOCKERFILE="components/api-server/Dockerfile" - BUILD_CONTEXT="components/api-server" - BUILD_ARGS=(--build-arg "GIT_VERSION=${build_version}" --build-arg "BUILD_TIME=${build_time}") + BUILD_CONTEXT="." + BUILD_ARGS+=(--build-arg "BUILD_TIME=${build_time}") ;; control-plane) DEPLOYMENT="hypershell-controller" @@ -33,7 +35,6 @@ case "${COMPONENT}" in BASELINE_IMAGE="${control_plane_ref}" DOCKERFILE="components/control-plane/Dockerfile" BUILD_CONTEXT="." - BUILD_ARGS=() ;; web-console) DEPLOYMENT="hypershell-web-console" @@ -42,7 +43,6 @@ case "${COMPONENT}" in BASELINE_IMAGE="${web_console_ref}" DOCKERFILE="components/web-console/Dockerfile" BUILD_CONTEXT="." - BUILD_ARGS=() ;; *) error "Unknown component: ${COMPONENT}" diff --git a/scripts/test_build_version.py b/scripts/test_build_version.py new file mode 100644 index 00000000..b1d3ab82 --- /dev/null +++ b/scripts/test_build_version.py @@ -0,0 +1,61 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT_PATH = Path(__file__).with_name("build-version.sh") +REVISION = "abcdef0123456789abcdef0123456789abcdef01" + + +class BuildVersionTest(unittest.TestCase): + def run_script(self, mode: str, **environment: str): + command_environment = os.environ.copy() + command_environment.update(environment) + return subprocess.run( + ("bash", str(SCRIPT_PATH), mode), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=command_environment, + ) + + def test_local_version_uses_dev_prefix(self): + result = self.run_script("local", HYPERSHELL_VCS_REF=REVISION) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("dev-abcdef0\n", result.stdout) + + def test_ci_version_uses_version_file(self): + with tempfile.TemporaryDirectory() as temporary_directory: + version_file = Path(temporary_directory) / "VERSION" + version_file.write_text("1.6.0\n", encoding="utf-8") + result = self.run_script( + "ci", + HYPERSHELL_VCS_REF=REVISION, + HYPERSHELL_VERSION_FILE=str(version_file), + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("v1.6.0-abcdef0\n", result.stdout) + + def test_rejects_an_abbreviated_revision(self): + result = self.run_script("local", HYPERSHELL_VCS_REF="abcdef0") + self.assertNotEqual(0, result.returncode) + self.assertIn("full 40-character Git SHA", result.stderr) + + def test_rejects_an_invalid_ci_version(self): + with tempfile.TemporaryDirectory() as temporary_directory: + version_file = Path(temporary_directory) / "VERSION" + version_file.write_text("v1.6.0\n", encoding="utf-8") + result = self.run_script( + "ci", + HYPERSHELL_VCS_REF=REVISION, + HYPERSHELL_VERSION_FILE=str(version_file), + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("stable semantic version", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index a8314698..78806c73 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -108,9 +108,9 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | REL-03 | Release files | Present | - | `VERSION`, `CHANGELOG.md`, `build-version.env`, `.release-please-manifest.json` | REL-W1 | | REL-04 | Release publication | Present | The repository owner must set the documented GitHub App variable and secret. | `.github/workflows/release-please.yml`, `docs/releasing.md` | REL-W1 | | REL-05 | First release baseline | Present | - | `release-please-config.json`, `.release-please-manifest.json` | REL-W1 | -| REL-06 | Component-selective CI builds | Missing | `VERSION` does not yet start the three final push builds. | `.tekton/`, `.github/workflows/e2e.yml` | REL-W2 | -| REL-07 | Build version format | Missing | Component builds do not yet use the managed build prefix. | `Makefile`, `scripts/kind/`, `.tekton/` | REL-W2 | -| REL-08 | Container metadata | Missing | The three image files still use static versions. | `components/*/Dockerfile` | REL-W2 | +| REL-06 | Component-selective CI builds | Present | - | `.tekton/`, `.github/workflows/e2e.yml` | REL-W2 | +| REL-07 | Build version format | Present | - | `Makefile`, `scripts/build-version.sh`, `scripts/kind/`, `.tekton/` | REL-W2 | +| REL-08 | Container metadata | Present | - | `components/{api-server,control-plane,web-console}/Dockerfile` | REL-W2 | | REL-09 | API service metadata | Partial | The route exists, but its contract and build stamp are not complete. | `components/api-server/` | REL-W3 | | REL-10 | Web-console version display | Missing | Runtime configuration and the user-menu row are absent. | `components/web-console/` | REL-W4 | | REL-11 | Registry and deployment scope | Present | - | `.tekton/`, `docs/releasing.md` | REL-W1 | From 4f627f66b70776bd35a4ffc024d1e798af0bfc37 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 11:15:15 -0400 Subject: [PATCH 04/16] feat(api): expose image build metadata --- Makefile | 2 +- components/api-server/Dockerfile | 11 +- components/api-server/Makefile | 2 +- .../environments/e_development_oidc.go | 2 +- .../environments/e_development_test.go | 7 + components/api-server/openapi/openapi.yaml | 30 +- components/api-server/pkg/api/api.go | 4 - .../api-server/pkg/api/metadata_test.go | 56 ++++ .../pkg/api/openapi/.openapi-generator/FILES | 2 + .../api-server/pkg/api/openapi/README.md | 1 + .../pkg/api/openapi/api/openapi.yaml | 42 ++- .../api-server/pkg/api/openapi/api_default.go | 10 +- .../pkg/api/openapi/docs/DefaultAPI.md | 10 +- .../pkg/api/openapi/docs/ServiceMetadata.md | 133 +++++++++ .../pkg/api/openapi/model_service_metadata.go | 272 ++++++++++++++++++ .../api-server/pkg/api/openapi_embed_test.go | 60 +++- components/sdk-go/client/client.go | 2 +- components/sdk-go/client/gateway_api.go | 2 +- .../sdk-go/client/gateway_network_api.go | 2 +- .../sdk-go/client/gateway_release_api.go | 2 +- components/sdk-go/client/iterator.go | 2 +- .../sdk-go/client/managed_cluster_api.go | 2 +- .../sdk-go/client/managed_database_api.go | 2 +- .../open_shell_gateway_service_account_api.go | 2 +- components/sdk-go/client/role_api.go | 2 +- components/sdk-go/client/role_binding_api.go | 2 +- components/sdk-go/types/base.go | 2 +- components/sdk-go/types/gateway.go | 2 +- components/sdk-go/types/gateway_network.go | 2 +- components/sdk-go/types/gateway_release.go | 2 +- components/sdk-go/types/list_options.go | 2 +- components/sdk-go/types/managed_cluster.go | 2 +- components/sdk-go/types/managed_database.go | 2 +- .../open_shell_gateway_service_account.go | 2 +- components/sdk-go/types/role.go | 2 +- components/sdk-go/types/role_binding.go | 2 +- components/sdk-typescript/src/base.ts | 2 +- components/sdk-typescript/src/client.ts | 2 +- components/sdk-typescript/src/gateway.ts | 2 +- components/sdk-typescript/src/gateway_api.ts | 2 +- .../sdk-typescript/src/gateway_network.ts | 2 +- .../sdk-typescript/src/gateway_network_api.ts | 2 +- .../sdk-typescript/src/gateway_release.ts | 2 +- .../sdk-typescript/src/gateway_release_api.ts | 2 +- components/sdk-typescript/src/index.ts | 2 +- .../sdk-typescript/src/managed_cluster.ts | 2 +- .../sdk-typescript/src/managed_cluster_api.ts | 2 +- .../sdk-typescript/src/managed_database.ts | 2 +- .../src/managed_database_api.ts | 2 +- .../src/open_shell_gateway_service_account.ts | 2 +- .../open_shell_gateway_service_account_api.ts | 2 +- components/sdk-typescript/src/role.ts | 2 +- components/sdk-typescript/src/role_api.ts | 2 +- components/sdk-typescript/src/role_binding.ts | 2 +- .../sdk-typescript/src/role_binding_api.ts | 2 +- deploy/kind/kustomization.yaml | 2 +- deploy/openshift/kustomization.yaml | 2 +- scripts/sdk-generator/parser.go | 7 +- scripts/sdk-generator/parser_test.go | 14 + skills/RECONCILE.md | 2 +- 60 files changed, 676 insertions(+), 73 deletions(-) create mode 100644 components/api-server/pkg/api/metadata_test.go create mode 100644 components/api-server/pkg/api/openapi/docs/ServiceMetadata.md create mode 100644 components/api-server/pkg/api/openapi/model_service_metadata.go diff --git a/Makefile b/Makefile index b1000d8e..ba320329 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ IMAGE_TAG?=latest vcs_ref:=$(shell git rev-parse HEAD 2>/dev/null || echo unknown) build_prefix:=dev build_version:=$(shell HYPERSHELL_VCS_REF=$(vcs_ref) scripts/build-version.sh local 2>/dev/null || echo dev-unknown) -build_time:=$(shell date -u '+%Y-%m-%d %H:%M:%S UTC') +build_time:=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') # Computed baseline references (registry images used in Kind manifests) api_server_ref=$(IMAGE_REGISTRY)/hypershell-api-server-main:$(IMAGE_TAG) diff --git a/components/api-server/Dockerfile b/components/api-server/Dockerfile index 0ffc0ae0..88730949 100644 --- a/components/api-server/Dockerfile +++ b/components/api-server/Dockerfile @@ -17,11 +17,14 @@ COPY components/api-server/openapi/ openapi/ ARG BUILD_PREFIX ARG VCS_REF ARG BUILD_TIME=unknown -RUN short_ref="${VCS_REF%?????????????????????????????????}" \ - && build_version="${BUILD_PREFIX}-${short_ref}" \ - && CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w \ +RUN set -eu; \ + short_ref="${VCS_REF%?????????????????????????????????}"; \ + build_version="${BUILD_PREFIX}-${short_ref}"; \ + build_time="${BUILD_TIME}"; \ + if [ "${build_time}" = "unknown" ]; then build_time="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"; fi; \ + CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w \ -X github.com/openshift-online/rh-trex-ai/pkg/api.Version=${build_version} \ - -X 'github.com/openshift-online/rh-trex-ai/pkg/api.BuildTime=${BUILD_TIME}'" \ + -X 'github.com/openshift-online/rh-trex-ai/pkg/api.BuildTime=${build_time}'" \ -o hypershell ./cmd/hypershell FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 diff --git a/components/api-server/Makefile b/components/api-server/Makefile index b589697a..60b2f780 100644 --- a/components/api-server/Makefile +++ b/components/api-server/Makefile @@ -14,7 +14,7 @@ CLI_MODULE=github.com/openshift-online/hypershell/components/cli # Version information for ldflags git_sha:=$(shell git rev-parse HEAD 2>/dev/null || echo "unknown") build_version:=$(shell HYPERSHELL_VCS_REF=$(git_sha) ../../scripts/build-version.sh local 2>/dev/null || echo "dev-unknown") -build_time:=$(shell date -u '+%Y-%m-%d %H:%M:%S UTC') +build_time:=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') ldflags=-X github.com/openshift-online/rh-trex-ai/pkg/api.Version=$(build_version) -X 'github.com/openshift-online/rh-trex-ai/pkg/api.BuildTime=$(build_time)' .PHONY: binary diff --git a/components/api-server/cmd/hypershell/environments/e_development_oidc.go b/components/api-server/cmd/hypershell/environments/e_development_oidc.go index e4749e07..df49b087 100644 --- a/components/api-server/cmd/hypershell/environments/e_development_oidc.go +++ b/components/api-server/cmd/hypershell/environments/e_development_oidc.go @@ -44,7 +44,7 @@ func (e *DevOidcEnvImpl) Flags() map[string]string { "enable-https": "false", "enable-metrics-https": "false", "api-server-hostname": "localhost", - "auth-bypass-paths": "/healthcheck,/metrics,/api/hypershell/v1/openapi,/openapi", + "auth-bypass-paths": "/healthcheck,/metrics,/api/hypershell/v1/metadata,/api/hypershell/v1/openapi,/openapi", "auth-bypass-methods": "/grpc.health.v1.Health/,/grpc.reflection.v1alpha.ServerReflection/,/hypershell.v1.GatewayService/WatchGateways,/hypershell.v1.GatewayReleaseService/WatchGatewayReleases,/hypershell.v1.ManagedClusterService/WatchManagedClusters,/hypershell.v1.ManagedDatabaseService/WatchManagedDatabases,/hypershell.v1.GatewayNetworkService/WatchGatewayNetworks", } } diff --git a/components/api-server/cmd/hypershell/environments/e_development_test.go b/components/api-server/cmd/hypershell/environments/e_development_test.go index 54b7aabb..880b4fbe 100644 --- a/components/api-server/cmd/hypershell/environments/e_development_test.go +++ b/components/api-server/cmd/hypershell/environments/e_development_test.go @@ -45,3 +45,10 @@ func TestDevOverrideConfigDisablesJWT(t *testing.T) { t.Error("development environment must disable HTTPS") } } + +func TestDevOIDCMetadataBypass(t *testing.T) { + paths := (&DevOidcEnvImpl{}).Flags()["auth-bypass-paths"] + if !strings.Contains(paths, "/api/hypershell/v1/metadata") { + t.Fatalf("auth bypass paths do not contain the metadata endpoint: %q", paths) + } +} diff --git a/components/api-server/openapi/openapi.yaml b/components/api-server/openapi/openapi.yaml index 28f7c4e1..446b6f69 100644 --- a/components/api-server/openapi/openapi.yaml +++ b/components/api-server/openapi/openapi.yaml @@ -18,14 +18,17 @@ paths: /api/hypershell/v1/metadata: get: summary: Service metadata + description: Returns the API server build identity without a database query. operationId: getMetadata + security: [] + x-sdk-exclude: true responses: '200': description: Service metadata content: application/json: schema: - $ref: '#/components/schemas/ObjectReference' + $ref: '#/components/schemas/ServiceMetadata' /api/hypershell/v1/managed_clusters: $ref: 'openapi.managedClusters.yaml#/paths/~1api~1hypershell~1v1~1managed_clusters' /api/hypershell/v1/managed_clusters/{id}: @@ -63,6 +66,31 @@ paths: # AUTO-ADD NEW PATHS components: schemas: + ServiceMetadata: + type: object + required: + - id + - href + - kind + - version + - build_time + properties: + id: + type: string + description: Service identifier + href: + type: string + description: Metadata request path + kind: + type: string + enum: + - API + version: + type: string + description: API server image build version + build_time: + type: string + description: Time when the API server binary was built List: allOf: - type: object diff --git a/components/api-server/pkg/api/api.go b/components/api-server/pkg/api/api.go index 50f27d57..2ce26ec9 100644 --- a/components/api-server/pkg/api/api.go +++ b/components/api-server/pkg/api/api.go @@ -18,7 +18,3 @@ const ( // Re-export TRex functions var NewID = trexapi.NewID -var ( - Version string - BuildTime string -) diff --git a/components/api-server/pkg/api/metadata_test.go b/components/api-server/pkg/api/metadata_test.go new file mode 100644 index 00000000..9d690fc6 --- /dev/null +++ b/components/api-server/pkg/api/metadata_test.go @@ -0,0 +1,56 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + trexapi "github.com/openshift-online/rh-trex-ai/pkg/api" + "github.com/openshift-online/rh-trex-ai/pkg/handlers" +) + +func TestMetadataHandlerReportsBuildIdentity(t *testing.T) { + previousVersion := trexapi.Version + previousBuildTime := trexapi.BuildTime + defer func() { + trexapi.Version = previousVersion + trexapi.BuildTime = previousBuildTime + handlers.SetMetadataID("hypershell") + }() + + trexapi.Version = "v1.6.0-1234567" + trexapi.BuildTime = "2026-09-02T15:00:00Z" + handlers.SetMetadataID("hypershell") + + request := httptest.NewRequest(http.MethodGet, "/api/hypershell/v1/metadata", nil) + response := httptest.NewRecorder() + handlers.NewMetadataHandler().Get(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("metadata status = %d, want %d", response.Code, http.StatusOK) + } + if got := response.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("metadata content type = %q, want application/json", got) + } + + var metadata trexapi.Metadata + if err := json.Unmarshal(response.Body.Bytes(), &metadata); err != nil { + t.Fatalf("decode metadata response: %v", err) + } + if metadata.ID != "hypershell" { + t.Errorf("metadata id = %q, want hypershell", metadata.ID) + } + if metadata.HREF != "/api/hypershell/v1/metadata" { + t.Errorf("metadata href = %q", metadata.HREF) + } + if metadata.Kind != "API" { + t.Errorf("metadata kind = %q, want API", metadata.Kind) + } + if metadata.Version != "v1.6.0-1234567" { + t.Errorf("metadata version = %q", metadata.Version) + } + if metadata.BuildTime != "2026-09-02T15:00:00Z" { + t.Errorf("metadata build time = %q", metadata.BuildTime) + } +} diff --git a/components/api-server/pkg/api/openapi/.openapi-generator/FILES b/components/api-server/pkg/api/openapi/.openapi-generator/FILES index 706c9c43..51890144 100644 --- a/components/api-server/pkg/api/openapi/.openapi-generator/FILES +++ b/components/api-server/pkg/api/openapi/.openapi-generator/FILES @@ -41,6 +41,7 @@ docs/Role.md docs/RoleBinding.md docs/RoleBindingList.md docs/RoleList.md +docs/ServiceMetadata.md git_push.sh go.mod go.sum @@ -78,6 +79,7 @@ model_role.go model_role_binding.go model_role_binding_list.go model_role_list.go +model_service_metadata.go response.go test/api_default_test.go utils.go diff --git a/components/api-server/pkg/api/openapi/README.md b/components/api-server/pkg/api/openapi/README.md index 7a8f5d1a..4dc0f912 100644 --- a/components/api-server/pkg/api/openapi/README.md +++ b/components/api-server/pkg/api/openapi/README.md @@ -153,6 +153,7 @@ Class | Method | HTTP request | Description - [RoleBinding](docs/RoleBinding.md) - [RoleBindingList](docs/RoleBindingList.md) - [RoleList](docs/RoleList.md) + - [ServiceMetadata](docs/ServiceMetadata.md) ## Documentation For Authorization diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 65e687da..91b99990 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -19,15 +19,18 @@ tags: paths: /api/hypershell/v1/metadata: get: + description: Returns the API server build identity without a database query. operationId: getMetadata responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ObjectReference" + $ref: "#/components/schemas/ServiceMetadata" description: Service metadata + security: [] summary: Service metadata + x-sdk-exclude: true /api/hypershell/v1/managed_clusters: get: operationId: listManagedClusters @@ -2119,6 +2122,37 @@ components: $ref: "#/components/schemas/Error" description: Keycloak provisioning or lifecycle verification is unavailable schemas: + ServiceMetadata: + example: + kind: API + build_time: build_time + id: id + href: href + version: version + properties: + id: + description: Service identifier + type: string + href: + description: Metadata request path + type: string + kind: + enum: + - API + type: string + version: + description: API server image build version + type: string + build_time: + description: Time when the API server binary was built + type: string + required: + - build_time + - href + - id + - kind + - version + type: object List: allOf: - properties: @@ -2133,12 +2167,6 @@ components: type: object - $ref: "#/components/schemas/ObjectReference" ObjectReference: - example: - updated_at: 2000-01-23T04:56:07.000+00:00 - kind: kind - created_at: 2000-01-23T04:56:07.000+00:00 - id: id - href: href properties: id: type: string diff --git a/components/api-server/pkg/api/openapi/api_default.go b/components/api-server/pkg/api/openapi/api_default.go index 7621d56d..6467b161 100644 --- a/components/api-server/pkg/api/openapi/api_default.go +++ b/components/api-server/pkg/api/openapi/api_default.go @@ -2922,13 +2922,15 @@ type ApiGetMetadataRequest struct { ApiService *DefaultAPIService } -func (r ApiGetMetadataRequest) Execute() (*ObjectReference, *http.Response, error) { +func (r ApiGetMetadataRequest) Execute() (*ServiceMetadata, *http.Response, error) { return r.ApiService.GetMetadataExecute(r) } /* GetMetadata Service metadata +Returns the API server build identity without a database query. + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). @return ApiGetMetadataRequest */ @@ -2941,13 +2943,13 @@ func (a *DefaultAPIService) GetMetadata(ctx context.Context) ApiGetMetadataReque // Execute executes the request // -// @return ObjectReference -func (a *DefaultAPIService) GetMetadataExecute(r ApiGetMetadataRequest) (*ObjectReference, *http.Response, error) { +// @return ServiceMetadata +func (a *DefaultAPIService) GetMetadataExecute(r ApiGetMetadataRequest) (*ServiceMetadata, *http.Response, error) { var ( localVarHTTPMethod = http.MethodGet localVarPostBody interface{} formFiles []formFile - localVarReturnValue *ObjectReference + localVarReturnValue *ServiceMetadata ) localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DefaultAPIService.GetMetadata") diff --git a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md index 2a96f7a7..2705fd87 100644 --- a/components/api-server/pkg/api/openapi/docs/DefaultAPI.md +++ b/components/api-server/pkg/api/openapi/docs/DefaultAPI.md @@ -1378,10 +1378,12 @@ Name | Type | Description | Notes ## GetMetadata -> ObjectReference GetMetadata(ctx).Execute() +> ServiceMetadata GetMetadata(ctx).Execute() Service metadata + + ### Example ```go @@ -1403,7 +1405,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error when calling `DefaultAPI.GetMetadata``: %v\n", err) fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) } - // response from `GetMetadata`: ObjectReference + // response from `GetMetadata`: ServiceMetadata fmt.Fprintf(os.Stdout, "Response from `DefaultAPI.GetMetadata`: %v\n", resp) } ``` @@ -1419,11 +1421,11 @@ Other parameters are passed through a pointer to a apiGetMetadataRequest struct ### Return type -[**ObjectReference**](ObjectReference.md) +[**ServiceMetadata**](ServiceMetadata.md) ### Authorization -[Bearer](../README.md#Bearer) +No authorization required ### HTTP request headers diff --git a/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md b/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md new file mode 100644 index 00000000..ec444164 --- /dev/null +++ b/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md @@ -0,0 +1,133 @@ +# ServiceMetadata + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Id** | **string** | Service identifier | +**Href** | **string** | Metadata request path | +**Kind** | **string** | | +**Version** | **string** | API server image build version | +**BuildTime** | **string** | Time when the API server binary was built | + +## Methods + +### NewServiceMetadata + +`func NewServiceMetadata(id string, href string, kind string, version string, buildTime string, ) *ServiceMetadata` + +NewServiceMetadata instantiates a new ServiceMetadata object +This constructor will assign default values to properties that have it defined, +and makes sure properties required by API are set, but the set of arguments +will change when the set of required properties is changed + +### NewServiceMetadataWithDefaults + +`func NewServiceMetadataWithDefaults() *ServiceMetadata` + +NewServiceMetadataWithDefaults instantiates a new ServiceMetadata object +This constructor will only assign default values to properties that have it defined, +but it doesn't guarantee that properties required by API are set + +### GetId + +`func (o *ServiceMetadata) GetId() string` + +GetId returns the Id field if non-nil, zero value otherwise. + +### GetIdOk + +`func (o *ServiceMetadata) GetIdOk() (*string, bool)` + +GetIdOk returns a tuple with the Id field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetId + +`func (o *ServiceMetadata) SetId(v string)` + +SetId sets Id field to given value. + + +### GetHref + +`func (o *ServiceMetadata) GetHref() string` + +GetHref returns the Href field if non-nil, zero value otherwise. + +### GetHrefOk + +`func (o *ServiceMetadata) GetHrefOk() (*string, bool)` + +GetHrefOk returns a tuple with the Href field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetHref + +`func (o *ServiceMetadata) SetHref(v string)` + +SetHref sets Href field to given value. + + +### GetKind + +`func (o *ServiceMetadata) GetKind() string` + +GetKind returns the Kind field if non-nil, zero value otherwise. + +### GetKindOk + +`func (o *ServiceMetadata) GetKindOk() (*string, bool)` + +GetKindOk returns a tuple with the Kind field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetKind + +`func (o *ServiceMetadata) SetKind(v string)` + +SetKind sets Kind field to given value. + + +### GetVersion + +`func (o *ServiceMetadata) GetVersion() string` + +GetVersion returns the Version field if non-nil, zero value otherwise. + +### GetVersionOk + +`func (o *ServiceMetadata) GetVersionOk() (*string, bool)` + +GetVersionOk returns a tuple with the Version field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetVersion + +`func (o *ServiceMetadata) SetVersion(v string)` + +SetVersion sets Version field to given value. + + +### GetBuildTime + +`func (o *ServiceMetadata) GetBuildTime() string` + +GetBuildTime returns the BuildTime field if non-nil, zero value otherwise. + +### GetBuildTimeOk + +`func (o *ServiceMetadata) GetBuildTimeOk() (*string, bool)` + +GetBuildTimeOk returns a tuple with the BuildTime field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetBuildTime + +`func (o *ServiceMetadata) SetBuildTime(v string)` + +SetBuildTime sets BuildTime field to given value. + + + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/components/api-server/pkg/api/openapi/model_service_metadata.go b/components/api-server/pkg/api/openapi/model_service_metadata.go new file mode 100644 index 00000000..b0e83abd --- /dev/null +++ b/components/api-server/pkg/api/openapi/model_service_metadata.go @@ -0,0 +1,272 @@ +/* +HyperShell API + +HyperShell gateway management API + +API version: 1.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package openapi + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the ServiceMetadata type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ServiceMetadata{} + +// ServiceMetadata struct for ServiceMetadata +type ServiceMetadata struct { + // Service identifier + Id string `json:"id"` + // Metadata request path + Href string `json:"href"` + Kind string `json:"kind"` + // API server image build version + Version string `json:"version"` + // Time when the API server binary was built + BuildTime string `json:"build_time"` +} + +type _ServiceMetadata ServiceMetadata + +// NewServiceMetadata instantiates a new ServiceMetadata object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewServiceMetadata(id string, href string, kind string, version string, buildTime string) *ServiceMetadata { + this := ServiceMetadata{} + this.Id = id + this.Href = href + this.Kind = kind + this.Version = version + this.BuildTime = buildTime + return &this +} + +// NewServiceMetadataWithDefaults instantiates a new ServiceMetadata object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewServiceMetadataWithDefaults() *ServiceMetadata { + this := ServiceMetadata{} + return &this +} + +// GetId returns the Id field value +func (o *ServiceMetadata) GetId() string { + if o == nil { + var ret string + return ret + } + + return o.Id +} + +// GetIdOk returns a tuple with the Id field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Id, true +} + +// SetId sets field value +func (o *ServiceMetadata) SetId(v string) { + o.Id = v +} + +// GetHref returns the Href field value +func (o *ServiceMetadata) GetHref() string { + if o == nil { + var ret string + return ret + } + + return o.Href +} + +// GetHrefOk returns a tuple with the Href field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetHrefOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Href, true +} + +// SetHref sets field value +func (o *ServiceMetadata) SetHref(v string) { + o.Href = v +} + +// GetKind returns the Kind field value +func (o *ServiceMetadata) GetKind() string { + if o == nil { + var ret string + return ret + } + + return o.Kind +} + +// GetKindOk returns a tuple with the Kind field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetKindOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Kind, true +} + +// SetKind sets field value +func (o *ServiceMetadata) SetKind(v string) { + o.Kind = v +} + +// GetVersion returns the Version field value +func (o *ServiceMetadata) GetVersion() string { + if o == nil { + var ret string + return ret + } + + return o.Version +} + +// GetVersionOk returns a tuple with the Version field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetVersionOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Version, true +} + +// SetVersion sets field value +func (o *ServiceMetadata) SetVersion(v string) { + o.Version = v +} + +// GetBuildTime returns the BuildTime field value +func (o *ServiceMetadata) GetBuildTime() string { + if o == nil { + var ret string + return ret + } + + return o.BuildTime +} + +// GetBuildTimeOk returns a tuple with the BuildTime field value +// and a boolean to check if the value has been set. +func (o *ServiceMetadata) GetBuildTimeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.BuildTime, true +} + +// SetBuildTime sets field value +func (o *ServiceMetadata) SetBuildTime(v string) { + o.BuildTime = v +} + +func (o ServiceMetadata) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ServiceMetadata) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["id"] = o.Id + toSerialize["href"] = o.Href + toSerialize["kind"] = o.Kind + toSerialize["version"] = o.Version + toSerialize["build_time"] = o.BuildTime + return toSerialize, nil +} + +func (o *ServiceMetadata) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "id", + "href", + "kind", + "version", + "build_time", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varServiceMetadata := _ServiceMetadata{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varServiceMetadata) + + if err != nil { + return err + } + + *o = ServiceMetadata(varServiceMetadata) + + return err +} + +type NullableServiceMetadata struct { + value *ServiceMetadata + isSet bool +} + +func (v NullableServiceMetadata) Get() *ServiceMetadata { + return v.value +} + +func (v *NullableServiceMetadata) Set(val *ServiceMetadata) { + v.value = val + v.isSet = true +} + +func (v NullableServiceMetadata) IsSet() bool { + return v.isSet +} + +func (v *NullableServiceMetadata) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableServiceMetadata(val *ServiceMetadata) *NullableServiceMetadata { + return &NullableServiceMetadata{value: val, isSet: true} +} + +func (v NullableServiceMetadata) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableServiceMetadata) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/components/api-server/pkg/api/openapi_embed_test.go b/components/api-server/pkg/api/openapi_embed_test.go index f2437bbe..a7304ef9 100644 --- a/components/api-server/pkg/api/openapi_embed_test.go +++ b/components/api-server/pkg/api/openapi_embed_test.go @@ -8,6 +8,27 @@ import ( type openAPIOperation struct { OperationID string `yaml:"operationId"` + Responses map[string]openAPIResponse + Security *[]map[string][]string `yaml:"security"` +} + +type openAPIResponse struct { + Content map[string]openAPIContent +} + +type openAPIContent struct { + Schema openAPISchemaReference +} + +type openAPISchemaReference struct { + Reference string `yaml:"$ref"` +} + +type openAPISchema struct { + Required []string + Properties map[string]struct { + Type string + } } type openAPIPathItem struct { @@ -24,8 +45,11 @@ func TestGetOpenAPISpecReturnsCompleteContract(t *testing.T) { } var spec struct { - OpenAPI string `yaml:"openapi"` - Paths map[string]openAPIPathItem `yaml:"paths"` + OpenAPI string `yaml:"openapi"` + Paths map[string]openAPIPathItem `yaml:"paths"` + Components struct { + Schemas map[string]openAPISchema + } } if err := yaml.Unmarshal(data, &spec); err != nil { t.Fatalf("parse embedded OpenAPI document: %v", err) @@ -78,4 +102,36 @@ func TestGetOpenAPISpecReturnsCompleteContract(t *testing.T) { t.Errorf("DELETE %s operationId = %q, want %q", path, operation.OperationID, expectedOperationID) } } + + metadataOperation := spec.Paths["/api/hypershell/v1/metadata"].Get + if metadataOperation == nil { + t.Fatal("GET metadata operation is missing") + } + if metadataOperation.Security == nil || len(*metadataOperation.Security) != 0 { + t.Error("GET metadata must have an explicit empty security requirement") + } + response, exists := metadataOperation.Responses["200"] + if !exists { + t.Fatal("GET metadata has no 200 response") + } + if got := response.Content["application/json"].Schema.Reference; got != "#/components/schemas/ServiceMetadata" { + t.Errorf("GET metadata response schema = %q", got) + } + + metadataSchema, exists := spec.Components.Schemas["ServiceMetadata"] + if !exists { + t.Fatal("ServiceMetadata schema is missing") + } + required := make(map[string]bool, len(metadataSchema.Required)) + for _, name := range metadataSchema.Required { + required[name] = true + } + for _, name := range []string{"id", "href", "kind", "version", "build_time"} { + if !required[name] { + t.Errorf("ServiceMetadata does not require %q", name) + } + if property, exists := metadataSchema.Properties[name]; !exists || property.Type != "string" { + t.Errorf("ServiceMetadata property %q must be a string", name) + } + } } diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 847fa5d5..ab2386c5 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index 00bb5525..81015fe7 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/gateway_network_api.go b/components/sdk-go/client/gateway_network_api.go index 75d2411a..3e110fa3 100644 --- a/components/sdk-go/client/gateway_network_api.go +++ b/components/sdk-go/client/gateway_network_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/gateway_release_api.go b/components/sdk-go/client/gateway_release_api.go index 72c64bf8..8f967e33 100644 --- a/components/sdk-go/client/gateway_release_api.go +++ b/components/sdk-go/client/gateway_release_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index a2792ca0..80786483 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index 646d6726..cf87d551 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/managed_database_api.go b/components/sdk-go/client/managed_database_api.go index 6c86ed2c..0495f1a6 100644 --- a/components/sdk-go/client/managed_database_api.go +++ b/components/sdk-go/client/managed_database_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/open_shell_gateway_service_account_api.go b/components/sdk-go/client/open_shell_gateway_service_account_api.go index 0cc42149..7c217078 100644 --- a/components/sdk-go/client/open_shell_gateway_service_account_api.go +++ b/components/sdk-go/client/open_shell_gateway_service_account_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index 8cc27d75..bb77761d 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index 76a4cd1f..473b2d94 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package client diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index 33610fdb..4108a4b3 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 1541d672..af974d4d 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/gateway_network.go b/components/sdk-go/types/gateway_network.go index c57107ce..e5176b20 100644 --- a/components/sdk-go/types/gateway_network.go +++ b/components/sdk-go/types/gateway_network.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/gateway_release.go b/components/sdk-go/types/gateway_release.go index 9303e0de..9533d251 100644 --- a/components/sdk-go/types/gateway_release.go +++ b/components/sdk-go/types/gateway_release.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index 3e76f6cb..534d66a6 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index d85e3d70..f4f379f4 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/managed_database.go b/components/sdk-go/types/managed_database.go index 8407ac9e..01a13543 100644 --- a/components/sdk-go/types/managed_database.go +++ b/components/sdk-go/types/managed_database.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/open_shell_gateway_service_account.go b/components/sdk-go/types/open_shell_gateway_service_account.go index 5364c0d2..893b3ad4 100644 --- a/components/sdk-go/types/open_shell_gateway_service_account.go +++ b/components/sdk-go/types/open_shell_gateway_service_account.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 10bca77a..41571623 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index 5a863d2d..33f70681 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d package types diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 3049004e..042ef2c0 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index 3093afc8..210d0d5a 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig } from './base.js'; import { GatewayAPI } from './gateway_api.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index de4e3184..dc7f5be3 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 4e71256d..05462413 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network.ts b/components/sdk-typescript/src/gateway_network.ts index 97fe414a..36532449 100644 --- a/components/sdk-typescript/src/gateway_network.ts +++ b/components/sdk-typescript/src/gateway_network.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network_api.ts b/components/sdk-typescript/src/gateway_network_api.ts index d08e74a2..301ace32 100644 --- a/components/sdk-typescript/src/gateway_network_api.ts +++ b/components/sdk-typescript/src/gateway_network_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release.ts b/components/sdk-typescript/src/gateway_release.ts index c915a33f..8752ccd5 100644 --- a/components/sdk-typescript/src/gateway_release.ts +++ b/components/sdk-typescript/src/gateway_release.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release_api.ts b/components/sdk-typescript/src/gateway_release_api.ts index f990e48a..a8016b1f 100644 --- a/components/sdk-typescript/src/gateway_release_api.ts +++ b/components/sdk-typescript/src/gateway_release_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index ee5fcee8..7a0bd5da 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index a648937d..e557d225 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index f83d1b15..ec980d32 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database.ts b/components/sdk-typescript/src/managed_database.ts index a156b437..6bf78a01 100644 --- a/components/sdk-typescript/src/managed_database.ts +++ b/components/sdk-typescript/src/managed_database.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database_api.ts b/components/sdk-typescript/src/managed_database_api.ts index e3b31217..2cef6654 100644 --- a/components/sdk-typescript/src/managed_database_api.ts +++ b/components/sdk-typescript/src/managed_database_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account.ts b/components/sdk-typescript/src/open_shell_gateway_service_account.ts index fdb26eb3..78b47d45 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d export type OpenShellGatewayServiceAccountCapabilities = { diff --git a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts index 3e2d5f93..378080f7 100644 --- a/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts +++ b/components/sdk-typescript/src/open_shell_gateway_service_account_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, RequestOptions } from './base.js'; import { sdkFetch } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index f433f5cf..f0e6dd4f 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index 1ba0e062..da0ff020 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index d995b20d..c5a35247 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 9678dcbc..bb8e979b 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 3a4b85a8fe6c5a35061a7a9a470d9813d217c510e482871cd237ebf7bd70f446 +// Spec SHA256: 37bf7852f6962f87a1713717ae6e1880188293af803882fe018779408f5f888d import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/deploy/kind/kustomization.yaml b/deploy/kind/kustomization.yaml index 62e06858..cccb2fab 100644 --- a/deploy/kind/kustomization.yaml +++ b/deploy/kind/kustomization.yaml @@ -141,7 +141,7 @@ patches: value: "--jwk-cert-url=http://keycloak-service.keycloak.svc.cluster.local:8080/realms/hypershell/protocol/openid-connect/certs" - op: add path: /spec/template/spec/containers/0/command/- - value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/openapi,/openapi" + value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/metadata,/api/hypershell/v1/openapi,/openapi" - op: add path: /spec/template/spec/containers/0/command/- value: "--auth-bypass-methods=/grpc.health.v1.Health/,/grpc.reflection.v1alpha.ServerReflection/,/hypershell.v1.GatewayService/WatchGateways,/hypershell.v1.GatewayReleaseService/WatchGatewayReleases,/hypershell.v1.ManagedClusterService/WatchManagedClusters,/hypershell.v1.ManagedDatabaseService/WatchManagedDatabases,/hypershell.v1.GatewayNetworkService/WatchGatewayNetworks" diff --git a/deploy/openshift/kustomization.yaml b/deploy/openshift/kustomization.yaml index 02a4d510..03979747 100644 --- a/deploy/openshift/kustomization.yaml +++ b/deploy/openshift/kustomization.yaml @@ -94,7 +94,7 @@ patches: value: "--jwk-cert-url=$(JWK_CERT_URL)" - op: add path: /spec/template/spec/containers/0/command/- - value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/openapi,/openapi" + value: "--auth-bypass-paths=/healthcheck,/metrics,/api/hypershell/v1/metadata,/api/hypershell/v1/openapi,/openapi" - op: add path: /spec/template/spec/containers/0/command/- value: "--auth-bypass-methods=/grpc.health.v1.Health/,/grpc.reflection.v1alpha.ServerReflection/,/hypershell.v1.GatewayService/WatchGateways,/hypershell.v1.GatewayReleaseService/WatchGatewayReleases,/hypershell.v1.ManagedClusterService/WatchManagedClusters,/hypershell.v1.ManagedDatabaseService/WatchManagedDatabases,/hypershell.v1.GatewayNetworkService/WatchGatewayNetworks" diff --git a/scripts/sdk-generator/parser.go b/scripts/sdk-generator/parser.go index b253889a..b9d4a64e 100644 --- a/scripts/sdk-generator/parser.go +++ b/scripts/sdk-generator/parser.go @@ -21,6 +21,9 @@ func parseSpec(specPath, apiPrefix string) (*Spec, error) { resourceViews := primaryCollectionViews(document, apiPrefix) resources := make([]Resource, 0, len(resourceViews)) for _, view := range resourceViews { + if extensionIsTrue(view.Extensions, "x-sdk-exclude") { + continue + } schema := document.Schema(view.SchemaRef) if schema == nil || schema.Name == "" { continue @@ -140,7 +143,7 @@ func scopedCollectionViews(document *ir.Document, apiPrefix string) []*ir.Resour var result []*ir.ResourceView seen := make(map[string]bool) for _, view := range document.ResourceViews { - if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) || len(view.ScopeParameters) == 0 { + if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) || len(view.ScopeParameters) == 0 || extensionIsTrue(view.Extensions, "x-sdk-exclude") { continue } remainder := strings.TrimPrefix(view.Path, strings.TrimSuffix(apiPrefix, "/")+"/") @@ -504,7 +507,7 @@ func projectFields(document *ir.Document, schemaRef string, includeReadOnly bool func primaryCollectionViews(document *ir.Document, apiPrefix string) []*ir.ResourceView { bySchema := make(map[string]*ir.ResourceView) for _, view := range document.ResourceViews { - if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) { + if view.Kind != ir.ResourceCollection || !view.Capabilities.Has(ir.CapabilityList) || extensionIsTrue(view.Extensions, "x-sdk-exclude") { continue } remainder := strings.TrimPrefix(view.Path, strings.TrimSuffix(apiPrefix, "/")+"/") diff --git a/scripts/sdk-generator/parser_test.go b/scripts/sdk-generator/parser_test.go index 80ac38ec..26226e38 100644 --- a/scripts/sdk-generator/parser_test.go +++ b/scripts/sdk-generator/parser_test.go @@ -69,3 +69,17 @@ func TestParseSpecProjectsScopedServiceAccountResource(t *testing.T) { } } } + +func TestParseSpecExcludesSingletonMetadata(t *testing.T) { + specPath := filepath.Join("..", "..", "components", "api-server", "openapi", "openapi.yaml") + spec, err := parseSpec(specPath, "/api/hypershell/v1") + if err != nil { + t.Fatalf("parse spec: %v", err) + } + + for _, resource := range spec.Resources { + if resource.Name == "ServiceMetadata" { + t.Fatal("singleton metadata must not become a CRUD SDK resource") + } + } +} diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 78806c73..b28fd69e 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -111,7 +111,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | REL-06 | Component-selective CI builds | Present | - | `.tekton/`, `.github/workflows/e2e.yml` | REL-W2 | | REL-07 | Build version format | Present | - | `Makefile`, `scripts/build-version.sh`, `scripts/kind/`, `.tekton/` | REL-W2 | | REL-08 | Container metadata | Present | - | `components/{api-server,control-plane,web-console}/Dockerfile` | REL-W2 | -| REL-09 | API service metadata | Partial | The route exists, but its contract and build stamp are not complete. | `components/api-server/` | REL-W3 | +| REL-09 | API service metadata | Present | - | `components/api-server/openapi/openapi.yaml`, `components/api-server/pkg/api/metadata_test.go` | REL-W3 | | REL-10 | Web-console version display | Missing | Runtime configuration and the user-menu row are absent. | `components/web-console/` | REL-W4 | | REL-11 | Registry and deployment scope | Present | - | `.tekton/`, `docs/releasing.md` | REL-W1 | | REL-12 | Verification and documentation | Partial | Release controls have tests and documentation. Image, API, and console checks remain. | `scripts/test_release_policy.py`, `docs/releasing.md` | REL-W1..REL-W4 | From bbc3d1284db2c5e2e2be5f91cdc0b5cd29eff331 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 11:17:57 -0400 Subject: [PATCH 05/16] feat(web-console): show image build version --- .../browser-runtime-config.test.ts | 25 +++++++++++- .../app/composition/browser-runtime-config.ts | 20 ++++++++-- .../app/features/shell/user-menu.test.tsx | 40 +++++++++++++++++++ .../app/features/shell/user-menu.tsx | 10 +++++ components/web-console/app/i18n/messages.ts | 10 +++++ components/web-console/bff/src/config.ts | 14 +++++++ components/web-console/bff/test/app.test.ts | 4 ++ .../web-console/bff/test/config.test.ts | 27 ++++++++++++- components/web-console/locales/en.json | 8 ++++ skills/RECONCILE.md | 15 +++---- 10 files changed, 160 insertions(+), 13 deletions(-) diff --git a/components/web-console/app/composition/browser-runtime-config.test.ts b/components/web-console/app/composition/browser-runtime-config.test.ts index 39ac1f4d..ccab0d12 100644 --- a/components/web-console/app/composition/browser-runtime-config.test.ts +++ b/components/web-console/app/composition/browser-runtime-config.test.ts @@ -19,20 +19,27 @@ function documentWithMeta(content: string | undefined): Document { describe("readBrowserRuntimeConfig", () => { it("reads the sample ratio from the injected meta tag", () => { const config = readBrowserRuntimeConfig( - documentWithMeta('{"tracing":{"sampleRatio":0.25}}'), + documentWithMeta( + '{"build":{"version":"v1.6.0-1234567"},"tracing":{"sampleRatio":0.25}}', + ), ); - expect(config).toEqual({ tracing: { sampleRatio: 0.25 } }); + expect(config).toEqual({ + build: { version: "v1.6.0-1234567" }, + tracing: { sampleRatio: 0.25 }, + }); }); it("samples nothing when the meta tag is absent", () => { expect(readBrowserRuntimeConfig(documentWithMeta(undefined))).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); }); it("fails closed to no tracing when the content is not valid JSON", () => { expect(readBrowserRuntimeConfig(documentWithMeta("not-json"))).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); }); @@ -46,8 +53,22 @@ describe("readBrowserRuntimeConfig", () => { "{}", ]) { expect(readBrowserRuntimeConfig(documentWithMeta(content))).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); } }); + + it("ignores an invalid build version without changing tracing", () => { + expect( + readBrowserRuntimeConfig( + documentWithMeta( + '{"build":{"version":"latest"},"tracing":{"sampleRatio":0.5}}', + ), + ), + ).toEqual({ + build: {}, + tracing: { sampleRatio: 0.5 }, + }); + }); }); diff --git a/components/web-console/app/composition/browser-runtime-config.ts b/components/web-console/app/composition/browser-runtime-config.ts index f49c6756..e815ecd5 100644 --- a/components/web-console/app/composition/browser-runtime-config.ts +++ b/components/web-console/app/composition/browser-runtime-config.ts @@ -5,6 +5,9 @@ const runtimeConfigMetaName = "hypershell-runtime-config"; export interface BrowserRuntimeConfig { + build: { + version?: string; + }; tracing: { /** Fraction of browser-rooted traces to record, 0..1. */ sampleRatio: number; @@ -14,9 +17,17 @@ export interface BrowserRuntimeConfig { // When no config is available the browser records nothing: it must never emit // traces the BFF cannot relay (a dev server or a deployment with tracing off). const disabledRuntimeConfig: BrowserRuntimeConfig = { + build: {}, tracing: { sampleRatio: 0 }, }; +const buildVersionPattern = + /^(?:dev|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))-[0-9a-f]{7}$/u; + +function isBuildVersion(value: unknown): value is string { + return typeof value === "string" && buildVersionPattern.test(value); +} + function isSampleRatio(value: unknown): value is number { return ( typeof value === "number" && @@ -46,12 +57,15 @@ export function readBrowserRuntimeConfig( } try { const parsed = JSON.parse(content) as { + build?: { version?: unknown }; tracing?: { sampleRatio?: unknown }; }; + const buildVersion = parsed.build?.version; const sampleRatio = parsed.tracing?.sampleRatio; - return isSampleRatio(sampleRatio) - ? { tracing: { sampleRatio } } - : disabledRuntimeConfig; + return { + build: isBuildVersion(buildVersion) ? { version: buildVersion } : {}, + tracing: { sampleRatio: isSampleRatio(sampleRatio) ? sampleRatio : 0 }, + }; } catch { return disabledRuntimeConfig; } diff --git a/components/web-console/app/features/shell/user-menu.test.tsx b/components/web-console/app/features/shell/user-menu.test.tsx index fb38e735..5967fd67 100644 --- a/components/web-console/app/features/shell/user-menu.test.tsx +++ b/components/web-console/app/features/shell/user-menu.test.tsx @@ -29,8 +29,19 @@ function renderMenu() { ); } +function setBuildVersion(version: string) { + const meta = document.createElement("meta"); + meta.setAttribute("name", "hypershell-runtime-config"); + meta.setAttribute( + "content", + JSON.stringify({ build: { version }, tracing: { sampleRatio: 0 } }), + ); + document.head.append(meta); +} + beforeEach(() => { vi.clearAllMocks(); + document.querySelector('meta[name="hypershell-runtime-config"]')?.remove(); }); it("shows the user name and a full sign-out link", async () => { @@ -40,6 +51,7 @@ it("shows the user name and a full sign-out link", async () => { roles: ["hypershell-users"], user: { name: "Ada Lovelace", preferredUsername: "ada" }, }); + setBuildVersion("v1.6.0-1234567"); renderMenu(); @@ -47,12 +59,40 @@ it("shows the user name and a full sign-out link", async () => { await user.click(toggle); const menu = screen.getByRole("menu"); + const version = within(menu).getByRole("menuitem", { + name: "Console version v1.6.0-1234567", + }); const logout = within(menu).getByRole("menuitem", { name: "Log out" }); + expect((version as HTMLButtonElement).disabled).toBe(true); + expect(version.getAttribute("href")).toBeNull(); // Sign-out is a real navigation to the BFF endpoint, not a client route, so // the BFF can clear the session and perform RP-initiated Keycloak logout. expect(logout.getAttribute("href")).toBe("/auth/logout"); }); +it("shows an unknown version and keeps logout available", async () => { + const user = userEvent.setup(); + getSessionMock.mockResolvedValue({ + authenticated: true, + roles: ["hypershell-users"], + user: { name: "Ada Lovelace" }, + }); + setBuildVersion("latest"); + + renderMenu(); + + await user.click( + await screen.findByRole("button", { name: /Ada Lovelace/u }), + ); + const menu = screen.getByRole("menu"); + expect( + within(menu).getByRole("menuitem", { + name: "Console version unknown", + }), + ).toBeTruthy(); + expect(within(menu).getByRole("menuitem", { name: "Log out" })).toBeTruthy(); +}); + it("falls back to the preferred username, then email, then Account", async () => { getSessionMock.mockResolvedValue({ authenticated: true, diff --git a/components/web-console/app/features/shell/user-menu.tsx b/components/web-console/app/features/shell/user-menu.tsx index 295d814b..84ed554e 100644 --- a/components/web-console/app/features/shell/user-menu.tsx +++ b/components/web-console/app/features/shell/user-menu.tsx @@ -8,6 +8,7 @@ import { UserIcon } from "@patternfly/react-icons"; import { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; +import { readBrowserRuntimeConfig } from "../../composition/browser-runtime-config"; import { messages } from "../../i18n/messages"; import { useSession } from "./use-session"; @@ -21,6 +22,9 @@ export function UserMenu() { const intl = useIntl(); const [isOpen, setIsOpen] = useState(false); const { data: session } = useSession(); + const buildVersion = + readBrowserRuntimeConfig().build.version ?? + intl.formatMessage(messages.unknownVersion); if (!session?.authenticated) { return null; @@ -55,6 +59,12 @@ export function UserMenu() { )} > + + + diff --git a/components/web-console/app/i18n/messages.ts b/components/web-console/app/i18n/messages.ts index 9d8d1c2a..db45e408 100644 --- a/components/web-console/app/i18n/messages.ts +++ b/components/web-console/app/i18n/messages.ts @@ -11,6 +11,11 @@ export const messages = defineMessages({ defaultMessage: "Breadcrumb", description: "Accessible label for the application breadcrumb navigation.", }, + consoleVersion: { + id: "app.consoleVersion", + defaultMessage: "Console version {version}", + description: "Web-console image version in the identity menu.", + }, errorBody: { id: "app.error.body", defaultMessage: "Refresh the page to try again.", @@ -70,4 +75,9 @@ export const messages = defineMessages({ description: "Accessible label for the color scheme toggle when dark mode is active.", }, + unknownVersion: { + id: "app.unknownVersion", + defaultMessage: "unknown", + description: "Version value when the web-console image version is absent.", + }, }); diff --git a/components/web-console/bff/src/config.ts b/components/web-console/bff/src/config.ts index 229c0b19..3ca29f3e 100644 --- a/components/web-console/bff/src/config.ts +++ b/components/web-console/bff/src/config.ts @@ -32,6 +32,14 @@ const httpOrigin = z const configSchema = z.object({ HOST: z.string().trim().min(1).default("0.0.0.0"), + HYPERSHELL_BUILD_VERSION: z + .string() + .trim() + .regex( + /^(?:dev|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))-[0-9a-f]{7}$/u, + "must contain a supported image build version", + ) + .optional(), HYPERSHELL_API_ORIGIN: httpOrigin.default("http://127.0.0.1:8000"), HYPERSHELL_API_TIMEOUT_MS: z.coerce .number() @@ -85,6 +93,7 @@ export interface TracingConfig { export interface ServerConfig { apiOrigin: string; apiTimeoutMs: number; + buildVersion?: string; host: string; logLevel: z.infer["LOG_LEVEL"]; nodeEnv: z.infer["NODE_ENV"]; @@ -105,6 +114,9 @@ export interface ServerConfig { * origins, session secret, and OIDC settings never cross this boundary. */ export interface BrowserRuntimeConfig { + build: { + version?: string; + }; tracing: { /** * Fraction of browser-rooted traces to record, 0..1. It mirrors the BFF @@ -121,6 +133,7 @@ export function browserRuntimeConfig( config: ServerConfig, ): BrowserRuntimeConfig { return { + build: config.buildVersion ? { version: config.buildVersion } : {}, tracing: { sampleRatio: config.tracing?.sampleRatio ?? 0 }, }; } @@ -161,6 +174,7 @@ export function loadConfig( return { apiOrigin: result.data.HYPERSHELL_API_ORIGIN, apiTimeoutMs: result.data.HYPERSHELL_API_TIMEOUT_MS, + buildVersion: result.data.HYPERSHELL_BUILD_VERSION, host: result.data.HOST, logLevel: result.data.LOG_LEVEL, nodeEnv: result.data.NODE_ENV, diff --git a/components/web-console/bff/test/app.test.ts b/components/web-console/bff/test/app.test.ts index 60e2b48a..195f7ee8 100644 --- a/components/web-console/bff/test/app.test.ts +++ b/components/web-console/bff/test/app.test.ts @@ -84,6 +84,7 @@ describe("web-console BFF", () => { const config: ServerConfig = { apiOrigin: `http://127.0.0.1:${String(address.port)}`, apiTimeoutMs: 100, + buildVersion: "v1.6.0-1234567", host: "127.0.0.1", logLevel: "silent", nodeEnv: "test", @@ -152,6 +153,9 @@ describe("web-console BFF", () => { expect(response.statusCode).toBe(200); expect(response.body).toContain('name="hypershell-runtime-config"'); + expect(response.body).toContain( + ""version":"v1.6.0-1234567"", + ); expect(response.body).toContain(""sampleRatio":0"); // The meta tag lands in the head, before the application markup. expect(response.body.indexOf("hypershell-runtime-config")).toBeLessThan( diff --git a/components/web-console/bff/test/config.test.ts b/components/web-console/bff/test/config.test.ts index d2fd10f8..5180184b 100644 --- a/components/web-console/bff/test/config.test.ts +++ b/components/web-console/bff/test/config.test.ts @@ -6,6 +6,7 @@ describe("loadConfig", () => { HOST: "127.0.0.1", HYPERSHELL_API_ORIGIN: "https://api.example.test/", HYPERSHELL_API_TIMEOUT_MS: "5000", + HYPERSHELL_BUILD_VERSION: "v1.6.0-1234567", LOG_LEVEL: "warn", NODE_ENV: "production", PORT: "8081", @@ -14,6 +15,7 @@ describe("loadConfig", () => { expect(config.apiOrigin).toBe("https://api.example.test"); expect(config.apiTimeoutMs).toBe(5000); + expect(config.buildVersion).toBe("v1.6.0-1234567"); expect(config.host).toBe("127.0.0.1"); expect(config.port).toBe(8081); expect(pathIsAbsolute(config.staticRoot)).toBe(true); @@ -34,6 +36,12 @@ describe("loadConfig", () => { ).toThrow(/HYPERSHELL_API_ORIGIN/u); }); + it("rejects an invalid image build version", () => { + expect(() => loadConfig({ HYPERSHELL_BUILD_VERSION: "latest" })).toThrow( + /HYPERSHELL_BUILD_VERSION/u, + ); + }); + it("leaves tracing disabled when no collector endpoint is set", () => { const config = loadConfig({ STATIC_ROOT: "./public" }); @@ -89,6 +97,7 @@ describe("browserRuntimeConfig", () => { }); expect(browserRuntimeConfig(config)).toEqual({ + build: {}, tracing: { sampleRatio: 0.25 }, }); }); @@ -97,6 +106,7 @@ describe("browserRuntimeConfig", () => { const config = loadConfig({ STATIC_ROOT: "./public" }); expect(browserRuntimeConfig(config)).toEqual({ + build: {}, tracing: { sampleRatio: 0 }, }); }); @@ -111,7 +121,22 @@ describe("browserRuntimeConfig", () => { const serialized = JSON.stringify(browserRuntimeConfig(config)); expect(serialized).not.toContain("collector.example.test"); expect(serialized).not.toContain("a".repeat(64)); - expect(Object.keys(browserRuntimeConfig(config))).toEqual(["tracing"]); + expect(Object.keys(browserRuntimeConfig(config))).toEqual([ + "build", + "tracing", + ]); + }); + + it("exposes the image build version to the browser", () => { + const config = loadConfig({ + HYPERSHELL_BUILD_VERSION: "dev-abcdef0", + STATIC_ROOT: "./public", + }); + + expect(browserRuntimeConfig(config)).toEqual({ + build: { version: "dev-abcdef0" }, + tracing: { sampleRatio: 0 }, + }); }); }); diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 377c6449..3a47a56f 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -27,6 +27,10 @@ "defaultMessage": "Copy", "description": "Tooltip for a button that copies text to the clipboard." }, + "app.consoleVersion": { + "defaultMessage": "Console version {version}", + "description": "Web-console image version in the identity menu." + }, "app.error.body": { "defaultMessage": "Refresh the page to try again.", "description": "Recovery guidance shown after an unexpected route failure." @@ -755,6 +759,10 @@ "defaultMessage": "Switch to light mode", "description": "Accessible label for the color scheme toggle when dark mode is active." }, + "app.unknownVersion": { + "defaultMessage": "unknown", + "description": "Version value when the web-console image version is absent." + }, "app.value.notAvailable": { "defaultMessage": "Not available", "description": "Shown when the API does not provide a value." diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index b28fd69e..4663854a 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -48,9 +48,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-08-31 (Keycloak event-storm KC-ES-W1 complete) -**Spec corpus**: 40 spec files; the coverage table tracks 32 analyzed feature/spec groups after adding OpenShell Gateway Console -**Codebase commit**: working tree (Keycloak event-storm KC-ES-W1 complete) +**Last analyzed**: 2026-09-02 (source-release REL-W4 complete) +**Spec corpus**: 44 spec files; the coverage table tracks 33 analyzed feature/spec groups +**Codebase commit**: working tree (source-release REL-W4 complete) ### Coverage Summary @@ -72,16 +72,17 @@ skills/ | Platform - Local Development | 1 | 25 | 23 | 0 | 1 | 1 | 96% | | Platform - E2E Testing | 1 | 8 | 8 | 0 | 0 | 0 | 100% | | Platform - OIDC Integration | 1 | 7 | 6 | 1 | 0 | 0 | 93% | +| Platform - Source Release | 1 | 12 | 12 | 0 | 0 | 0 | 100% | | Web Console - Architecture | 1 | 28 | 21 | 5 | 2 | 0 | 86% | | Security - RBAC Enforcement | 1 | 13 | 11 | 0 | 0 | 2 | 85% | | Standards | 13 | 0 | 0 | 0 | 0 | 0 | N/A | -| **TOTAL** | **32** | **225** | **176** | **18** | **26** | **5** | **82%** | +| **TOTAL** | **33** | **237** | **188** | **18** | **26** | **5** | **83%** | ### Spec Dependency Order ``` Layer 0 (roots): data-model, standards/* -Layer 1: control-plane, local-development, web-console architecture +Layer 1: control-plane, local-development, source-release, web-console architecture Layer 2: openshell-gateway (core) Layer 3: openshell-gateway-database, openshell-gateway-tls Layer 4: openshell-gateway-oidc (depends on TLS for trusted CA) @@ -112,9 +113,9 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | REL-07 | Build version format | Present | - | `Makefile`, `scripts/build-version.sh`, `scripts/kind/`, `.tekton/` | REL-W2 | | REL-08 | Container metadata | Present | - | `components/{api-server,control-plane,web-console}/Dockerfile` | REL-W2 | | REL-09 | API service metadata | Present | - | `components/api-server/openapi/openapi.yaml`, `components/api-server/pkg/api/metadata_test.go` | REL-W3 | -| REL-10 | Web-console version display | Missing | Runtime configuration and the user-menu row are absent. | `components/web-console/` | REL-W4 | +| REL-10 | Web-console version display | Present | - | `components/web-console/bff/src/config.ts`, `components/web-console/app/features/shell/user-menu.tsx` | REL-W4 | | REL-11 | Registry and deployment scope | Present | - | `.tekton/`, `docs/releasing.md` | REL-W1 | -| REL-12 | Verification and documentation | Partial | Release controls have tests and documentation. Image, API, and console checks remain. | `scripts/test_release_policy.py`, `docs/releasing.md` | REL-W1..REL-W4 | +| REL-12 | Verification and documentation | Present | - | `scripts/test_release_policy.py`, `scripts/test_build_version.py`, `scripts/check_image_build_policy.py`, `components/api-server/pkg/api/metadata_test.go`, `components/web-console/app/features/shell/user-menu.test.tsx`, `docs/releasing.md` | REL-W1..REL-W4 | **Wave plan:** From 58b1e9a34f6f10c18b7a940523590ebe16fbb495 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 11:37:56 -0400 Subject: [PATCH 06/16] fix(release): use published history baseline --- release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-please-config.json b/release-please-config.json index 9a843da8..0156b062 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "bootstrap-sha": "8c2e9fb4ce76d320e399cbc7cf6a2f338308d746", + "bootstrap-sha": "f35eb1120efc25a61f8ad7fd060d15704f3945b3", "always-update": true, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": false, From dd46967c5e4e14b83a19f1472c2963535f5e6d9c Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 12:16:04 -0400 Subject: [PATCH 07/16] feat(release): support Jira keys in pull request titles --- AGENTS.md | 23 +++++++++++++++++++++++ CLAUDE.md | 19 +++++++++++++++++++ docs/releasing.md | 10 ++++++++++ scripts/release_policy.py | 11 +++++++++-- scripts/test_release_policy.py | 3 +++ specs/platform/source-release.spec.md | 13 +++++++++++++ 6 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0ad54191 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# HyperShell Agent Guidance + +Read [CLAUDE.md](CLAUDE.md) for the repository structure, commands, and +development conventions. + +## Pull Requests + +Use a Conventional Commits title for each pull request: + +```text +(): +``` + +If the title must contain a Jira key, put the key after the colon: + +```text +feat(release): [HYPERSHELL-123] add managed source releases +``` + +Do not put the Jira key before the conventional type. The repository uses the +pull request title as the squash commit subject. Release Please must be able to +parse the type at the start of that subject. See [docs/releasing.md](docs/releasing.md) +for the allowed types and release rules. diff --git a/CLAUDE.md b/CLAUDE.md index 0aad3909..9a722bd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,25 @@ make hooks-install The pre-commit and pre-push hooks run the repository policy checks. Run the same checks manually with `make check`. +## Pull Requests + +Use a Conventional Commits title for each pull request: + +```text +(): +``` + +If the title must contain a Jira key, put the key after the colon: + +```text +feat(release): [HYPERSHELL-123] add managed source releases +``` + +Do not put the Jira key before the conventional type. The repository uses the +pull request title as the squash commit subject. Release Please must be able to +parse the type at the start of that subject. See [release guidance](docs/releasing.md) +for the allowed types and release rules. + ## Structure - `components/api-server/` - Go REST + gRPC API microservice (rh-trex-ai framework), PostgreSQL-backed diff --git a/docs/releasing.md b/docs/releasing.md index 8fd78326..c116cd2d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -12,6 +12,16 @@ Use this form for each pull request title: (): ``` +To keep Jira tracking in the title, put the Jira key after the colon: + +```text +feat(release): [HYPERSHELL-123] add managed source releases +``` + +The conventional type must remain first. Do not put the Jira key before the +type. Release Please cannot parse a squash commit that starts with the Jira +key. + The allowed types are `feat`, `fix`, `perf`, `refactor`, `docs`, `spec`, `deps`, `build`, `ci`, `test`, `style`, `chore`, and `revert`. The `feat` type increments the minor version. The `fix` type increments the patch version. A diff --git a/scripts/release_policy.py b/scripts/release_policy.py index 2bb04721..b3187887 100644 --- a/scripts/release_policy.py +++ b/scripts/release_policy.py @@ -13,6 +13,7 @@ REPOSITORY_ROOT = Path(__file__).resolve().parent.parent TITLE_FORM = "(): " +JIRA_TITLE_EXAMPLE = "feat: [HYPERSHELL-123] " ALLOWED_TYPES = ( "build", "chore", @@ -29,10 +30,13 @@ "test", ) _TYPE_PATTERN = "|".join(ALLOWED_TYPES) +_JIRA_KEY_PATTERN = r"\[HYPERSHELL-[1-9][0-9]*\]" _TITLE_PATTERN = re.compile( rf"^(?P{_TYPE_PATTERN})" r"(?:\((?P[a-z0-9][a-z0-9._/-]*)\))?" - r"(?P!)?: (?P\S(?:.*\S)?)$" + r"(?P!)?: " + rf"(?:(?P{_JIRA_KEY_PATTERN}) )?" + r"(?P(?!\[HYPERSHELL-)\S(?:.*\S)?)$" ) _SEMVER_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") _RELEASE_TITLE_PATTERN = re.compile(r"^chore\(main\): release (?P" + _SEMVER_PATTERN.pattern + r")$") @@ -50,7 +54,10 @@ def title_error(title: str) -> str | None: if _TITLE_PATTERN.fullmatch(title): return None allowed = ", ".join(ALLOWED_TYPES) - return f"title must use {TITLE_FORM}; allowed types: {allowed}" + return ( + f"title must use {TITLE_FORM}; an optional Jira key must follow the " + f"colon, for example {JIRA_TITLE_EXAMPLE}; allowed types: {allowed}" + ) def is_releasing_commit(message: str) -> bool: diff --git a/scripts/test_release_policy.py b/scripts/test_release_policy.py index 2c05548d..42ec60f0 100644 --- a/scripts/test_release_policy.py +++ b/scripts/test_release_policy.py @@ -17,6 +17,7 @@ def test_accepts_supported_titles(self): "feat: add releases", "fix(api): return the version", "feat(web-console)!: change the menu", + "feat(release): [HYPERSHELL-123] add releases", "chore(main): release 0.1.0", ): with self.subTest(title=title): @@ -27,6 +28,8 @@ def test_rejects_invalid_titles(self): "Add releases", "Feature: add releases", "feat(API): add releases", + "[HYPERSHELL-123] feat(release): add releases", + "feat(release): [HYPERSHELL-ABC] add releases", "feat: ", "feat add releases", "merge: add releases", diff --git a/specs/platform/source-release.spec.md b/specs/platform/source-release.spec.md index 849a1f28..37f05a11 100644 --- a/specs/platform/source-release.spec.md +++ b/specs/platform/source-release.spec.md @@ -30,6 +30,12 @@ The type and scope SHALL use lowercase characters. The repository SHALL check the title in a required CI gate. A squash merge SHALL use the pull request title as the commit subject on `main`. +An optional Jira key MAY be the first part of the description. It SHALL use the +form `[HYPERSHELL-]`. The conventional type SHALL remain the first part +of the title. For example, `feat(api): [HYPERSHELL-123] add service status` is +valid. `[HYPERSHELL-123] feat(api): add service status` is not valid because +Release Please cannot parse that squash commit subject. + `fix` SHALL request a patch increment. `feat` SHALL request a minor increment. The `!` marker or a `BREAKING CHANGE` footer SHALL request a major increment. Before version `1.0.0`, a breaking change SHALL request a minor increment. @@ -49,6 +55,13 @@ version increment by themselves. - WHEN `feat(api): add service status` enters `main` - THEN the Release PR SHALL propose version `1.3.0` +#### Scenario: Jira-linked pull request title + +- GIVEN a pull request tracks Jira issue `HYPERSHELL-123` +- WHEN its title is `feat(api): [HYPERSHELL-123] add service status` +- THEN the required lint gate SHALL pass +- AND the squash commit subject SHALL keep the Jira key + ### Requirement REL-02: Managed release pull request Release Please SHALL maintain one Release PR against `main`. It SHALL open the From 03d3f6277762b0c1ba20dc95b9344d362983d938 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 12:16:25 -0400 Subject: [PATCH 08/16] test(build): validate image metadata semantically --- Makefile | 8 +-- scripts/check_image_build_policy.py | 80 ++++++++++++++++++++++-- scripts/test_check_image_build_policy.py | 51 +++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 scripts/test_check_image_build_policy.py diff --git a/Makefile b/Makefile index ba320329..7af8dc8c 100644 --- a/Makefile +++ b/Makefile @@ -217,12 +217,12 @@ test-release-policy: check-release-policy: test-release-policy PYTHONDONTWRITEBYTECODE=1 python3 scripts/release_policy.py check-files -.PHONY: test-build-version -test-build-version: - PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_build_version.py +.PHONY: test-image-build-policy +test-image-build-policy: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/test_build_version.py scripts/test_check_image_build_policy.py .PHONY: check-image-build-policy -check-image-build-policy: test-build-version +check-image-build-policy: test-image-build-policy PYTHONDONTWRITEBYTECODE=1 python3 scripts/check_image_build_policy.py .PHONY: check diff --git a/scripts/check_image_build_policy.py b/scripts/check_image_build_policy.py index 3b84a892..dd1276bb 100644 --- a/scripts/check_image_build_policy.py +++ b/scripts/check_image_build_policy.py @@ -2,6 +2,7 @@ """Check image identity and event-specific component build rules.""" from pathlib import Path +import re import sys @@ -21,6 +22,78 @@ }, } NON_PUSH_RELEASE_FILES = ("VERSION", "CHANGELOG.md", "build-version.env") +SHORT_REVISION_TRIM = 33 +_ASSIGNMENT_PATTERN = ( + r"(?:^|\s){name}\s*=\s*(?:\"(?P[^\"]*)\"|" + r"'(?P[^']*)'|(?P[^\s]+))" +) +_BUILD_VERSION_PATTERN = re.compile( + r"(?:\$BUILD_PREFIX|\$\{BUILD_PREFIX\})-" + r"\$\{VCS_REF%(?P\?+)\}" +) + + +def _logical_instructions(dockerfile: str) -> list[str]: + """Return Dockerfile instructions with joined continuation lines.""" + joined = re.sub(r"\\[ \t]*\n[ \t]*", " ", dockerfile) + return [ + line.strip() + for line in joined.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def _assignment_values(instructions: list[str], name: str) -> list[str]: + """Return all values for one Dockerfile assignment.""" + pattern = re.compile(_ASSIGNMENT_PATTERN.format(name=re.escape(name))) + values: list[str] = [] + for instruction in instructions: + for match in pattern.finditer(instruction): + values.append( + match.group("double") + or match.group("single") + or match.group("bare") + ) + return values + + +def _is_variable_reference(value: str, name: str) -> bool: + return value in {f"${name}", f"${{{name}}}"} + + +def image_metadata_errors(relative_path: str, dockerfile: str) -> list[str]: + """Return errors for image identity metadata.""" + errors: list[str] = [] + instructions = _logical_instructions(dockerfile) + + for name, label in ( + ("org.opencontainers.image.version", "OCI build version"), + ("HYPERSHELL_BUILD_VERSION", "runtime build version"), + ): + values = _assignment_values(instructions, name) + if len(values) != 1: + errors.append(f"{relative_path} must set one {label}") + continue + match = _BUILD_VERSION_PATTERN.fullmatch(values[0]) + if match is None: + errors.append( + f"{relative_path} {label} must combine BUILD_PREFIX and a shortened VCS_REF" + ) + continue + if len(match.group("trim")) != SHORT_REVISION_TRIM: + errors.append( + f"{relative_path} {label} must shorten the revision to seven characters" + ) + + for name, label in ( + ("org.opencontainers.image.revision", "OCI revision"), + ("HYPERSHELL_BUILD_REVISION", "runtime revision"), + ): + values = _assignment_values(instructions, name) + if len(values) != 1 or not _is_variable_reference(values[0], "VCS_REF"): + errors.append(f"{relative_path} must set {label} from VCS_REF") + + return errors def check_repository(root: Path) -> list[str]: @@ -53,12 +126,7 @@ def check_repository(root: Path) -> list[str]: dockerfile_path = root / values["dockerfile"] dockerfile = dockerfile_path.read_text(encoding="utf-8") - if 'org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}"' not in dockerfile: - errors.append(f"{values['dockerfile']} must set the OCI build version") - if 'org.opencontainers.image.revision="${VCS_REF}"' not in dockerfile: - errors.append(f"{values['dockerfile']} must set the OCI revision") - if 'HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}"' not in dockerfile: - errors.append(f"{values['dockerfile']} must set the runtime build version") + errors.extend(image_metadata_errors(values["dockerfile"], dockerfile)) e2e = (root / ".github/workflows/e2e.yml").read_text(encoding="utf-8") if "release_version_changed=false" not in e2e: diff --git a/scripts/test_check_image_build_policy.py b/scripts/test_check_image_build_policy.py new file mode 100644 index 00000000..7b6ccad9 --- /dev/null +++ b/scripts/test_check_image_build_policy.py @@ -0,0 +1,51 @@ +import importlib.util +from pathlib import Path +import unittest + + +SCRIPT_PATH = Path(__file__).with_name("check_image_build_policy.py") +SPEC = importlib.util.spec_from_file_location("check_image_build_policy", SCRIPT_PATH) +POLICY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(POLICY) + + +class ImageMetadataPolicyTest(unittest.TestCase): + def test_accepts_reordered_assignments_and_different_quotes(self): + trim = "?" * 33 + dockerfile = f""" + # syntax=docker/dockerfile:1 + ENV HYPERSHELL_BUILD_REVISION=$VCS_REF \\ + HYPERSHELL_BUILD_VERSION='${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}' + LABEL org.opencontainers.image.revision='${{VCS_REF}}' \\ + org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" + """ + + self.assertEqual([], POLICY.image_metadata_errors("Dockerfile", dockerfile)) + + def test_reports_missing_assignments(self): + errors = POLICY.image_metadata_errors("Dockerfile", "FROM scratch\n") + + self.assertEqual(4, len(errors)) + self.assertIn("Dockerfile must set one OCI build version", errors) + self.assertIn("Dockerfile must set one runtime build version", errors) + self.assertIn("Dockerfile must set OCI revision from VCS_REF", errors) + self.assertIn("Dockerfile must set runtime revision from VCS_REF", errors) + + def test_reports_an_invalid_short_revision(self): + trim = "?" * 32 + dockerfile = f""" + ENV HYPERSHELL_BUILD_VERSION="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" \\ + HYPERSHELL_BUILD_REVISION="${{VCS_REF}}" + LABEL org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" \\ + org.opencontainers.image.revision="${{VCS_REF}}" + """ + + errors = POLICY.image_metadata_errors("Dockerfile", dockerfile) + + self.assertEqual(2, len(errors)) + for error in errors: + self.assertIn("must shorten the revision to seven characters", error) + + +if __name__ == "__main__": + unittest.main() From 79cf6a3cd964eaf85ae3985435c7618553a10b0e Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 12:16:50 -0400 Subject: [PATCH 09/16] fix(api): restore generated metadata documentation --- .../pkg/api/openapi/docs/ServiceMetadata.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md b/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md index ec444164..99ddbc97 100644 --- a/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md +++ b/components/api-server/pkg/api/openapi/docs/ServiceMetadata.md @@ -4,11 +4,11 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**Id** | **string** | Service identifier | -**Href** | **string** | Metadata request path | -**Kind** | **string** | | -**Version** | **string** | API server image build version | -**BuildTime** | **string** | Time when the API server binary was built | +**Id** | **string** | Service identifier | +**Href** | **string** | Metadata request path | +**Kind** | **string** | | +**Version** | **string** | API server image build version | +**BuildTime** | **string** | Time when the API server binary was built | ## Methods @@ -131,3 +131,5 @@ SetBuildTime sets BuildTime field to given value. [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + From 9690868fd336bfe73ac1b1f7b040dbf771be11dc Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 12:19:40 -0400 Subject: [PATCH 10/16] docs(release): explain GitHub App setup --- docs/releasing.md | 53 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index c116cd2d..edcb375c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -52,17 +52,48 @@ first changelog does not contain older repository history. ## GitHub App Setup -Create and install a GitHub App for Release Please. Give the installation these -repository permissions: - -- Contents: Read and write -- Issues: Read and write -- Pull requests: Read and write - -Set the app client ID in the `RELEASE_PLEASE_APP_CLIENT_ID` repository -variable. Set the private key in the `RELEASE_PLEASE_APP_PRIVATE_KEY` -repository secret. The app token lets checks run on a Release PR that the app -creates or updates. +The current workflow requires a GitHub App token. The built-in `GITHUB_TOKEN` +can create a Release PR, but that PR does not start other GitHub Actions +workflows. The app token lets the Release PR start the normal required checks. + +The client ID identifies the app. It is not a secret. The private key proves +the app identity and is a secret. The workflow uses both values to create a +short-lived installation token for this repository. + +First, ask an `openshift-online` organization owner whether a suitable release +automation app already exists. If it does not exist, an organization owner or +a person with permission to manage the organization GitHub Apps must +[register a GitHub App][register-app]. Use these settings: + +- Make the app private to the organization. +- Set the homepage URL to the HyperShell repository. +- Disable webhooks. This workflow does not use them. +- Do not request user authorization. +- Give the app these repository permissions: + - Contents: Read and write + - Issues: Read and write + - Pull requests: Read and write + +Do not give the app organization permissions. Install the app on the +`openshift-online` organization and give it access only to the `hypershell` +repository. The organization policy can require owner approval. + +On the app settings page, copy the client ID. Under **Private keys**, select +**Generate a private key**. GitHub downloads a PEM file. Keep this file secure, +and do not commit it. If the file is lost, generate a new key. + +In the HyperShell repository, go to **Settings > Secrets and variables > +Actions**. Add these values: + +- Variable `RELEASE_PLEASE_APP_CLIENT_ID`: the app client ID +- Secret `RELEASE_PLEASE_APP_PRIVATE_KEY`: the complete PEM private key + +The workflow does not store the generated installation token. GitHub expires +the token after one hour. A personal access token can also start subsequent +workflows, but it is tied to a person. A GitHub App is the preferred repository +credential for this automation. + +[register-app]: https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app ## Failure Recovery From c251962f4f43bff04e5be428ba94835270b013e2 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 12:21:35 -0400 Subject: [PATCH 11/16] docs(release): clarify automated check approval --- docs/releasing.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index edcb375c..00bcd6e2 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -53,8 +53,10 @@ first changelog does not contain older repository history. ## GitHub App Setup The current workflow requires a GitHub App token. The built-in `GITHUB_TOKEN` -can create a Release PR, but that PR does not start other GitHub Actions -workflows. The app token lets the Release PR start the normal required checks. +can create a Release PR, but GitHub puts the [resulting workflow runs][github-token] +in an approval-required state. A person would have to approve the checks after +each Release PR update. The app token lets the normal required checks start +without manual approval. The client ID identifies the app. It is not a secret. The private key proves the app identity and is a secret. The workflow uses both values to create a @@ -78,9 +80,9 @@ Do not give the app organization permissions. Install the app on the `openshift-online` organization and give it access only to the `hypershell` repository. The organization policy can require owner approval. -On the app settings page, copy the client ID. Under **Private keys**, select -**Generate a private key**. GitHub downloads a PEM file. Keep this file secure, -and do not commit it. If the file is lost, generate a new key. +On the app settings page, copy the client ID. Under **Private keys**, +[generate a private key][private-key]. GitHub downloads a PEM file. Keep this +file secure, and do not commit it. If the file is lost, generate a new key. In the HyperShell repository, go to **Settings > Secrets and variables > Actions**. Add these values: @@ -94,6 +96,8 @@ workflows, but it is tied to a person. A GitHub App is the preferred repository credential for this automation. [register-app]: https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app +[private-key]: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps +[github-token]: https://docs.github.com/en/actions/concepts/security/github_token ## Failure Recovery From b1e5b073fb81aaa09c3fb160604e8590c5bfa663 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 12:28:33 -0400 Subject: [PATCH 12/16] fix(release): use manual workflow approval --- .github/workflows/release-please.yml | 19 +++----- docs/releasing.md | 65 ++++++++------------------- skills/RECONCILE.md | 6 +-- specs/platform/source-release.spec.md | 16 +++++-- 4 files changed, 38 insertions(+), 68 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b9973f3a..83e9ea78 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -9,7 +9,9 @@ on: workflow_dispatch: permissions: - contents: read + contents: write + issues: write + pull-requests: write concurrency: group: release-please-${{ github.repository }} @@ -25,28 +27,17 @@ jobs: github.event.workflow_run.head_branch == 'main') runs-on: ubuntu-24.04 steps: - - name: Create GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} - permission-contents: write - permission-issues: write - permission-pull-requests: write - - name: Check out the tested revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ github.event.workflow_run.head_sha || github.sha }} - token: ${{ steps.app-token.outputs.token }} - name: Decide whether Release Please must run id: gate env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_REF: ${{ github.event.workflow_run.head_sha || github.sha }} run: | open_count="$(gh pr list --base main --label 'autorelease: pending' --state open --json number --jq 'length')" @@ -62,6 +53,6 @@ jobs: if: steps.gate.outputs.run == 'true' uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: - token: ${{ steps.app-token.outputs.token }} + token: ${{ secrets.GITHUB_TOKEN }} config-file: release-please-config.json manifest-file: .release-please-manifest.json diff --git a/docs/releasing.md b/docs/releasing.md index 00bcd6e2..a0c3e1d3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -50,53 +50,24 @@ The first manifest version is `0.0.0`. The history baseline is the commit before this release strategy. Thus, the first feature proposes `0.1.0`, and the first changelog does not contain older repository history. -## GitHub App Setup - -The current workflow requires a GitHub App token. The built-in `GITHUB_TOKEN` -can create a Release PR, but GitHub puts the [resulting workflow runs][github-token] -in an approval-required state. A person would have to approve the checks after -each Release PR update. The app token lets the normal required checks start -without manual approval. - -The client ID identifies the app. It is not a secret. The private key proves -the app identity and is a secret. The workflow uses both values to create a -short-lived installation token for this repository. - -First, ask an `openshift-online` organization owner whether a suitable release -automation app already exists. If it does not exist, an organization owner or -a person with permission to manage the organization GitHub Apps must -[register a GitHub App][register-app]. Use these settings: - -- Make the app private to the organization. -- Set the homepage URL to the HyperShell repository. -- Disable webhooks. This workflow does not use them. -- Do not request user authorization. -- Give the app these repository permissions: - - Contents: Read and write - - Issues: Read and write - - Pull requests: Read and write - -Do not give the app organization permissions. Install the app on the -`openshift-online` organization and give it access only to the `hypershell` -repository. The organization policy can require owner approval. - -On the app settings page, copy the client ID. Under **Private keys**, -[generate a private key][private-key]. GitHub downloads a PEM file. Keep this -file secure, and do not commit it. If the file is lost, generate a new key. - -In the HyperShell repository, go to **Settings > Secrets and variables > -Actions**. Add these values: - -- Variable `RELEASE_PLEASE_APP_CLIENT_ID`: the app client ID -- Secret `RELEASE_PLEASE_APP_PRIVATE_KEY`: the complete PEM private key - -The workflow does not store the generated installation token. GitHub expires -the token after one hour. A personal access token can also start subsequent -workflows, but it is tied to a person. A GitHub App is the preferred repository -credential for this automation. - -[register-app]: https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app -[private-key]: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps +## Release PR Workflow Approval + +The Release Please workflow uses the built-in `GITHUB_TOKEN`. It needs no +repository variable or secret. Its permissions are limited to write access for +contents, issues, and pull requests. + +GitHub puts [workflow runs for an automation-created pull request][github-token] +in an approval-required state. After Release Please opens or updates the +Release PR, a user with write access must open the PR and select **Approve +workflows to run**. Approve the workflow runs for the current revision before +you merge the Release PR. You can wait until the release is ready and approve +only the current revision. + +Events that the built-in token creates do not start later workflows, except for +the documented pull request case. This strategy does not require a later tag or +release workflow. If a later change adds that automation, use a GitHub App or a +personal access token for Release Please. + [github-token]: https://docs.github.com/en/actions/concepts/security/github_token ## Failure Recovery diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 4663854a..748668f5 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -48,9 +48,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-09-02 (source-release REL-W4 complete) +**Last analyzed**: 2026-09-02 (source-release manual approval update complete) **Spec corpus**: 44 spec files; the coverage table tracks 33 analyzed feature/spec groups -**Codebase commit**: working tree (source-release REL-W4 complete) +**Codebase commit**: working tree (source-release manual approval update complete) ### Coverage Summary @@ -107,7 +107,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | REL-01 | Conventional pull request titles | Present | - | `scripts/release_policy.py`, `.github/workflows/lint.yml` | REL-W1 | | REL-02 | Managed Release PR | Present | - | `.github/workflows/release-please.yml`, `release-please-config.json` | REL-W1 | | REL-03 | Release files | Present | - | `VERSION`, `CHANGELOG.md`, `build-version.env`, `.release-please-manifest.json` | REL-W1 | -| REL-04 | Release publication | Present | The repository owner must set the documented GitHub App variable and secret. | `.github/workflows/release-please.yml`, `docs/releasing.md` | REL-W1 | +| REL-04 | Release publication | Present | - | `.github/workflows/release-please.yml`, `docs/releasing.md` | REL-W1 | | REL-05 | First release baseline | Present | - | `release-please-config.json`, `.release-please-manifest.json` | REL-W1 | | REL-06 | Component-selective CI builds | Present | - | `.tekton/`, `.github/workflows/e2e.yml` | REL-W2 | | REL-07 | Build version format | Present | - | `Makefile`, `scripts/build-version.sh`, `scripts/kind/`, `.tekton/` | REL-W2 | diff --git a/specs/platform/source-release.spec.md b/specs/platform/source-release.spec.md index 37f05a11..f173d03e 100644 --- a/specs/platform/source-release.spec.md +++ b/specs/platform/source-release.spec.md @@ -73,6 +73,11 @@ selects it for release. The automation SHALL NOT enable automatic merge. The Release PR is a release decision surface. It is not a SemVer prerelease, and it SHALL NOT create an `-rc` tag. +Release Please SHALL use the repository `GITHUB_TOKEN` to create and update the +Release PR. GitHub SHALL hold the resulting CI workflow runs for manual +approval. A user with write access SHALL approve the workflow runs for the +current Release PR revision before merge. + #### Scenario: Work enters main while the Release PR is open - GIVEN an open Release PR proposes version `0.4.0` @@ -80,6 +85,7 @@ and it SHALL NOT create an `-rc` tag. - THEN Release Please SHALL update the same Release PR - AND it SHALL update the proposed changelog - AND it SHALL recalculate the proposed version when necessary +- AND CI workflow runs for the updated revision SHALL wait for manual approval - AND it SHALL NOT create a GitHub release #### Scenario: Maintenance-only work @@ -117,8 +123,9 @@ PR commit. The GitHub release notes SHALL use the matching `CHANGELOG.md` section. The repository SHALL have immutable GitHub releases enabled. Release automation SHALL be safe to run again after a failure. It SHALL use a -dedicated GitHub App token so that automation-created pull requests run normal -CI. Each external GitHub Action SHALL use a full commit SHA. +repository `GITHUB_TOKEN` with write access only for contents, issues, and pull +requests. The repository SHALL allow GitHub Actions to create pull requests. +Each external GitHub Action SHALL use a full commit SHA. #### Scenario: Ordinary merge @@ -290,8 +297,9 @@ Repository checks SHALL verify conventional title validation, release-file consistency, build-version calculation, API metadata, browser runtime configuration, user-menu presentation, and event-specific image selection. Release documentation SHALL describe commit types, release PR operation, -required GitHub App permissions, the manual merge step, first-release behavior, -failure recovery, and the boundary with Argo image bump. +`GITHUB_TOKEN` permissions, manual workflow approval, the manual merge step, +first-release behavior, failure recovery, and the boundary with Argo image +bump. #### Scenario: Release automation fails after merge From 48ffb4855bd4ce7b9f64fd4e239965530caa02e9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 13:40:08 -0400 Subject: [PATCH 13/16] feat(web-console): show API and console versions --- .../app/adapters/api/api-version.test.ts | 64 +++++++++++++++++++ .../app/adapters/api/api-version.ts | 38 +++++++++++ .../composition/api-version-composition.ts | 4 ++ .../app/features/shell/use-api-version.ts | 15 +++++ .../app/features/shell/user-menu.test.tsx | 62 ++++++++++++++++-- .../app/features/shell/user-menu.tsx | 29 ++++++--- components/web-console/app/i18n/messages.ts | 5 ++ components/web-console/locales/en.json | 4 ++ skills/RECONCILE.md | 8 +-- specs/platform/source-release.spec.md | 25 +++++--- 10 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 components/web-console/app/adapters/api/api-version.test.ts create mode 100644 components/web-console/app/adapters/api/api-version.ts create mode 100644 components/web-console/app/composition/api-version-composition.ts create mode 100644 components/web-console/app/features/shell/use-api-version.ts diff --git a/components/web-console/app/adapters/api/api-version.test.ts b/components/web-console/app/adapters/api/api-version.test.ts new file mode 100644 index 00000000..0096f190 --- /dev/null +++ b/components/web-console/app/adapters/api/api-version.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createApiVersionAdapter } from "./api-version"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +describe("API version adapter", () => { + it("reads the API image version and preserves cancellation", async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValue( + jsonResponse({ + build_time: "2026-09-02T15:00:00Z", + href: "/api/hypershell/v1/metadata", + id: "hypershell", + kind: "API", + version: "v1.6.0-7654321", + }), + ); + const abortController = new AbortController(); + + await expect( + createApiVersionAdapter(fetchImplementation).readVersion( + abortController.signal, + ), + ).resolves.toBe("v1.6.0-7654321"); + expect(fetchImplementation).toHaveBeenCalledWith( + "/api/hypershell/v1/metadata", + { + credentials: "same-origin", + headers: { accept: "application/json" }, + signal: abortController.signal, + }, + ); + }); + + it("rejects a failed metadata response", async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValue(jsonResponse({ error: "unavailable" }, 503)); + + await expect( + createApiVersionAdapter(fetchImplementation).readVersion(), + ).rejects.toThrow("API metadata request failed with 503"); + }); + + it.each([null, {}, { version: "" }, { version: 42 }])( + "rejects metadata without a version: %j", + async (body) => { + const fetchImplementation = vi + .fn() + .mockResolvedValue(jsonResponse(body)); + + await expect( + createApiVersionAdapter(fetchImplementation).readVersion(), + ).rejects.toThrow(/API metadata response/u); + }, + ); +}); diff --git a/components/web-console/app/adapters/api/api-version.ts b/components/web-console/app/adapters/api/api-version.ts new file mode 100644 index 00000000..170ecd05 --- /dev/null +++ b/components/web-console/app/adapters/api/api-version.ts @@ -0,0 +1,38 @@ +/** The API build identity that the user menu needs. */ +export interface ApiVersionReader { + readVersion(signal?: AbortSignal): Promise; +} + +const metadataPath = "/api/hypershell/v1/metadata"; + +function versionFromMetadata(body: unknown): string { + if (typeof body !== "object" || body === null) { + throw new Error("API metadata response is not an object"); + } + const version = (body as { version?: unknown }).version; + if (typeof version !== "string" || version.trim() === "") { + throw new Error("API metadata response has no version"); + } + return version.trim(); +} + +/** Reads the API image version through the same-origin BFF proxy. */ +export function createApiVersionAdapter( + fetchImplementation: typeof globalThis.fetch = globalThis.fetch, +): ApiVersionReader { + return { + async readVersion(signal) { + const response = await fetchImplementation(metadataPath, { + credentials: "same-origin", + headers: { accept: "application/json" }, + ...(signal ? { signal } : {}), + }); + if (!response.ok) { + throw new Error( + `API metadata request failed with ${String(response.status)}`, + ); + } + return versionFromMetadata(await response.json()); + }, + }; +} diff --git a/components/web-console/app/composition/api-version-composition.ts b/components/web-console/app/composition/api-version-composition.ts new file mode 100644 index 00000000..5fd5e123 --- /dev/null +++ b/components/web-console/app/composition/api-version-composition.ts @@ -0,0 +1,4 @@ +import { createApiVersionAdapter } from "../adapters/api/api-version"; + +/** Browser-wide reader for the API image version. */ +export const apiVersionReader = createApiVersionAdapter(); diff --git a/components/web-console/app/features/shell/use-api-version.ts b/components/web-console/app/features/shell/use-api-version.ts new file mode 100644 index 00000000..786cfb93 --- /dev/null +++ b/components/web-console/app/features/shell/use-api-version.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiVersionReader } from "../../composition/api-version-composition"; + +export const apiVersionQueryKey = ["api-version"] as const; + +/** Reads the API image version after the browser session is authenticated. */ +export function useApiVersion(enabled: boolean) { + return useQuery({ + enabled, + queryFn: ({ signal }) => apiVersionReader.readVersion(signal), + queryKey: apiVersionQueryKey, + staleTime: 300_000, + }); +} diff --git a/components/web-console/app/features/shell/user-menu.test.tsx b/components/web-console/app/features/shell/user-menu.test.tsx index 5967fd67..c84df4ae 100644 --- a/components/web-console/app/features/shell/user-menu.test.tsx +++ b/components/web-console/app/features/shell/user-menu.test.tsx @@ -8,7 +8,14 @@ import { beforeEach, expect, it, vi } from "vitest"; import { englishMessages } from "../../i18n/catalog"; import { UserMenu } from "./user-menu"; -const { getSessionMock } = vi.hoisted(() => ({ getSessionMock: vi.fn() })); +const { getSessionMock, readVersionMock } = vi.hoisted(() => ({ + getSessionMock: vi.fn(), + readVersionMock: vi.fn(), +})); + +vi.mock("../../composition/api-version-composition", () => ({ + apiVersionReader: { readVersion: readVersionMock }, +})); vi.mock("../../composition/session-composition", () => ({ sessionGateway: { getSession: getSessionMock }, @@ -41,10 +48,11 @@ function setBuildVersion(version: string) { beforeEach(() => { vi.clearAllMocks(); + readVersionMock.mockResolvedValue("v1.6.0-7654321"); document.querySelector('meta[name="hypershell-runtime-config"]')?.remove(); }); -it("shows the user name and a full sign-out link", async () => { +it("shows the user name, image versions, and a full sign-out link", async () => { const user = userEvent.setup(); getSessionMock.mockResolvedValue({ authenticated: true, @@ -59,24 +67,30 @@ it("shows the user name and a full sign-out link", async () => { await user.click(toggle); const menu = screen.getByRole("menu"); - const version = within(menu).getByRole("menuitem", { + const consoleVersion = within(menu).getByRole("menuitem", { name: "Console version v1.6.0-1234567", }); + const apiVersion = await within(menu).findByRole("menuitem", { + name: "API version v1.6.0-7654321", + }); const logout = within(menu).getByRole("menuitem", { name: "Log out" }); - expect((version as HTMLButtonElement).disabled).toBe(true); - expect(version.getAttribute("href")).toBeNull(); + expect((consoleVersion as HTMLButtonElement).disabled).toBe(true); + expect(consoleVersion.getAttribute("href")).toBeNull(); + expect((apiVersion as HTMLButtonElement).disabled).toBe(true); + expect(apiVersion.getAttribute("href")).toBeNull(); // Sign-out is a real navigation to the BFF endpoint, not a client route, so // the BFF can clear the session and perform RP-initiated Keycloak logout. expect(logout.getAttribute("href")).toBe("/auth/logout"); }); -it("shows an unknown version and keeps logout available", async () => { +it("shows unknown versions and keeps logout available", async () => { const user = userEvent.setup(); getSessionMock.mockResolvedValue({ authenticated: true, roles: ["hypershell-users"], user: { name: "Ada Lovelace" }, }); + readVersionMock.mockRejectedValue(new Error("API unavailable")); setBuildVersion("latest"); renderMenu(); @@ -90,9 +104,44 @@ it("shows an unknown version and keeps logout available", async () => { name: "Console version unknown", }), ).toBeTruthy(); + expect( + within(menu).getByRole("menuitem", { + name: "API version unknown", + }), + ).toBeTruthy(); expect(within(menu).getByRole("menuitem", { name: "Log out" })).toBeTruthy(); }); +it("keeps the build version that it reads when it mounts", async () => { + const user = userEvent.setup(); + getSessionMock.mockResolvedValue({ + authenticated: true, + roles: ["hypershell-users"], + user: { name: "Ada Lovelace" }, + }); + setBuildVersion("v1.6.0-1234567"); + + renderMenu(); + + const toggle = await screen.findByRole("button", { name: /Ada Lovelace/u }); + document + .querySelector('meta[name="hypershell-runtime-config"]') + ?.setAttribute( + "content", + JSON.stringify({ + build: { version: "v1.6.0-7654321" }, + tracing: { sampleRatio: 0 }, + }), + ); + await user.click(toggle); + + expect( + within(screen.getByRole("menu")).getByRole("menuitem", { + name: "Console version v1.6.0-1234567", + }), + ).toBeTruthy(); +}); + it("falls back to the preferred username, then email, then Account", async () => { getSessionMock.mockResolvedValue({ authenticated: true, @@ -116,5 +165,6 @@ it("renders nothing when unauthenticated", async () => { await vi.waitFor(() => { expect(getSessionMock).toHaveBeenCalled(); }); + expect(readVersionMock).not.toHaveBeenCalled(); expect(container.querySelector("button")).toBeNull(); }); diff --git a/components/web-console/app/features/shell/user-menu.tsx b/components/web-console/app/features/shell/user-menu.tsx index 84ed554e..f787fe80 100644 --- a/components/web-console/app/features/shell/user-menu.tsx +++ b/components/web-console/app/features/shell/user-menu.tsx @@ -10,21 +10,28 @@ import { FormattedMessage, useIntl } from "react-intl"; import { readBrowserRuntimeConfig } from "../../composition/browser-runtime-config"; import { messages } from "../../i18n/messages"; +import { useApiVersion } from "./use-api-version"; import { useSession } from "./use-session"; /** - * Masthead identity menu. Shows the authenticated user's display name and a - * single sign-out action that performs full RP-initiated logout by navigating - * to the BFF `/auth/logout` endpoint (a real navigation, not a client route). - * Renders nothing when unauthenticated or in no-auth mode. + * Masthead identity menu. Shows the user's display name, the console and API + * image versions, and a sign-out action. Sign-out uses the BFF `/auth/logout` + * endpoint so that the BFF can complete RP-initiated logout. The menu renders + * nothing when the user is not authenticated or when no-auth mode is active. */ export function UserMenu() { const intl = useIntl(); const [isOpen, setIsOpen] = useState(false); + const [configuredBuildVersion] = useState( + () => readBrowserRuntimeConfig().build.version, + ); const { data: session } = useSession(); - const buildVersion = - readBrowserRuntimeConfig().build.version ?? - intl.formatMessage(messages.unknownVersion); + const { data: apiBuildVersion } = useApiVersion( + session?.authenticated === true, + ); + const unknownVersion = intl.formatMessage(messages.unknownVersion); + const consoleBuildVersion = configuredBuildVersion ?? unknownVersion; + const displayedApiBuildVersion = apiBuildVersion ?? unknownVersion; if (!session?.authenticated) { return null; @@ -62,7 +69,13 @@ export function UserMenu() { + + + diff --git a/components/web-console/app/i18n/messages.ts b/components/web-console/app/i18n/messages.ts index db45e408..84e04753 100644 --- a/components/web-console/app/i18n/messages.ts +++ b/components/web-console/app/i18n/messages.ts @@ -6,6 +6,11 @@ export const messages = defineMessages({ defaultMessage: "Account", description: "Fallback label for the identity menu when no name is known.", }, + apiVersion: { + id: "app.apiVersion", + defaultMessage: "API version {version}", + description: "API server image version in the identity menu.", + }, breadcrumbLabel: { id: "app.breadcrumb.ariaLabel", defaultMessage: "Breadcrumb", diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 3a47a56f..e006d8d8 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -15,6 +15,10 @@ "defaultMessage": "Retry", "description": "Action that repeats a failed request." }, + "app.apiVersion": { + "defaultMessage": "API version {version}", + "description": "API server image version in the identity menu." + }, "app.breadcrumb.ariaLabel": { "defaultMessage": "Breadcrumb", "description": "Accessible label for the application breadcrumb navigation." diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 748668f5..41025de2 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -48,9 +48,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-09-02 (source-release manual approval update complete) +**Last analyzed**: 2026-09-02 (source-release image version display complete) **Spec corpus**: 44 spec files; the coverage table tracks 33 analyzed feature/spec groups -**Codebase commit**: working tree (source-release manual approval update complete) +**Codebase commit**: working tree (source-release image version display complete) ### Coverage Summary @@ -113,7 +113,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | REL-07 | Build version format | Present | - | `Makefile`, `scripts/build-version.sh`, `scripts/kind/`, `.tekton/` | REL-W2 | | REL-08 | Container metadata | Present | - | `components/{api-server,control-plane,web-console}/Dockerfile` | REL-W2 | | REL-09 | API service metadata | Present | - | `components/api-server/openapi/openapi.yaml`, `components/api-server/pkg/api/metadata_test.go` | REL-W3 | -| REL-10 | Web-console version display | Present | - | `components/web-console/bff/src/config.ts`, `components/web-console/app/features/shell/user-menu.tsx` | REL-W4 | +| REL-10 | Web-console version display | Present | - | `components/web-console/bff/src/config.ts`, `components/web-console/app/adapters/api/api-version.ts`, `components/web-console/app/features/shell/user-menu.tsx` | REL-W4 | | REL-11 | Registry and deployment scope | Present | - | `.tekton/`, `docs/releasing.md` | REL-W1 | | REL-12 | Verification and documentation | Present | - | `scripts/test_release_policy.py`, `scripts/test_build_version.py`, `scripts/check_image_build_policy.py`, `components/api-server/pkg/api/metadata_test.go`, `components/web-console/app/features/shell/user-menu.test.tsx`, `docs/releasing.md` | REL-W1..REL-W4 | @@ -122,7 +122,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI - REL-W1 adds the managed source-release control and policy checks. - REL-W2 adds deterministic image identity and final-release build selection. - REL-W3 makes the existing API metadata route report the image identity. -- REL-W4 adds the console identity to runtime configuration and the user menu. +- REL-W4 adds the console and API identities to the user menu. ### openshell-gateway-console.spec.md diff --git a/specs/platform/source-release.spec.md b/specs/platform/source-release.spec.md index f173d03e..7eafab0f 100644 --- a/specs/platform/source-release.spec.md +++ b/specs/platform/source-release.spec.md @@ -255,25 +255,32 @@ current paths and meanings. The web-console image SHALL give its build version to the BFF as runtime configuration. The BFF SHALL include only this non-secret value in the existing browser runtime-configuration allowlist. The authenticated user menu SHALL show -the localized text `Console version ` as non-action content. The -menu SHALL remain usable when the value is unavailable and SHALL show a -localized unknown value. - -The console SHALL show its own image build version. It SHALL NOT state or imply +the localized text `Console version ` as non-action content. It +SHALL get the API server build version from the existing metadata endpoint +through the same-origin BFF proxy. It SHALL show the localized text +`API version ` as separate non-action content. The menu SHALL +remain usable when either value is unavailable and SHALL show a localized +unknown value for that source. + +The two rows SHALL identify their source. The console SHALL NOT state or imply that the console and API server use the same revision. -#### Scenario: Show the console build version +#### Scenario: Show both image build versions - GIVEN an authenticated user uses console build `v1.6.0-1234567` +- AND the API server returns build version `v1.6.0-7654321` - WHEN the user opens the user menu - THEN the menu SHALL show `Console version v1.6.0-1234567` -- AND the version row SHALL not start navigation or another action +- AND the menu SHALL show `API version v1.6.0-7654321` +- AND neither version row SHALL start navigation or another action -#### Scenario: Build version is unavailable +#### Scenario: Build versions are unavailable - GIVEN browser runtime configuration has no valid build version +- AND the API metadata request is unavailable - WHEN an authenticated user opens the user menu -- THEN the menu SHALL show a localized unknown version +- THEN the menu SHALL show a localized unknown console version +- AND the menu SHALL show a localized unknown API version - AND the Log out action SHALL remain available ### Requirement REL-11: Registry and deployment scope From 0b09d02894e6466abee53befad575723436dba67 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 14:05:55 -0400 Subject: [PATCH 14/16] fix(release): mark modified local builds --- Makefile | 15 +++- components/api-server/Dockerfile | 11 ++- components/control-plane/Dockerfile | 8 +- components/web-console/Dockerfile | 8 +- .../browser-runtime-config.test.ts | 10 +++ .../app/composition/browser-runtime-config.ts | 2 +- components/web-console/bff/src/config.ts | 2 +- .../web-console/bff/test/config.test.ts | 9 +++ docs/releasing.md | 4 + scripts/build-version.sh | 30 ++++++- scripts/check_image_build_policy.py | 3 +- scripts/kind/build-images.sh | 16 ++++ scripts/kind/swap-component.sh | 19 ++++- scripts/test_build_version.py | 81 ++++++++++++++++++- scripts/test_check_image_build_policy.py | 21 ++++- skills/RECONCILE.md | 4 +- specs/platform/source-release.spec.md | 23 ++++-- 17 files changed, 235 insertions(+), 31 deletions(-) diff --git a/Makefile b/Makefile index 7af8dc8c..9e942f20 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ IMAGE_TAG?=latest vcs_ref:=$(shell git rev-parse HEAD 2>/dev/null || echo unknown) build_prefix:=dev build_version:=$(shell HYPERSHELL_VCS_REF=$(vcs_ref) scripts/build-version.sh local 2>/dev/null || echo dev-unknown) +build_suffix:=$(if $(filter %-modified,$(build_version)),-modified,) build_time:=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') # Computed baseline references (registry images used in Kind manifests) @@ -157,14 +158,18 @@ install-js: verify-pnpm .PHONY: build-api-server build-api-server: $(CONTAINER_ENGINE) build -t $(api_server_local) \ - --build-arg BUILD_PREFIX=$(build_prefix) --build-arg VCS_REF=$(vcs_ref) \ + --build-arg BUILD_PREFIX=$(build_prefix) \ + --build-arg BUILD_SUFFIX=$(build_suffix) \ + --build-arg VCS_REF=$(vcs_ref) \ --build-arg BUILD_TIME="$(build_time)" \ -f components/api-server/Dockerfile . .PHONY: build-controller build-controller: $(CONTAINER_ENGINE) build -t $(control_plane_local) \ - --build-arg BUILD_PREFIX=$(build_prefix) --build-arg VCS_REF=$(vcs_ref) \ + --build-arg BUILD_PREFIX=$(build_prefix) \ + --build-arg BUILD_SUFFIX=$(build_suffix) \ + --build-arg VCS_REF=$(vcs_ref) \ -f components/control-plane/Dockerfile . .PHONY: build-cli @@ -174,7 +179,9 @@ build-cli: .PHONY: build-web-console build-web-console: $(CONTAINER_ENGINE) build -t $(web_console_local) \ - --build-arg BUILD_PREFIX=$(build_prefix) --build-arg VCS_REF=$(vcs_ref) \ + --build-arg BUILD_PREFIX=$(build_prefix) \ + --build-arg BUILD_SUFFIX=$(build_suffix) \ + --build-arg VCS_REF=$(vcs_ref) \ -f components/web-console/Dockerfile . # ============================================================================ @@ -320,7 +327,7 @@ export IMAGE_REGISTRY IMAGE_TAG KIND_CONFIG export api_server_ref control_plane_ref web_console_ref export API_SERVER_IMAGE CONTROL_PLANE_IMAGE WEB_CONSOLE_IMAGE export api_server_local control_plane_local web_console_local -export vcs_ref build_prefix build_version build_time +export vcs_ref build_prefix build_suffix build_version build_time export API_HOSTNAME CONSOLE_HOSTNAME HEALTH_HOSTNAME KEYCLOAK_HOSTNAME KEYCLOAK_OIDC_ISSUER export KIND_DNS_PORT diff --git a/components/api-server/Dockerfile b/components/api-server/Dockerfile index 88730949..10bc2575 100644 --- a/components/api-server/Dockerfile +++ b/components/api-server/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 ARG BUILD_PREFIX=dev +ARG BUILD_SUFFIX= ARG VCS_REF=unknown FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder @@ -15,11 +16,14 @@ COPY components/api-server/plugins/ plugins/ COPY components/api-server/openapi/ openapi/ ARG BUILD_PREFIX +ARG BUILD_SUFFIX ARG VCS_REF ARG BUILD_TIME=unknown +# Remove the final 33 characters from the 40-character VCS_REF. +# This keeps the first seven characters. RUN set -eu; \ short_ref="${VCS_REF%?????????????????????????????????}"; \ - build_version="${BUILD_PREFIX}-${short_ref}"; \ + build_version="${BUILD_PREFIX}-${short_ref}${BUILD_SUFFIX}"; \ build_time="${BUILD_TIME}"; \ if [ "${build_time}" = "unknown" ]; then build_time="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"; fi; \ CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w \ @@ -30,11 +34,12 @@ RUN set -eu; \ FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 ARG BUILD_PREFIX +ARG BUILD_SUFFIX ARG VCS_REF COPY --from=builder /workspace/hypershell /usr/local/bin/ -ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ +ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ HYPERSHELL_BUILD_REVISION="${VCS_REF}" EXPOSE 8000 @@ -45,5 +50,5 @@ CMD ["serve"] LABEL org.opencontainers.image.title="HyperShell API Server" \ org.opencontainers.image.description="REST + gRPC API server for HyperShell fleet management" \ - org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ org.opencontainers.image.revision="${VCS_REF}" diff --git a/components/control-plane/Dockerfile b/components/control-plane/Dockerfile index 5d35a8ac..49491012 100644 --- a/components/control-plane/Dockerfile +++ b/components/control-plane/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 ARG BUILD_PREFIX=dev +ARG BUILD_SUFFIX= ARG VCS_REF=unknown FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder @@ -24,17 +25,20 @@ RUN cd components/control-plane && CGO_ENABLED=0 go build -mod=mod -ldflags="-s FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 ARG BUILD_PREFIX +ARG BUILD_SUFFIX ARG VCS_REF COPY --from=builder /workspace/hypershell-controller /usr/local/bin/ COPY components/control-plane/manifests/ /manifests/ -ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ +# Remove the final 33 characters from the 40-character VCS_REF. +# This keeps the first seven characters. +ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ HYPERSHELL_BUILD_REVISION="${VCS_REF}" ENTRYPOINT ["/usr/local/bin/hypershell-controller"] LABEL org.opencontainers.image.title="HyperShell Controller" \ org.opencontainers.image.description="Control plane that reconciles HyperShell resources via gRPC watch streams" \ - org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ org.opencontainers.image.revision="${VCS_REF}" diff --git a/components/web-console/Dockerfile b/components/web-console/Dockerfile index fadbdf14..eef95232 100644 --- a/components/web-console/Dockerfile +++ b/components/web-console/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 ARG BUILD_PREFIX=dev +ARG BUILD_SUFFIX= ARG VCS_REF=unknown FROM registry.access.redhat.com/hi/nodejs:24.18.1-builder@sha256:e2a04f7443db3d27751ed6e9d74ee5c3a02217dbf6c8769e468ef3b1b2a3e6fa AS build @@ -34,16 +35,19 @@ RUN pnpm run build:web \ FROM registry.access.redhat.com/hi/nodejs:24.18.1@sha256:07b0f6cf5dabef30b1efa030eea020095b1ea227c088dbcbe0ebd35bb24b7ad9 ARG BUILD_PREFIX +ARG BUILD_SUFFIX ARG VCS_REF +# Remove the final 33 characters from the 40-character VCS_REF. +# This keeps the first seven characters. LABEL org.opencontainers.image.title="HyperShell web console" \ org.opencontainers.image.description="HyperShell web console BFF and static application" \ - org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + org.opencontainers.image.version="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ org.opencontainers.image.revision="${VCS_REF}" ENV HOST=0.0.0.0 \ HYPERSHELL_BUILD_REVISION="${VCS_REF}" \ - HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}" \ + HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ HYPERSHELL_API_ORIGIN=http://127.0.0.1:8000 \ HYPERSHELL_API_TIMEOUT_MS=30000 \ NODE_ENV=production \ diff --git a/components/web-console/app/composition/browser-runtime-config.test.ts b/components/web-console/app/composition/browser-runtime-config.test.ts index ccab0d12..0a681cfe 100644 --- a/components/web-console/app/composition/browser-runtime-config.test.ts +++ b/components/web-console/app/composition/browser-runtime-config.test.ts @@ -71,4 +71,14 @@ describe("readBrowserRuntimeConfig", () => { tracing: { sampleRatio: 0.5 }, }); }); + + it("reads a modified local image build version", () => { + const config = readBrowserRuntimeConfig( + documentWithMeta( + '{"build":{"version":"dev-abcdef0-modified"},"tracing":{"sampleRatio":0}}', + ), + ); + + expect(config.build.version).toBe("dev-abcdef0-modified"); + }); }); diff --git a/components/web-console/app/composition/browser-runtime-config.ts b/components/web-console/app/composition/browser-runtime-config.ts index e815ecd5..a2f37a69 100644 --- a/components/web-console/app/composition/browser-runtime-config.ts +++ b/components/web-console/app/composition/browser-runtime-config.ts @@ -22,7 +22,7 @@ const disabledRuntimeConfig: BrowserRuntimeConfig = { }; const buildVersionPattern = - /^(?:dev|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))-[0-9a-f]{7}$/u; + /^(?:dev-[0-9a-f]{7}(?:-modified)?|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-[0-9a-f]{7})$/u; function isBuildVersion(value: unknown): value is string { return typeof value === "string" && buildVersionPattern.test(value); diff --git a/components/web-console/bff/src/config.ts b/components/web-console/bff/src/config.ts index 3ca29f3e..7dfd25d2 100644 --- a/components/web-console/bff/src/config.ts +++ b/components/web-console/bff/src/config.ts @@ -36,7 +36,7 @@ const configSchema = z.object({ .string() .trim() .regex( - /^(?:dev|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))-[0-9a-f]{7}$/u, + /^(?:dev-[0-9a-f]{7}(?:-modified)?|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-[0-9a-f]{7})$/u, "must contain a supported image build version", ) .optional(), diff --git a/components/web-console/bff/test/config.test.ts b/components/web-console/bff/test/config.test.ts index 5180184b..d19070dd 100644 --- a/components/web-console/bff/test/config.test.ts +++ b/components/web-console/bff/test/config.test.ts @@ -42,6 +42,15 @@ describe("loadConfig", () => { ); }); + it("accepts a modified local image build version", () => { + const config = loadConfig({ + HYPERSHELL_BUILD_VERSION: "dev-abcdef0-modified", + STATIC_ROOT: "./public", + }); + + expect(config.buildVersion).toBe("dev-abcdef0-modified"); + }); + it("leaves tracing disabled when no collector endpoint is set", () => { const config = loadConfig({ STATIC_ROOT: "./public" }); diff --git a/docs/releasing.md b/docs/releasing.md index a0c3e1d3..4baee68c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -87,3 +87,7 @@ console once. The build keeps the existing full-SHA registry tags. Argo image bump continues to select deployment images. This process does not promote images, create semantic registry aliases, or change Argo configuration. + +Supported local build commands use `dev-` for a clean Git work tree. +They use `dev--modified` when the work tree contains staged, +unstaged, or untracked changes. CI versions never use the `-modified` suffix. diff --git a/scripts/build-version.sh b/scripts/build-version.sh index f6aa6220..c09d6f8c 100755 --- a/scripts/build-version.sh +++ b/scripts/build-version.sh @@ -4,7 +4,8 @@ set -euo pipefail mode="${1:-local}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repository_root="$(cd "${script_dir}/.." && pwd)" -revision="${HYPERSHELL_VCS_REF:-$(git -C "${repository_root}" rev-parse HEAD 2>/dev/null || true)}" +git_work_tree="${HYPERSHELL_GIT_WORK_TREE:-${repository_root}}" +revision="${HYPERSHELL_VCS_REF:-$(git -C "${git_work_tree}" rev-parse HEAD 2>/dev/null || true)}" revision="${revision,,}" if [[ ! "${revision}" =~ ^[0-9a-f]{40}$ ]]; then @@ -15,6 +16,30 @@ fi case "${mode}" in local) prefix=dev + dirty_state="${HYPERSHELL_GIT_DIRTY:-}" + if [[ -z "${dirty_state}" ]]; then + if ! work_tree_status="$(git -C "${git_work_tree}" status --porcelain --untracked-files=normal 2>/dev/null)"; then + echo "build version error: the Git work tree could not be inspected" >&2 + exit 1 + fi + if [[ -n "${work_tree_status}" ]]; then + dirty_state=1 + else + dirty_state=0 + fi + fi + case "${dirty_state}" in + 0) + suffix="" + ;; + 1) + suffix=-modified + ;; + *) + echo "build version error: HYPERSHELL_GIT_DIRTY must be 0 or 1" >&2 + exit 1 + ;; + esac ;; ci) version_file="${HYPERSHELL_VERSION_FILE:-${repository_root}/VERSION}" @@ -24,6 +49,7 @@ case "${mode}" in exit 1 fi prefix="v${version}" + suffix="" ;; *) echo "build version error: mode must be local or ci" >&2 @@ -31,4 +57,4 @@ case "${mode}" in ;; esac -printf '%s-%s\n' "${prefix}" "${revision:0:7}" +printf '%s-%s%s\n' "${prefix}" "${revision:0:7}" "${suffix}" diff --git a/scripts/check_image_build_policy.py b/scripts/check_image_build_policy.py index dd1276bb..1324eb12 100644 --- a/scripts/check_image_build_policy.py +++ b/scripts/check_image_build_policy.py @@ -30,6 +30,7 @@ _BUILD_VERSION_PATTERN = re.compile( r"(?:\$BUILD_PREFIX|\$\{BUILD_PREFIX\})-" r"\$\{VCS_REF%(?P\?+)\}" + r"\$\{BUILD_SUFFIX\}" ) @@ -77,7 +78,7 @@ def image_metadata_errors(relative_path: str, dockerfile: str) -> list[str]: match = _BUILD_VERSION_PATTERN.fullmatch(values[0]) if match is None: errors.append( - f"{relative_path} {label} must combine BUILD_PREFIX and a shortened VCS_REF" + f"{relative_path} {label} must combine BUILD_PREFIX, a shortened VCS_REF, and BUILD_SUFFIX" ) continue if len(match.group("trim")) != SHORT_REVISION_TRIM: diff --git a/scripts/kind/build-images.sh b/scripts/kind/build-images.sh index 2a7768ec..3a8c9db3 100755 --- a/scripts/kind/build-images.sh +++ b/scripts/kind/build-images.sh @@ -35,11 +35,25 @@ fi build_vcs_ref="$(git -C "${BUILD_DIR}" rev-parse HEAD)" build_prefix=dev +local_build_version="$( + HYPERSHELL_GIT_WORK_TREE="${BUILD_DIR}" \ + HYPERSHELL_VCS_REF="${build_vcs_ref}" \ + "${REPO_ROOT}/scripts/build-version.sh" local +)" +case "${local_build_version}" in + "dev-${build_vcs_ref:0:7}") build_suffix="" ;; + "dev-${build_vcs_ref:0:7}-modified") build_suffix=-modified ;; + *) + error "Unexpected local build version: ${local_build_version}" + exit 1 + ;; +esac info "Building API server..." ${CONTAINER_ENGINE} build -t "${api_server_local}" \ -f "${BUILD_DIR}/components/api-server/Dockerfile" \ --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg BUILD_SUFFIX="${build_suffix}" \ --build-arg VCS_REF="${build_vcs_ref}" \ --build-arg BUILD_TIME="${build_time}" \ "${BUILD_DIR}" @@ -48,6 +62,7 @@ info "Building control plane..." ${CONTAINER_ENGINE} build -t "${control_plane_local}" \ -f "${BUILD_DIR}/components/control-plane/Dockerfile" \ --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg BUILD_SUFFIX="${build_suffix}" \ --build-arg VCS_REF="${build_vcs_ref}" \ "${BUILD_DIR}" @@ -55,6 +70,7 @@ info "Building web console..." ${CONTAINER_ENGINE} build -t "${web_console_local}" \ -f "${BUILD_DIR}/components/web-console/Dockerfile" \ --build-arg BUILD_PREFIX="${build_prefix}" \ + --build-arg BUILD_SUFFIX="${build_suffix}" \ --build-arg VCS_REF="${build_vcs_ref}" \ "${BUILD_DIR}" diff --git a/scripts/kind/swap-component.sh b/scripts/kind/swap-component.sh index cb8fb814..6d8c2d69 100755 --- a/scripts/kind/swap-component.sh +++ b/scripts/kind/swap-component.sh @@ -10,7 +10,24 @@ require_cluster ACTION="${1:-}" COMPONENT="${2:-}" VCS_REF="$(git -C "${REPO_ROOT}" rev-parse HEAD)" -BUILD_ARGS=(--build-arg "BUILD_PREFIX=dev" --build-arg "VCS_REF=${VCS_REF}") +LOCAL_BUILD_VERSION="$( + HYPERSHELL_GIT_WORK_TREE="${REPO_ROOT}" \ + HYPERSHELL_VCS_REF="${VCS_REF}" \ + "${REPO_ROOT}/scripts/build-version.sh" local +)" +case "${LOCAL_BUILD_VERSION}" in + "dev-${VCS_REF:0:7}") BUILD_SUFFIX="" ;; + "dev-${VCS_REF:0:7}-modified") BUILD_SUFFIX=-modified ;; + *) + error "Unexpected local build version: ${LOCAL_BUILD_VERSION}" + exit 1 + ;; +esac +BUILD_ARGS=( + --build-arg "BUILD_PREFIX=dev" + --build-arg "BUILD_SUFFIX=${BUILD_SUFFIX}" + --build-arg "VCS_REF=${VCS_REF}" +) if [[ -z "${ACTION}" ]] || [[ -z "${COMPONENT}" ]]; then error "Usage: swap-component.sh up|down " diff --git a/scripts/test_build_version.py b/scripts/test_build_version.py index b1d3ab82..39df8727 100644 --- a/scripts/test_build_version.py +++ b/scripts/test_build_version.py @@ -12,6 +12,8 @@ class BuildVersionTest(unittest.TestCase): def run_script(self, mode: str, **environment: str): command_environment = os.environ.copy() + command_environment.pop("HYPERSHELL_GIT_DIRTY", None) + command_environment.pop("HYPERSHELL_GIT_WORK_TREE", None) command_environment.update(environment) return subprocess.run( ("bash", str(SCRIPT_PATH), mode), @@ -23,16 +25,72 @@ def run_script(self, mode: str, **environment: str): ) def test_local_version_uses_dev_prefix(self): - result = self.run_script("local", HYPERSHELL_VCS_REF=REVISION) + result = self.run_script( + "local", HYPERSHELL_GIT_DIRTY="0", HYPERSHELL_VCS_REF=REVISION + ) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual("dev-abcdef0\n", result.stdout) + def test_local_version_marks_all_work_tree_change_types(self): + for change_type in ("staged", "unstaged", "untracked"): + with self.subTest(change_type=change_type): + with tempfile.TemporaryDirectory() as temporary_directory: + work_tree = Path(temporary_directory) + self.run_git(work_tree, "init", "--quiet") + tracked_file = work_tree / "tracked.txt" + tracked_file.write_text("original\n", encoding="utf-8") + self.run_git(work_tree, "add", "tracked.txt") + self.run_git( + work_tree, + "-c", + "user.name=Build Test", + "-c", + "user.email=build-test@example.test", + "commit", + "--quiet", + "-m", + "initial", + ) + revision = self.run_git( + work_tree, "rev-parse", "HEAD" + ).stdout.strip() + clean_result = self.run_script( + "local", + HYPERSHELL_GIT_WORK_TREE=str(work_tree), + HYPERSHELL_VCS_REF=revision, + ) + self.assertEqual( + 0, clean_result.returncode, clean_result.stderr + ) + self.assertEqual( + f"dev-{revision[:7]}\n", clean_result.stdout + ) + + if change_type == "untracked": + (work_tree / "untracked.txt").write_text( + "new\n", encoding="utf-8" + ) + else: + tracked_file.write_text("changed\n", encoding="utf-8") + if change_type == "staged": + self.run_git(work_tree, "add", "tracked.txt") + + result = self.run_script( + "local", + HYPERSHELL_GIT_WORK_TREE=str(work_tree), + HYPERSHELL_VCS_REF=revision, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(f"dev-{revision[:7]}-modified\n", result.stdout) + def test_ci_version_uses_version_file(self): with tempfile.TemporaryDirectory() as temporary_directory: version_file = Path(temporary_directory) / "VERSION" version_file.write_text("1.6.0\n", encoding="utf-8") result = self.run_script( "ci", + HYPERSHELL_GIT_DIRTY="1", HYPERSHELL_VCS_REF=REVISION, HYPERSHELL_VERSION_FILE=str(version_file), ) @@ -40,10 +98,19 @@ def test_ci_version_uses_version_file(self): self.assertEqual("v1.6.0-abcdef0\n", result.stdout) def test_rejects_an_abbreviated_revision(self): - result = self.run_script("local", HYPERSHELL_VCS_REF="abcdef0") + result = self.run_script( + "local", HYPERSHELL_GIT_DIRTY="0", HYPERSHELL_VCS_REF="abcdef0" + ) self.assertNotEqual(0, result.returncode) self.assertIn("full 40-character Git SHA", result.stderr) + def test_rejects_an_invalid_dirty_state(self): + result = self.run_script( + "local", HYPERSHELL_GIT_DIRTY="yes", HYPERSHELL_VCS_REF=REVISION + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("HYPERSHELL_GIT_DIRTY must be 0 or 1", result.stderr) + def test_rejects_an_invalid_ci_version(self): with tempfile.TemporaryDirectory() as temporary_directory: version_file = Path(temporary_directory) / "VERSION" @@ -56,6 +123,16 @@ def test_rejects_an_invalid_ci_version(self): self.assertNotEqual(0, result.returncode) self.assertIn("stable semantic version", result.stderr) + @staticmethod + def run_git(work_tree: Path, *arguments: str): + return subprocess.run( + ("git", "-C", str(work_tree), *arguments), + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_check_image_build_policy.py b/scripts/test_check_image_build_policy.py index 7b6ccad9..499fd7d1 100644 --- a/scripts/test_check_image_build_policy.py +++ b/scripts/test_check_image_build_policy.py @@ -15,9 +15,9 @@ def test_accepts_reordered_assignments_and_different_quotes(self): dockerfile = f""" # syntax=docker/dockerfile:1 ENV HYPERSHELL_BUILD_REVISION=$VCS_REF \\ - HYPERSHELL_BUILD_VERSION='${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}' + HYPERSHELL_BUILD_VERSION='${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}' LABEL org.opencontainers.image.revision='${{VCS_REF}}' \\ - org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" + org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}" """ self.assertEqual([], POLICY.image_metadata_errors("Dockerfile", dockerfile)) @@ -34,6 +34,21 @@ def test_reports_missing_assignments(self): def test_reports_an_invalid_short_revision(self): trim = "?" * 32 dockerfile = f""" + ENV HYPERSHELL_BUILD_VERSION="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}" \\ + HYPERSHELL_BUILD_REVISION="${{VCS_REF}}" + LABEL org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}${{BUILD_SUFFIX}}" \\ + org.opencontainers.image.revision="${{VCS_REF}}" + """ + + errors = POLICY.image_metadata_errors("Dockerfile", dockerfile) + + self.assertEqual(2, len(errors)) + for error in errors: + self.assertIn("must shorten the revision to seven characters", error) + + def test_requires_the_local_build_suffix(self): + trim = "?" * 33 + dockerfile = f""" ENV HYPERSHELL_BUILD_VERSION="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" \\ HYPERSHELL_BUILD_REVISION="${{VCS_REF}}" LABEL org.opencontainers.image.version="${{BUILD_PREFIX}}-${{VCS_REF%{trim}}}" \\ @@ -44,7 +59,7 @@ def test_reports_an_invalid_short_revision(self): self.assertEqual(2, len(errors)) for error in errors: - self.assertIn("must shorten the revision to seven characters", error) + self.assertIn("and BUILD_SUFFIX", error) if __name__ == "__main__": diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 41025de2..aa0b0066 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -48,9 +48,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-09-02 (source-release image version display complete) +**Last analyzed**: 2026-09-02 (source-release review follow-up complete) **Spec corpus**: 44 spec files; the coverage table tracks 33 analyzed feature/spec groups -**Codebase commit**: working tree (source-release image version display complete) +**Codebase commit**: working tree (source-release review follow-up complete) ### Coverage Summary diff --git a/specs/platform/source-release.spec.md b/specs/platform/source-release.spec.md index 7eafab0f..ae83dcae 100644 --- a/specs/platform/source-release.spec.md +++ b/specs/platform/source-release.spec.md @@ -191,12 +191,13 @@ these three builds. This rule SHALL apply only to a `main` push. ### Requirement REL-07: Build version format -A supported local image build SHALL use build version -`dev-`. A CI image build SHALL use build version -`v-`. CI SHALL read the release version from -the checked-in release files. It SHALL NOT query for a live Git tag during the -image build. A build SHALL use the triggering full revision and SHALL derive the -short revision from it. +A supported local image build from a clean Git work tree SHALL use build +version `dev-`. A local build with staged, unstaged, or +untracked changes SHALL append `-modified`. A CI image build SHALL use build +version `v-`. CI SHALL read the release version +from the checked-in release files. It SHALL NOT query for a live Git tag during +the image build. A build SHALL use the triggering full revision and SHALL +derive the short revision from it. A normal CI build after a release SHALL continue to use that release version until the next Release PR merges. Thus, different component builds MAY have @@ -209,6 +210,13 @@ by a Release PR merge SHALL have the same build version. - WHEN a developer uses a supported local image build command - THEN the image build version SHALL be `dev-abcdef0` +#### Scenario: Modified local image build + +- GIVEN local `HEAD` is revision `abcdef0123456789abcdef0123456789abcdef01` +- AND the local Git work tree contains an uncommitted change +- WHEN a developer uses a supported local image build command +- THEN the image build version SHALL be `dev-abcdef0-modified` + #### Scenario: CI image build - GIVEN `VERSION` contains `1.6.0` @@ -222,7 +230,8 @@ Each API server, control-plane, and web-console image SHALL set `org.opencontainers.image.version` to its build version. It SHALL set `org.opencontainers.image.revision` to its full revision. The image SHALL also contain the build version as a runtime environment value. Static placeholder -versions SHALL NOT remain in these Containerfiles. +versions SHALL NOT remain in these Containerfiles. The Containerfiles SHALL +explain how they shorten the full revision. #### Scenario: Inspect a CI image From acecfaee0d74257fd578d8596f5e033452fef7a5 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 15:00:56 -0400 Subject: [PATCH 15/16] fix(release): validate image build identity --- .github/workflows/lint.yml | 3 ++ components/api-server/Dockerfile | 16 ++++++++++- components/api-server/Makefile | 10 ++++++- .../pkg/api/linked_metadata_test.go | 28 +++++++++++++++++++ components/control-plane/Dockerfile | 17 ++++++++++- components/web-console/Dockerfile | 17 ++++++++++- docs/releasing.md | 2 ++ skills/RECONCILE.md | 4 +-- specs/platform/source-release.spec.md | 20 +++++++++++-- 9 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 components/api-server/pkg/api/linked_metadata_test.go diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5e6f073f..6117ebc3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -72,6 +72,9 @@ jobs: with: go-version-file: hypershell/components/api-server/go.mod cache-dependency-path: hypershell/components/api-server/go.sum + - name: Verify linked build metadata + working-directory: hypershell/components/api-server + run: make check-build-metadata - name: Check formatting working-directory: hypershell/components/api-server run: | diff --git a/components/api-server/Dockerfile b/components/api-server/Dockerfile index 10bc2575..463b828b 100644 --- a/components/api-server/Dockerfile +++ b/components/api-server/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 ARG BUILD_PREFIX=dev ARG BUILD_SUFFIX= -ARG VCS_REF=unknown +ARG VCS_REF FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder @@ -19,6 +19,19 @@ ARG BUILD_PREFIX ARG BUILD_SUFFIX ARG VCS_REF ARG BUILD_TIME=unknown +# Validate the full revision before any 40-to-7-character shortening. +RUN set -eu; \ + if [ "${#VCS_REF}" -ne 40 ]; then \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + fi; \ + case "${VCS_REF}" in \ + *[!0-9a-f]*) \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + ;; \ + esac + # Remove the final 33 characters from the 40-character VCS_REF. # This keeps the first seven characters. RUN set -eu; \ @@ -39,6 +52,7 @@ ARG VCS_REF COPY --from=builder /workspace/hypershell /usr/local/bin/ +# The builder validates VCS_REF before this expression keeps seven characters. ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ HYPERSHELL_BUILD_REVISION="${VCS_REF}" diff --git a/components/api-server/Makefile b/components/api-server/Makefile index 60b2f780..3dca87b1 100644 --- a/components/api-server/Makefile +++ b/components/api-server/Makefile @@ -26,6 +26,14 @@ binary: install: go install -ldflags="$(ldflags)" ./cmd/hypershell +.PHONY: check-build-metadata +check-build-metadata: + HYPERSHELL_EXPECTED_BUILD_VERSION="$(build_version)" \ + HYPERSHELL_EXPECTED_BUILD_TIME="$(build_time)" \ + go test -tags=linked_metadata -count=1 \ + -run '^TestLinkedBuildIdentity$$' \ + -ldflags="$(ldflags)" ./pkg/api + .PHONY: run run: binary ./$(BINARY_NAME) migrate @@ -37,7 +45,7 @@ run-no-auth: binary ./$(BINARY_NAME) serve --enable-authz=false --enable-jwt=false .PHONY: test -test: install +test: install check-build-metadata go test -v ./... .PHONY: test-integration diff --git a/components/api-server/pkg/api/linked_metadata_test.go b/components/api-server/pkg/api/linked_metadata_test.go new file mode 100644 index 00000000..2f0b77ae --- /dev/null +++ b/components/api-server/pkg/api/linked_metadata_test.go @@ -0,0 +1,28 @@ +//go:build linked_metadata + +package api + +import ( + "os" + "testing" + + trexapi "github.com/openshift-online/rh-trex-ai/pkg/api" +) + +func TestLinkedBuildIdentity(t *testing.T) { + expectedVersion := os.Getenv("HYPERSHELL_EXPECTED_BUILD_VERSION") + if expectedVersion == "" { + t.Fatal("HYPERSHELL_EXPECTED_BUILD_VERSION is not set") + } + expectedBuildTime := os.Getenv("HYPERSHELL_EXPECTED_BUILD_TIME") + if expectedBuildTime == "" { + t.Fatal("HYPERSHELL_EXPECTED_BUILD_TIME is not set") + } + + if trexapi.Version != expectedVersion { + t.Errorf("linked version = %q, want %q", trexapi.Version, expectedVersion) + } + if trexapi.BuildTime != expectedBuildTime { + t.Errorf("linked build time = %q, want %q", trexapi.BuildTime, expectedBuildTime) + } +} diff --git a/components/control-plane/Dockerfile b/components/control-plane/Dockerfile index 49491012..d5c7a011 100644 --- a/components/control-plane/Dockerfile +++ b/components/control-plane/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 ARG BUILD_PREFIX=dev ARG BUILD_SUFFIX= -ARG VCS_REF=unknown +ARG VCS_REF FROM registry.access.redhat.com/hi/go:1.26.7@sha256:4c7c064cc73698c13384d1445ab93595f3b9309dd89ed45b9992e67284e3ed82 AS builder @@ -20,6 +20,20 @@ COPY components/api-server/proto/ components/api-server/proto/ COPY components/control-plane/cmd/ components/control-plane/cmd/ COPY components/control-plane/internal/ components/control-plane/internal/ +ARG VCS_REF +# Validate the full revision before any 40-to-7-character shortening. +RUN set -eu; \ + if [ "${#VCS_REF}" -ne 40 ]; then \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + fi; \ + case "${VCS_REF}" in \ + *[!0-9a-f]*) \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + ;; \ + esac + RUN cd components/control-plane && CGO_ENABLED=0 go build -mod=mod -ldflags="-s -w" -o /workspace/hypershell-controller ./cmd/hypershell-controller FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 @@ -31,6 +45,7 @@ ARG VCS_REF COPY --from=builder /workspace/hypershell-controller /usr/local/bin/ COPY components/control-plane/manifests/ /manifests/ +# The builder validates VCS_REF before this expression removes 33 characters. # Remove the final 33 characters from the 40-character VCS_REF. # This keeps the first seven characters. ENV HYPERSHELL_BUILD_VERSION="${BUILD_PREFIX}-${VCS_REF%?????????????????????????????????}${BUILD_SUFFIX}" \ diff --git a/components/web-console/Dockerfile b/components/web-console/Dockerfile index eef95232..2bd48a7d 100644 --- a/components/web-console/Dockerfile +++ b/components/web-console/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 ARG BUILD_PREFIX=dev ARG BUILD_SUFFIX= -ARG VCS_REF=unknown +ARG VCS_REF FROM registry.access.redhat.com/hi/nodejs:24.18.1-builder@sha256:e2a04f7443db3d27751ed6e9d74ee5c3a02217dbf6c8769e468ef3b1b2a3e6fa AS build @@ -28,6 +28,20 @@ COPY --chown=${CONTAINER_DEFAULT_USER} packages/gateway-management-ui packages/g COPY --chown=${CONTAINER_DEFAULT_USER} components/web-console components/web-console COPY --chown=${CONTAINER_DEFAULT_USER} images/brand images/brand +ARG VCS_REF +# Validate the full revision before any 40-to-7-character shortening. +RUN set -eu; \ + if [ "${#VCS_REF}" -ne 40 ]; then \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + fi; \ + case "${VCS_REF}" in \ + *[!0-9a-f]*) \ + echo "VCS_REF must be a full 40-character lowercase hexadecimal Git SHA" >&2; \ + exit 1; \ + ;; \ + esac + RUN pnpm run build:web \ && pnpm --filter @openshift-online/hypershell-web-console-bff deploy --prod /tmp/web-console \ && cp -R components/web-console/build/client /tmp/web-console/public @@ -38,6 +52,7 @@ ARG BUILD_PREFIX ARG BUILD_SUFFIX ARG VCS_REF +# The build stage validates VCS_REF before this expression removes 33 characters. # Remove the final 33 characters from the 40-character VCS_REF. # This keeps the first seven characters. LABEL org.opencontainers.image.title="HyperShell web console" \ diff --git a/docs/releasing.md b/docs/releasing.md index 4baee68c..a6db6e33 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -91,3 +91,5 @@ semantic registry aliases, or change Argo configuration. Supported local build commands use `dev-` for a clean Git work tree. They use `dev--modified` when the work tree contains staged, unstaged, or untracked changes. CI versions never use the `-modified` suffix. +Each image build rejects a revision that is not a full 40-character lowercase +hexadecimal Git SHA. diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index aa0b0066..b2a93323 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -48,9 +48,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-09-02 (source-release review follow-up complete) +**Last analyzed**: 2026-09-02 (source-release build safeguards complete) **Spec corpus**: 44 spec files; the coverage table tracks 33 analyzed feature/spec groups -**Codebase commit**: working tree (source-release review follow-up complete) +**Codebase commit**: working tree (source-release build safeguards complete) ### Coverage Summary diff --git a/specs/platform/source-release.spec.md b/specs/platform/source-release.spec.md index ae83dcae..8b1668cd 100644 --- a/specs/platform/source-release.spec.md +++ b/specs/platform/source-release.spec.md @@ -197,7 +197,8 @@ untracked changes SHALL append `-modified`. A CI image build SHALL use build version `v-`. CI SHALL read the release version from the checked-in release files. It SHALL NOT query for a live Git tag during the image build. A build SHALL use the triggering full revision and SHALL -derive the short revision from it. +derive the short revision from it. Each image build SHALL reject `VCS_REF` +unless it is a 40-character lowercase hexadecimal Git SHA. A normal CI build after a release SHALL continue to use that release version until the next Release PR merges. Thus, different component builds MAY have @@ -224,6 +225,13 @@ by a Release PR merge SHALL have the same build version. - WHEN CI builds a component image - THEN its build version SHALL be `v1.6.0-1234567` +#### Scenario: Reject an invalid image revision + +- GIVEN `VCS_REF` is `abcdef0` +- WHEN a component image build starts +- THEN the build SHALL stop before it shortens the revision +- AND the build SHALL report that a full 40-character Git SHA is required + ### Requirement REL-08: Container metadata Each API server, control-plane, and web-console image SHALL set @@ -248,7 +256,9 @@ server build version and build time. The `version` value SHALL equal the API server image build version. The endpoint SHALL be available without user authentication and SHALL NOT query the database. The OpenAPI contract SHALL describe its response. Existing liveness and readiness probes SHALL keep their -current paths and meanings. +current paths and meanings. CI SHALL link a focused test with the production +linker flags. The test SHALL fail when the linked version or build time does not +equal its expected value. #### Scenario: Read API build identity @@ -259,6 +269,12 @@ current paths and meanings. - AND the response `version` SHALL be `v1.6.0-1234567` - AND the response SHALL include `build_time` +#### Scenario: Detect an invalid linker target + +- GIVEN a linker target does not set the framework build identity +- WHEN CI runs the linked build-metadata test +- THEN the test SHALL fail + ### Requirement REL-10: Web-console version display The web-console image SHALL give its build version to the BFF as runtime From 3aa6cda0c3aa981684048d6ed472aebb5b537aa2 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 2 Sep 2026 15:39:21 -0400 Subject: [PATCH 16/16] docs(release): reference build version contract --- components/web-console/app/composition/browser-runtime-config.ts | 1 + components/web-console/bff/src/config.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/components/web-console/app/composition/browser-runtime-config.ts b/components/web-console/app/composition/browser-runtime-config.ts index a2f37a69..588e9cc2 100644 --- a/components/web-console/app/composition/browser-runtime-config.ts +++ b/components/web-console/app/composition/browser-runtime-config.ts @@ -21,6 +21,7 @@ const disabledRuntimeConfig: BrowserRuntimeConfig = { tracing: { sampleRatio: 0 }, }; +// REL-07 in specs/platform/source-release.spec.md defines this format. const buildVersionPattern = /^(?:dev-[0-9a-f]{7}(?:-modified)?|v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-[0-9a-f]{7})$/u; diff --git a/components/web-console/bff/src/config.ts b/components/web-console/bff/src/config.ts index 7dfd25d2..086cb106 100644 --- a/components/web-console/bff/src/config.ts +++ b/components/web-console/bff/src/config.ts @@ -32,6 +32,7 @@ const httpOrigin = z const configSchema = z.object({ HOST: z.string().trim().min(1).default("0.0.0.0"), + // REL-07 in specs/platform/source-release.spec.md defines this format. HYPERSHELL_BUILD_VERSION: z .string() .trim()