diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml deleted file mode 100644 index 0d46b12f85..0000000000 --- a/.github/workflows/build-release-candidate.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Build Release Candidate - -on: - workflow_call: - inputs: - version: - description: 'Version number without the leading v' - required: true - type: string - outputs: - artifact_name: - description: 'Immutable release candidate artifact name' - value: ${{ jobs.build.outputs.artifact_name }} - manifest_artifact_name: - description: 'Release candidate manifest artifact name' - value: ${{ jobs.build.outputs.manifest_artifact_name }} - -permissions: - contents: read - -jobs: - build: - name: Build and Validate Signed Candidate - runs-on: ubuntu-24.04 - timeout-minutes: 35 - outputs: - artifact_name: ${{ steps.identity.outputs.artifact_name }} - manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }} - steps: - - name: Resolve candidate identity - id: identity - run: | - set -euo pipefail - echo "artifact_name=release-candidate-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" - echo "manifest_artifact_name=release-candidate-manifest-${GITHUB_SHA}-${{ inputs.version }}" >> "$GITHUB_OUTPUT" - - - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - name: Validate candidate identity - run: | - set -euo pipefail - test "$(tr -d '\n' < VERSION)" = "${{ inputs.version }}" - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: 'frontend-modern/package-lock.json' - - - name: Install release prerequisites - run: | - sudo apt-get update - sudo apt-get install -y zip - - - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 - with: - version: 'v3.15.2' - - - name: Install Syft - run: | - set -euo pipefail - SYFT_VERSION="1.42.4" - SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" - SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f" - TMP_DIR="$(mktemp -d)" - trap 'rm -rf "$TMP_DIR"' EXIT - curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \ - -o "${TMP_DIR}/${SYFT_ARCHIVE}" - printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check -- - tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft - install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft - - - name: Build signed release candidate - run: ./scripts/build-release.sh "${{ inputs.version }}" - env: - PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} - PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - - - name: Validate installer signing key pins - env: - PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - run: | - set -euo pipefail - TRUSTED_SSH_PUBLIC_KEY="$( - go run ./scripts/release_update_key.go public-key-ssh \ - --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ - --comment pulse-installer - )" - for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do - grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || { - echo "::error::${installer} does not trust the configured release signing key." - exit 1 - } - done - - - name: Validate complete candidate locally - run: ./scripts/validate-release.sh "${{ inputs.version }}" --skip-docker - - - name: Create immutable candidate manifest - run: | - python3 scripts/release_candidate_manifest.py create \ - --release-dir release \ - --version "${{ inputs.version }}" \ - --source-sha "${GITHUB_SHA}" \ - --output release-candidate-manifest/release-candidate.json - - - name: Upload immutable release candidate - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ steps.identity.outputs.artifact_name }} - path: release/ - if-no-files-found: error - retention-days: 1 - compression-level: 0 - overwrite: true - - - name: Upload candidate manifest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ steps.identity.outputs.manifest_artifact_name }} - path: release-candidate-manifest/release-candidate.json - if-no-files-found: error - retention-days: 1 - overwrite: true diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 332b5a05f4..c7662cf8f3 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -84,6 +84,7 @@ jobs: hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }} promotion_mode: ${{ steps.promotion.outputs.promotion_mode }} is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }} + preflight_run_url: ${{ steps.preflight.outputs.run_url }} historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }} steps: - name: Extract version @@ -208,16 +209,35 @@ jobs: echo "[OK] Promotion policy validated for ${TAG}" - build_release_candidate: - name: Build Immutable Release Candidate - needs: prepare - if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} - permissions: - contents: read - uses: ./.github/workflows/build-release-candidate.yml - secrets: inherit - with: - version: ${{ needs.prepare.outputs.version }} + - name: Require recent exact-SHA stable patch preflight + id: preflight + if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' && steps.promotion.outputs.is_stable_patch == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.extract.outputs.version }} + run: | + set -euo pipefail + CUTOFF="$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ')" + RUN="$(gh api "repos/${{ github.repository }}/actions/workflows/release-dry-run.yml/runs?event=workflow_dispatch&status=success&head_sha=${GITHUB_SHA}&per_page=20" \ + | jq -c \ + --arg sha "$GITHUB_SHA" \ + --arg title "Release Dry Run v${VERSION}" \ + --arg cutoff "$CUTOFF" \ + '[.workflow_runs[] | select( + .conclusion == "success" + and .head_sha == $sha + and .display_title == $title + and .created_at >= $cutoff + )] | sort_by(.created_at) | last // empty')" + + if [ -z "$RUN" ]; then + echo "::error::Stable patch release requires a successful Release Dry Run for v${VERSION} at exact commit ${GITHUB_SHA} within the last 24 hours. Run ./scripts/trigger-stable-patch.sh --dry-run ${VERSION}, wait for success, then dispatch once." + exit 1 + fi + + RUN_URL="$(jq -r '.html_url' <<<"$RUN")" + echo "run_url=${RUN_URL}" >> "$GITHUB_OUTPUT" + echo "[OK] Exact-SHA release preflight passed: ${RUN_URL}" # Frontend checks run in parallel with backend tests frontend_checks: @@ -248,24 +268,9 @@ jobs: - name: Check frontend copy-paste duplication run: npm --prefix frontend-modern run lint:cpd - - name: Build verified frontend bundle - run: npm --prefix frontend-modern run build - - - name: Upload verified frontend bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-frontend-${{ github.sha }} - path: frontend-modern/dist/ - if-no-files-found: error - retention-days: 1 - compression-level: 0 - overwrite: true - # Backend tests run in parallel with frontend checks backend_tests: - needs: - - prepare - - frontend_checks + needs: prepare if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -275,11 +280,25 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Download verified frontend bundle - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend-modern/package-lock.json' + + - name: Restore frontend build cache + id: frontend-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: frontend-modern/dist - name: release-frontend-${{ github.sha }} + key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }} + + - name: Build frontend (if not cached) + if: steps.frontend-cache.outputs.cache-hit != 'true' + run: | + npm --prefix frontend-modern ci + npm --prefix frontend-modern run build - name: Copy frontend to embed location run: | @@ -489,7 +508,7 @@ jobs: integration_tests: needs: - prepare - - frontend_checks + - backend_tests if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 45 @@ -506,11 +525,11 @@ jobs: cache: 'npm' cache-dependency-path: 'frontend-modern/package-lock.json' - - name: Download verified frontend bundle - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Restore frontend build cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: frontend-modern/dist - name: release-frontend-${{ github.sha }} + key: frontend-build-${{ hashFiles('frontend-modern/package-lock.json', 'frontend-modern/src/**/*', 'frontend-modern/index.html', 'frontend-modern/postcss.config.cjs', 'frontend-modern/tailwind.config.cjs') }} - name: Copy frontend to embed location run: | @@ -642,14 +661,13 @@ jobs: create_release: needs: - prepare - - build_release_candidate - frontend_checks - backend_tests - docker_build - helm_smoke - integration_tests # Run if integration_tests passed OR was skipped (prereleases) - if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }} + if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: @@ -667,25 +685,80 @@ jobs: with: fetch-depth: 0 - - name: Download immutable release candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - name: ${{ needs.build_release_candidate.outputs.artifact_name }} - path: release + go-version-file: go.mod + cache: true - - name: Download release candidate manifest - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }} - path: release-candidate-manifest + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend-modern/package-lock.json' - - name: Verify immutable release candidate + - name: Install dependencies run: | - python3 scripts/release_candidate_manifest.py verify-local \ - --release-dir release \ - --manifest release-candidate-manifest/release-candidate.json \ - --version "${{ needs.prepare.outputs.version }}" \ - --source-sha "${GITHUB_SHA}" + sudo apt-get update + sudo apt-get install -y zip + + - name: Set up Helm + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 + with: + version: 'v3.15.2' + + - name: Install Syft + run: | + set -euo pipefail + + SYFT_VERSION="1.42.4" + SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" + SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f" + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TMP_DIR"' EXIT + + curl -fsSL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" \ + -o "${TMP_DIR}/${SYFT_ARCHIVE}" + printf '%s %s\n' "${SYFT_SHA256}" "${TMP_DIR}/${SYFT_ARCHIVE}" | sha256sum --check -- + tar -xzf "${TMP_DIR}/${SYFT_ARCHIVE}" -C "${TMP_DIR}" syft + install -m 0755 "${TMP_DIR}/syft" /usr/local/bin/syft + syft version + + - name: Build release artifacts + run: | + echo "Building release ${{ needs.prepare.outputs.tag }}..." + ./scripts/build-release.sh ${{ needs.prepare.outputs.version }} + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} + PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + + - name: Validate installer signing key pins + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + run: | + set -euo pipefail + TRUSTED_SSH_PUBLIC_KEY="$( + go run ./scripts/release_update_key.go public-key-ssh \ + --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ + --comment pulse-installer + )" + + for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do + grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || { + echo "::error::${installer} does not trust the configured release signing key." + exit 1 + } + done + + - name: Post-build health check + run: | + if [ -x ./pulse ]; then + ./pulse --version + elif [ -x ./cmd/pulse/pulse ]; then + ./cmd/pulse/pulse --version + fi - name: Attest release assets uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 @@ -1082,9 +1155,9 @@ jobs: validate_release_assets: needs: - prepare - - build_release_candidate - create_release - if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} + - publish_docker + if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }} permissions: contents: write issues: write @@ -1097,7 +1170,6 @@ jobs: release_id: ${{ needs.create_release.outputs.release_id }} draft: ${{ github.event.inputs.draft_only == 'true' }} target_commitish: ${{ needs.create_release.outputs.target_commitish }} - candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }} # End-to-end install.sh smoke against the just-published release. Catches # runtime regressions in the documented Proxmox-LXC / systemd install flow @@ -1171,15 +1243,15 @@ jobs: # when it fails the floating tags don't advance and customers pulling # rcourtman/pulse:latest stay on whatever the previous successful release # tagged. Calling promote-floating-tags as workflow_call after - # validate_release_assets and publish_docker succeed guarantees the floating - # tags advance. Draft-only runs must not promote floating tags because the - # release is still in private promotion state. + # validate_release_assets succeeds (which itself waits for the docker + # image to be pullable) guarantees the floating tags advance. Draft-only + # runs must not promote floating tags because the release is still in + # private promotion state. promote_floating_tags: needs: - prepare - - publish_docker - validate_release_assets - if: ${{ always() && needs.prepare.result == 'success' && needs.publish_docker.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} + if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' }} permissions: contents: read packages: write @@ -1199,8 +1271,8 @@ jobs: publish_private_pro_runtime: needs: - prepare - - create_release - if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }} + - validate_release_assets + if: ${{ always() && needs.prepare.result == 'success' && needs.validate_release_assets.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && github.event.inputs.draft_only != 'true' && startsWith(needs.prepare.outputs.version, '6.') }} runs-on: ubuntu-24.04 timeout-minutes: 150 steps: @@ -1326,6 +1398,7 @@ jobs: DRAFT_ONLY: ${{ github.event.inputs.draft_only }} VERSION: ${{ needs.prepare.outputs.version }} IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }} + PREFLIGHT_RUN_URL: ${{ needs.prepare.outputs.preflight_run_url }} CREATE_RESULT: ${{ needs.create_release.result }} DOCKER_RESULT: ${{ needs.publish_docker.result }} VALIDATE_RESULT: ${{ needs.validate_release_assets.result }} @@ -1363,3 +1436,6 @@ jobs: fi echo "Release verdict passed for v${VERSION}." + if [ -n "${PREFLIGHT_RUN_URL:-}" ]; then + echo "Stable patch preflight: ${PREFLIGHT_RUN_URL}" + fi diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index ccf4be8eb5..2bc186a2eb 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -59,16 +59,6 @@ permissions: contents: read jobs: - build_release_candidate: - name: Build Immutable Release Candidate - if: ${{ github.event_name == 'workflow_dispatch' }} - permissions: - contents: read - uses: ./.github/workflows/build-release-candidate.yml - secrets: inherit - with: - version: ${{ inputs.version }} - dry-run: name: Preflight Release Checks (No Publish) runs-on: ubuntu-24.04 diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index 40e15a4f20..cdd8a5df1e 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -23,11 +23,6 @@ on: description: 'Commit SHA associated with the release' required: true type: string - candidate_manifest_artifact: - description: 'Same-run immutable candidate manifest artifact for fast digest verification' - required: false - default: '' - type: string release: types: [edited] workflow_dispatch: @@ -52,11 +47,6 @@ on: description: 'Commit SHA associated with the release' required: true type: string - candidate_manifest_artifact: - description: 'Optional immutable candidate manifest artifact name' - required: false - default: '' - type: string permissions: contents: read @@ -119,31 +109,8 @@ jobs: cat context.env >> "$GITHUB_OUTPUT" cat context.env - - name: Download immutable candidate manifest - if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact != '' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ inputs.candidate_manifest_artifact }} - path: release-candidate-manifest - - - name: Fetch release asset metadata + - name: Download all release assets if: steps.context.outputs.should_run == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - gh api --paginate \ - "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets?per_page=100" \ - --slurp > "$RUNNER_TEMP/release-assets.json" - count="$(jq '[.[][]] | length' "$RUNNER_TEMP/release-assets.json")" - if [ "$count" -eq 0 ]; then - echo "::error::No assets found in release" - exit 1 - fi - echo "Found ${count} published release assets." - - - name: Download all release assets for legacy validation - if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == '' id: download env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -189,11 +156,11 @@ jobs: ls -lh - name: Install Docker - if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == '' + if: steps.context.outputs.should_run == 'true' run: docker --version - name: Pull Docker image (with retry logic) - if: steps.context.outputs.should_run == 'true' && inputs.candidate_manifest_artifact == '' + if: steps.context.outputs.should_run == 'true' id: docker continue-on-error: true run: | @@ -237,21 +204,13 @@ jobs: - name: Run validation script if: steps.context.outputs.should_run == 'true' id: validate - env: - CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }} run: | set +e echo "Running validation script..." + chmod +x scripts/validate-release.sh OUTPUT_FILE=$(mktemp) - if [ -n "${CANDIDATE_MANIFEST_ARTIFACT}" ]; then - echo "Validating GitHub's stored SHA-256 digests against the immutable candidate manifest..." - python3 scripts/release_candidate_manifest.py verify-release \ - --manifest release-candidate-manifest/release-candidate.json \ - --assets-json "$RUNNER_TEMP/release-assets.json" \ - --version "${{ steps.context.outputs.version }}" \ - --source-sha "${{ steps.context.outputs.target_commitish }}" 2>&1 | tee "$OUTPUT_FILE" - elif [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then + if [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then echo "Running full validation (Docker + assets)..." scripts/validate-release.sh \ "${{ steps.context.outputs.version }}" \ diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index cf029d50c3..2130164a9f 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -894,58 +894,22 @@ Companion drill: `go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1` - Manual scenario: 1. Push the exact candidate commit to the governed stable branch. - 2. For a no-public-release rehearsal, dispatch - `./scripts/trigger-stable-patch.sh --dry-run `. + 2. Dispatch `./scripts/trigger-stable-patch.sh --dry-run ` and do not + publish another release. 3. Confirm the run passes `Verify Current Stable Demo Path (No Mutation)` on a GitHub-hosted runner, including Tailscale ping, TCP/22, SSH host identity, current stable version, frontend parity, public health, and browser smoke. 4. Confirm the public demo version and health remain unchanged after the run. - Pass when: - The no-public-release rehearsal succeeds without host mutation, routine - patch metadata rejects the documented RC-required risk paths, and the single - publish DAG performs exact-SHA candidate checks before awaiting Docker, demo, - public verification, and the definitive terminal verdict. + The exact-SHA dry run succeeds without host mutation, routine patch metadata + rejects the documented RC-required risk paths, and the publish DAG awaits + Docker, demo, public verification, and the definitive terminal verdict. - Latest exercised record: `docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md` - Block release if: - The integrated candidate checks can be bypassed, the no-mutation demo path - fails when rehearsed, demo deployment is detached from the release DAG, or - routine mode can bypass a same-version RC or an RC-required runtime change. - -## Gate: `single-build-release-promotion-path` - -- Owner lanes: `L1` -- Risk covered: - A normal release can repeat expensive compilation after successful checks, - serialize integration behind backend tests, download the complete release - packet after upload, or delay independent Docker, asset, and private-runtime - work. The release may be unattended but still consume most of a working day. -- Minimum evidence tier: `real-external-e2e` -- Canonical proof commands: - 1. Push the exact implementation commit to the governed release branch. - 2. Dispatch `Release Dry Run` for the current version and exact SHA. Do not - create or modify a public release. - 3. Confirm `Build Immutable Release Candidate` builds, locally validates, and - uploads both the candidate and manifest artifacts. - 4. Confirm the release checks and candidate build overlap, and confirm the - no-mutation demo path passes on GitHub-hosted infrastructure. - 5. Run the candidate-manifest unit tests, release-promotion policy tests, and - installer/release workflow contract tests. - 6. Confirm `v6.0.5` release timestamps and asset count remain unchanged and - the public demo remains healthy on stable `6.0.5`. -- Pass when: - The external rehearsal proves the canonical candidate builder, local tests - pin candidate-only publication and GitHub digest validation, and the static - release DAG contains no duplicate release build, backend-to-integration - serialization, full-download standard validator, or avoidable post-release - serialization. -- Latest exercised record: - `docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md` -- Block release if: - Publication rebuilds release assets, the candidate manifest does not pin the - exact SHA and complete asset set, standard validation downloads the full - release packet, independent post-release jobs are serialized, or the - definitive verdict can pass without all applicable downstream results. + The exact-SHA dry run is missing or older than 24 hours, the no-mutation demo + path fails, demo deployment is detached from the release DAG, or routine mode + can bypass a same-version RC or an RC-required runtime change. ## Gate Ownership Rule diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index e54b78dbdc..9a11134009 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -163,33 +163,6 @@ Cloud, and self-hosted production users. and retain the prior governed release-pipeline rehearsal evidence as automation lineage rather than claiming the post-RC7 changes were RC-tested. -## Single-Build Release Path - -1. Every normal RC, stable, and patch release is initiated once through - `create-release.yml`. The workflow builds one signed candidate for the exact - pushed SHA while frontend, backend, Docker, Helm, and integration checks run - in parallel. No tag, draft, or public release mutation occurs until those - checks and the candidate build pass. -2. The signed candidate is uploaded as a one-day Actions artifact with a - machine-readable manifest that pins source SHA, version, filename, size, and - SHA-256 for every release asset. Publication downloads and verifies that - exact candidate; it must not rebuild release binaries or installers. -3. Standard post-publication asset verification compares the candidate - manifest with GitHub's server-side release-asset SHA-256 digests. It must - not re-download the multi-gigabyte release packet merely to recompute hashes - already proven before upload. Manual and release-edit repair validation may - retain the full-download fallback when no same-run candidate manifest exists. -4. Docker publication, release-asset verification, and the private Pro build - begin independently as soon as the release exists. Helm, floating tags, - install smoke, stable demo deployment, and private paid-runtime promotion - retain their required dependencies, and `Definitive Release Verdict` still - fails unless every applicable terminal result passes. -5. `Release Dry Run` remains the no-public-release rehearsal surface. It calls - the same candidate builder and no-mutation demo verification, but a separate - dry run is not required before a normal release because the single publish - workflow performs the exact-SHA preflight before crossing the publication - boundary. - ## Routine Stable Patch Path 1. A normal stable patch may omit a same-version RC only when all of these are @@ -203,13 +176,12 @@ Cloud, and self-hosted production users. - the canonical stable release-notes packet exists; - the mobile-impact gate either proves no mobile-facing change or records current candidate evidence; and - - the integrated exact-SHA candidate build and release checks pass before - the workflow creates or publishes the release. + - `Release Dry Run` passed for the exact candidate SHA within the previous + 24 hours. That run must include the no-mutation stable-demo path check. 2. `scripts/trigger-stable-patch.sh` is the standard operator entrypoint. Run - it once without `--dry-run`; it derives rollback and release notes, refuses - local-only or dirty state, and supplies workflow metadata without - interactive prompts. `--dry-run` is optional and exists only for an explicit - no-public-release rehearsal. + it once with `--dry-run`, monitor asynchronously, then run it once without + `--dry-run`. It derives rollback and release notes, refuses local-only or + dirty state, and supplies the workflow metadata without interactive prompts. 3. Creating a same-version RC or touching an RC-required path moves the patch onto the RC promotion path. The resolver enforces that boundary. Do not use the routine helper to relabel a risky patch as routine. diff --git a/docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md b/docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md deleted file mode 100644 index edaf47fbc0..0000000000 --- a/docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md +++ /dev/null @@ -1,78 +0,0 @@ -# Single-Build Release Promotion Path - 2026-07-09 - -## Scope - -Make the normal RC, stable, and patch release path one unattended workflow -whose public promotion and definitive verdict complete materially faster than -the v6.0.5 path without weakening release gates. - -## v6.0.5 Timing Baseline - -- The successful public release run `29022145812` started at - `2026-07-09T13:38:55Z` and did not finish its awaited private-runtime work - until `2026-07-09T15:45:54Z`. -- Pre-publication checks consumed approximately 36 minutes because integration - tests waited for the 20-minute backend suite. -- `create_release` then consumed another 21 minutes. Its signed release build - alone took 18 minutes and 44 seconds even though the same SHA had already - completed release checks. -- Post-publication asset validation consumed 22 minutes and 29 seconds because - it downloaded the complete 213-asset, multi-gigabyte release packet. -- Private Pro build and promotion consumed 46 minutes and 35 seconds, but did - not start until release-asset validation finished. -- Stable patch operation also required a separate 37-minute exact-SHA dry run, - making the safe operator path additive rather than a single release run. - -## Canonical Correction - -1. `.github/workflows/build-release-candidate.yml` builds and locally validates - one signed candidate for the exact pushed SHA. It emits a one-day candidate - artifact plus a small manifest containing version, tag, source SHA, - filenames, sizes, and SHA-256 values. -2. Candidate construction runs in parallel with frontend, backend, Docker, - Helm, and integration checks. Frontend lint/build runs once and provides one - verified bundle to backend and integration jobs. Backend and integration no - longer serialize on each other. -3. `create_release` downloads and verifies the immutable candidate instead of - rebuilding release assets. Tag, draft, and publication mutations remain - downstream of every required check. -4. Standard post-upload validation compares the candidate manifest with - GitHub's server-side release-asset SHA-256 digests and sizes. The legacy - full-download validator remains available for manual repair or release-edit - validation when no same-run manifest exists. -5. Docker publication, candidate-backed release-asset validation, and private - Pro publication start independently after release creation. Floating tags - still require both Docker and asset validation; install smoke, Helm, stable - demo, paid-runtime promotion, and the definitive verdict retain their - applicable safety dependencies. -6. `Release Dry Run` calls the same candidate builder for a no-public-release - rehearsal. Normal release publication does not require a separate dry run - because `create-release.yml` contains the exact-SHA candidate and test gates - before publication. - -## Timing Contract - -Using the v6.0.5 timings as the baseline: - -- the public release boundary should normally be reached within 35 minutes of - dispatch rather than after a separate rehearsal plus approximately 58 - minutes of checks and rebuilding; -- the definitive cross-product verdict should normally complete within 80 - minutes, with the private Pro build as the expected critical path rather than - serial release-asset downloads; and -- operator attention remains limited to preparing the release packet and one - dispatch. Runner queueing or external registry degradation may extend wall - time, but no standard job may reintroduce duplicate release builds, complete - packet downloads, or avoidable serial dependencies. - -## Verification - -Pending one pushed `Release Dry Run` for the exact implementation SHA. The run -must build and validate the signed candidate, upload both candidate artifacts, -pass release checks, complete no-mutation demo verification, and leave the -published `v6.0.5` release and demo runtime unchanged. - -## Current Verdict - -Blocked until the pushed no-public-release rehearsal succeeds and its measured -job timings are recorded here. diff --git a/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md b/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md index 02f7eac96e..1925f23dd9 100644 --- a/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md +++ b/docs/release-control/v6/internal/records/stable-patch-unattended-release-path-2026-07-09.md @@ -79,8 +79,7 @@ deployment, and one definitive release verdict. then emits a terminal `Definitive Release Verdict`. - Routine stable patch release resolution no longer fabricates RC ceremony, but it fails closed for documented high-risk runtime changes, an existing - same-version RC, stale rollback lineage, or failed integrated exact-SHA - candidate checks. + same-version RC, stale rollback lineage, or a missing exact-SHA dry run. - `scripts/trigger-stable-patch.sh` is the noninteractive operator entrypoint. ## End-to-End Rehearsal @@ -112,11 +111,7 @@ change is required for the currently verified `tag:infra` path. ## Current Verdict -Passed. A routine stable patch now uses one noninteractive publish dispatch -whose exact-SHA candidate checks run before publication. `Release Dry Run` -remains available for explicit no-public-release rehearsal. The publish DAG -awaits release promotion, Docker publication, stable demo deployment, -definitive public verification, and its terminal verdict; manual SSH is not -part of the standard path. The release-wide single-build timing contract is -tracked in -`docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md`. +Passed. A routine stable patch now requires one recent exact-SHA preflight and +one noninteractive publish dispatch. The publish DAG awaits release promotion, +Docker publication, stable demo deployment, definitive public verification, +and its terminal verdict; manual SSH is not part of the standard path. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 36b1c8fc79..d9ef4c3661 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -6764,29 +6764,9 @@ } ] }, - { - "id": "single-build-release-promotion-path", - "summary": "Confirm normal RC, stable, and patch releases build one exact-SHA signed candidate in parallel with checks, promote that candidate without rebuilding, validate GitHub asset digests without full packet downloads, and run independent post-publication lanes concurrently.", - "owner": "project-owner", - "blocking_level": "release-ready", - "minimum_evidence_tier": "real-external-e2e", - "status": "blocked", - "verification_doc": "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md", - "lane_ids": [ - "L1" - ], - "evidence": [ - { - "repo": "pulse", - "path": "docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md", - "kind": "file", - "evidence_tier": "local-rehearsal" - } - ] - }, { "id": "stable-patch-unattended-release-path", - "summary": "Confirm routine stable patches use one noninteractive publish dispatch with integrated exact-SHA candidate checks, awaited Docker and demo deployment, and a definitive release verdict without manual SSH recovery.", + "summary": "Confirm routine stable patches require one recent exact-SHA dry run, one noninteractive publish dispatch, awaited Docker and demo deployment, and a definitive release verdict without manual SSH recovery.", "owner": "project-owner", "blocking_level": "release-ready", "minimum_evidence_tier": "real-external-e2e", diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e0f1522e44..6e2e042598 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -46,8 +46,7 @@ TLS floor in the dynamic config. 15. `internal/cloudcp/docker/manager.go` 16. `internal/cloudcp/docker/labels.go` 17. `internal/cloudcp/tenant_runtime_rollout.go` -13. `.github/workflows/build-release-candidate.yml` -14. `.github/workflows/create-release.yml` +13. `.github/workflows/create-release.yml` 14. `.github/workflows/deploy-demo-server.yml` 15. `.github/workflows/helm-pages.yml` 16. `.github/workflows/promote-floating-tags.yml` @@ -96,8 +95,7 @@ TLS floor in the dynamic config. 59. `scripts/release_control/resolve_release_promotion.py` 60. `scripts/release_control/mobile_release_gate.py` 61. `scripts/release_control/mobile_release_gate_test.py` -62. `scripts/release_candidate_manifest.py` -63. `scripts/release_control/validate_artifact_release_line.py` +62. `scripts/release_control/validate_artifact_release_line.py` 63. `scripts/release_ldflags.sh` 64. `scripts/run_cloud_public_signup_smoke.sh` 65. `scripts/run_demo_public_browser_smoke.sh` @@ -320,16 +318,6 @@ TLS floor in the dynamic config. 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` 2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` - Normal releases are single-build promotions. The exact pushed SHA must - produce one signed candidate through - `.github/workflows/build-release-candidate.yml` while independent release - checks run in parallel. `create-release.yml` may publish only that candidate - after `scripts/release_candidate_manifest.py` verifies its version, source - SHA, filenames, sizes, and SHA-256 values. Standard post-upload validation - must compare that manifest with GitHub's server-side asset digests instead - of downloading the complete release packet again. Historical repair and - release-edit validation may use the full-download fallback because those - paths do not have a same-run candidate manifest. Release-facing agent-paradigm blurbs under `docs/releases/` must describe `pulse-mcp` as a generic MCP adapter for MCP-speaking clients, not a client-specific release artifact, and full-surface token guidance must come diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9d502180b5..51f5ebc135 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -335,8 +335,11 @@ Namespace, ConfigMap, Secret, and ServiceAccount-specific columns. Kubernetes Node inventory must also be reachable through a dedicated native tab, not only the overview stack, while retaining the shared `PlatformSectionTabs` shell. Primary app-shell navigation consumes unified-resources-owned resource evidence: -empty compatibility facets such as `docker: {}` do not admit runtime-lens tabs -on their own. +empty or generic compatibility facets do not admit runtime-lens tabs on their +own, and cached resource evidence must not render primary platform navigation +before the first authoritative resource snapshot resolves. A platform that is +not admitted stays reachable by direct setup URL, but does not occupy primary +navigation with an empty page. Feature-owned Docker / Podman action controls may render backend `actionReadiness` disabled reasons, but the shared primitive layer owns only the button/table affordance shell; it must not invent action availability, command @@ -391,7 +394,9 @@ Docker empty-state guidance on platform pages follows the same shared platform primitive boundary: it may use the route-specific Docker / Podman vocabulary, but it must distinguish standalone Docker host installation from the Proxmox LXC Docker host-side inventory path without adding page-local installer command -assembly or token handling. +assembly or token handling. The empty state must provide a direct action into +the Docker-specific Infrastructure add flow rather than leaving the operator at +a descriptive dead end. Docker / Podman inventory follows that same primitive boundary while the unified-resource owner supplies API-object-specific container, image, volume, network, Swarm node, task, secret, and config columns through dedicated native @@ -3147,6 +3152,10 @@ status summary directly above that shared table frame. The consumer owns the already-loaded resource counts, freshness-aware attention ordering, and settings action; the shared primitive boundary still forbids a second fetch, detached proof strip, decorative chart, or locally recreated table frame. +The shared platform toolbar owns count grammar and canonical status-route +normalization: one visible row uses the singular form, and legacy provider +status values such as `running` or `stopped` normalize into the page's shared +health filter rather than leaking provider vocabulary between platform routes. Platform table empty states follow the same registry-backed ownership. `PlatformTableEmptyState` owns the repeated table-card empty-state shell for Docker, Kubernetes, Proxmox, Standalone, TrueNAS, vSphere, and future platform diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index c41d127557..104c878887 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3302,7 +3302,6 @@ ".github/scripts/check-demo-reachability.sh", ".github/scripts/setup-demo-ssh.sh", ".github/workflows/backfill-release-assets.yml", - ".github/workflows/build-release-candidate.yml", ".github/workflows/create-release.yml", ".github/workflows/deploy-demo-server.yml", ".github/workflows/helm-pages.yml", @@ -3365,7 +3364,6 @@ "scripts/lib/hot-dev-runtime.sh", "scripts/pulse-auto-update.sh", "scripts/release_asset_common.sh", - "scripts/release_candidate_manifest.py", "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", "scripts/release_control/record_rc_to_ga_rehearsal.py", "scripts/release_control/release_promotion_policy_support.py", @@ -3461,7 +3459,6 @@ ".github/scripts/check-demo-reachability.sh", ".github/scripts/setup-demo-ssh.sh", ".github/workflows/backfill-release-assets.yml", - ".github/workflows/build-release-candidate.yml", ".github/workflows/create-release.yml", ".github/workflows/helm-pages.yml", ".github/workflows/install-sh-smoke.yml", @@ -3502,20 +3499,6 @@ "scripts/release_control/validate_artifact_release_line_test.py" ] }, - { - "id": "release-candidate-manifest-runtime", - "label": "immutable release candidate manifest proof", - "match_prefixes": [], - "match_files": [ - "scripts/release_candidate_manifest.py" - ], - "allow_same_subsystem_tests": false, - "test_prefixes": [], - "exact_files": [ - "scripts/installtests/build_release_assets_test.go", - "scripts/release_control/release_candidate_manifest_test.py" - ] - }, { "id": "release-build-metadata-runtime", "label": "release build metadata proof", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index dada776f1a..a005a51c3c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -475,10 +475,15 @@ has no rows for the current setup. Signals outside unified-resource inventory, such as TrueNAS recovery protection points or vSphere activity timeline rows, must be treated as explicit tab evidence rather than permanent navigation. Primary platform navigation is also resource-evidence gated: runtime lenses such -as Docker / Podman must be admitted from explicit source scopes, Docker host or -service resource types, or non-empty Docker runtime metadata. Empty compatibility -facets such as `docker: {}` on a plain machine agent are not Docker inventory and -must not create a transient Docker tab during hydration. +as Docker / Podman must be admitted from explicit Docker source scopes, Docker +host or service resource types, or concrete runtime identity/inventory evidence +such as a runtime/version or a positive object count. Generic host metadata, +zero-count compatibility projections, and empty facets such as `docker: {}` on a +plain machine agent are not Docker inventory and must not create a transient or +empty Docker tab during hydration. Navigation admission and the corresponding +platform page model must consume the same evidence contract. A direct route to +an absent platform may show its setup action, but the primary navigation must +stay limited to platform pages that can render admitted inventory. Kubernetes configuration and policy inventory are unified-resource consumer boundaries: the `/kubernetes/configuration` workflow tab must render Namespace, diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index c5c3bd95a1..9e825c1ea2 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -65,6 +65,7 @@ import { import { DarkModeContext, WebSocketContext, useWebSocket } from '@/contexts/appRuntime'; import { buildPrimaryPlatformNavigationVisibility, + createEmptyPlatformNavigationVisibility, selectFirstVisiblePrimaryPlatformNavigationId, type PlatformNavigationVisibility, type PrimaryPlatformNavId, @@ -78,7 +79,9 @@ function isPublicRoutePath(pathname: string): boolean { function isWorkspaceEntryRoutePath(pathname: string): boolean { const normalizedPath = pathname.replace(/\/+$/, '') || '/'; - return normalizedPath === '/' || normalizedPath === '/login' || normalizedPath === '/infrastructure'; + return ( + normalizedPath === '/' || normalizedPath === '/login' || normalizedPath === '/infrastructure' + ); } const AlertsPage = lazy(() => @@ -254,13 +257,15 @@ function App() { const navigate = useNavigate(); const location = useLocation(); const isPublicRoute = createMemo(() => isPublicRoutePath(location.pathname)); - const platformNavigationVisibility = createMemo(() => - buildPrimaryPlatformNavigationVisibility(runtime.state().resources || []), - ); const platformNavigationResolved = createMemo(() => { const store = runtime.enhancedStore(); return Boolean(store?.initialDataReceived?.()); }); + const platformNavigationVisibility = createMemo(() => + platformNavigationResolved() + ? buildPrimaryPlatformNavigationVisibility(runtime.state().resources || []) + : createEmptyPlatformNavigationVisibility(), + ); const hasSettingsAccess = createMemo(() => { const scopes = runtime.securityStatus()?.tokenScopes; return ( @@ -283,12 +288,9 @@ function App() { if (runtime.isLoading() || runtime.needsAuth() || isPublicRoute()) return; if (!isWorkspaceEntryRoutePath(location.pathname)) return; if (!platformNavigationResolved()) return; - navigate( - getDefaultWorkspaceRoute(platformNavigationVisibility(), hasSettingsAccess()), - { - replace: true, - }, - ); + navigate(getDefaultWorkspaceRoute(platformNavigationVisibility(), hasSettingsAccess()), { + replace: true, + }); }); createEffect(() => { @@ -480,6 +482,7 @@ function App() { proxyAuthInfo={runtime.proxyAuthInfo} handleLogout={runtime.handleLogout} state={runtime.state} + platformVisibility={platformNavigationVisibility} tokenScopes={() => runtime.securityStatus()?.tokenScopes} organizations={runtime.organizations} activeOrgID={runtime.activeOrgID} @@ -487,9 +490,7 @@ function App() { showOrgSwitcher={runtime.showOrgSwitcher} onSwitchOrg={runtime.handleOrgSwitch} > - - {props.children} - + {props.children} {/* AI Panel - slides in from right, pushes content. diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 8739edb159..c070a23c3c 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -16,6 +16,7 @@ import { buildPrimaryPlatformNavigationVisibility, primaryPlatformNavigationIsVisible, selectFirstVisiblePrimaryPlatformNavigationId, + type PlatformNavigationVisibility, type PrimaryPlatformNavId, } from '@/features/platformNavigation/platformNavigationModel'; import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState'; @@ -93,10 +94,7 @@ function currentPrimaryRoute(pathname: string, search: string, hash: string): st return `${pathname}${search}${hash}`; } -function resolvePrimaryNavigationRoute( - tab: PrimaryTab, - routeMemory: PrimaryRouteMemory, -): string { +function resolvePrimaryNavigationRoute(tab: PrimaryTab, routeMemory: PrimaryRouteMemory): string { if (!tab.enabled) { return tab.settingsRoute; } @@ -134,6 +132,7 @@ export interface AppLayoutProps { proxyAuthInfo: () => { username?: string; logoutURL?: string } | null; handleLogout: () => void; state: () => State; + platformVisibility?: () => PlatformNavigationVisibility; tokenScopes: () => string[] | undefined; organizations: () => Organization[]; activeOrgID: () => string; @@ -290,8 +289,10 @@ export function AppLayout(props: AppLayoutProps) { setKioskMode(!kioskMode()); }; - const platformNavigationVisibility = createMemo(() => - buildPrimaryPlatformNavigationVisibility(props.state().resources || []), + const platformNavigationVisibility = createMemo( + () => + props.platformVisibility?.() ?? + buildPrimaryPlatformNavigationVisibility(props.state().resources || []), ); const primaryInfrastructureRouteById: Record = { proxmox: ROOT_PROXMOX_PATH, diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 949d1892e6..5c7e4768ee 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -144,9 +144,9 @@ describe('App architecture', () => { expect(appSource).toContain(''); expect(appSource).toContain(''); expect(appSource).toContain('function isWorkspaceEntryRoutePath(pathname: string): boolean'); - expect(appSource).toContain( - "return normalizedPath === '/' || normalizedPath === '/login' || normalizedPath === '/infrastructure';", - ); + expect(appSource).toContain("normalizedPath === '/'"); + expect(appSource).toContain("normalizedPath === '/login'"); + expect(appSource).toContain("normalizedPath === '/infrastructure'"); expect(appSource).toContain( '', ); diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index b4611000a6..5b8c1ab090 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { State } from '@/types/api'; import type { Resource } from '@/types/resource'; import { AppLayout, resetPrimaryNavigationRouteMemory } from '@/AppLayout'; +import type { PlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel'; import { aiChatStore } from '@/stores/aiChat'; HTMLElement.prototype.scrollIntoView = vi.fn(); @@ -54,7 +55,11 @@ describe('AppLayout navigation icons', () => { ...overrides, }) as Resource; - const renderLayout = (resources: Resource[] = [], initialPath = '/settings/infrastructure') => { + const renderLayout = ( + resources: Resource[] = [], + initialPath = '/settings/infrastructure', + platformVisibility?: PlatformNavigationVisibility, + ) => { window.history.replaceState({}, '', initialPath); const RouteStateProbe = () => { const navigate = useNavigate(); @@ -94,6 +99,7 @@ describe('AppLayout navigation icons', () => { resources, }) as unknown as State } + platformVisibility={platformVisibility ? () => platformVisibility : undefined} tokenScopes={() => ['settings:read']} organizations={() => []} activeOrgID={() => 'default'} @@ -225,6 +231,26 @@ describe('AppLayout navigation icons', () => { ).toBeTruthy(); }); + it('does not expose cached platform evidence before navigation admission resolves', () => { + renderLayout( + [makeResource({ id: 'docker-1', type: 'docker-host', platformType: 'docker' })], + '/settings/infrastructure', + { + proxmox: false, + docker: false, + kubernetes: false, + truenas: false, + vmware: false, + standalone: false, + }, + ); + + const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' }); + const infrastructureGroup = desktopNav.querySelector('[aria-label="Infrastructure"]'); + expect(infrastructureGroup).toBeTruthy(); + expect(within(infrastructureGroup as HTMLElement).queryByRole('tab')).toBeNull(); + }); + it('restores the previous Proxmox route state when returning from another platform tab', async () => { renderLayout(platformResources(), '/proxmox/overview?status=running'); diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index a6b961213e..a3c0fc5bbc 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -23,13 +23,14 @@ import { getDiscoverySuggestedURLCodeClass, getDiscoverySuggestedURLHeadingClass, getDiscoverySuggestedURLTextClass, + getDiscoveryServiceContextSettingsTarget, } from '@/utils/discoveryPresentation'; import { DiscoveryProvenanceMarker } from '@/components/shared/DiscoveryProvenanceMarker'; import { DISCOVERY_ANALYSIS_EXPLANATION, DISCOVERY_ANALYSIS_REASONING_LABEL, } from '@/utils/resourceAnalysisPresentation'; -import { CopyValueButton } from '@/components/shared/Button'; +import { ButtonLink, CopyValueButton } from '@/components/shared/Button'; import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow'; import { InfoCardFrame } from '@/components/shared/InfoCardFrame'; import { useDiscoveryTabState } from './useDiscoveryTabState'; @@ -128,6 +129,7 @@ export const DiscoveryTab: Component = (props) => { }); const commandSettingsTarget = getDiscoveryCommandSettingsTarget(); const apiAccessSettingsTarget = getDiscoveryApiAccessSettingsTarget(); + const serviceContextSettingsTarget = getDiscoveryServiceContextSettingsTarget(); const showManualRunAction = () => props.showManualRunAction === true; const manualRunSummary = createMemo(() => { if (discovery.loading) return 'Checking saved discovery state'; @@ -148,9 +150,16 @@ export const DiscoveryTab: Component = (props) => {

Service Context Disabled

- Enable service context in Settings -> Pulse Intelligence -> Service Context - before using this tab. + Enable service context to inspect what is running on this resource.

+ + {serviceContextSettingsTarget.label} +
diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx index 3f9a4f97d4..6bb2597fda 100644 --- a/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx @@ -20,6 +20,18 @@ vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn(async () => true), })); +vi.mock('@/components/shared/Button', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ButtonLink: (props: { href: string; children: unknown; class?: string }) => ( + + {props.children as never} + + ), + }; +}); + vi.mock('@/api/ai', () => ({ AIAPI: { getSettings: vi.fn(async () => ({ discovery_enabled: true })), diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 79c07a6f07..fcb9d0ca30 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -136,8 +136,8 @@ const HostDetailsDisclosure: Component<{ summary={props.drawer.hostDetailSummary()} expanded={props.drawer.showHostDetails()} onToggle={() => props.drawer.setShowHostDetails((value) => !value)} - showLabel={`Show ${noun()}`} - hideLabel={`Hide ${noun()}`} + showLabel={isPulseAgentPlatformResource(props.resource) ? 'Show details' : `Show ${noun()}`} + hideLabel={isPulseAgentPlatformResource(props.resource) ? 'Hide details' : `Hide ${noun()}`} class={props.class} contentClass={ props.contentClass ?? @@ -358,20 +358,53 @@ export const ResourceDetailDrawerOverviewTab: Component + + } + > + + } > - +
+ + Technical details + State, identity, and source IDs + +
+ + } + > + + +
+
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index b51f3deb08..1653d7c129 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -360,15 +360,12 @@ describe('toDiscoveryConfig', () => { describe('resource drawer discovery promotion', () => { it('points disabled discovery readiness at Pulse Intelligence service context settings', () => { expect(discoveryTabSource).toContain('Service Context Disabled'); - expect(discoveryTabSource).toContain( - 'Settings -> Pulse Intelligence -> Service Context', - ); + expect(discoveryTabSource).toContain('getDiscoveryServiceContextSettingsTarget'); + expect(discoveryTabSource).toContain('serviceContextSettingsTarget.label'); expect(discoveryTabStateSource).toContain( 'Service context is disabled in Settings -> Pulse Intelligence -> Service Context.', ); - expect(discoveryReadinessSource).toContain( - 'Settings -> Pulse Intelligence -> Service Context', - ); + expect(discoveryReadinessSource).toContain('Settings -> Pulse Intelligence -> Service Context'); }); it('does not treat command-only diagnostic records as meaningful resource context', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index 48bbc6caa7..5116de80a7 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -361,7 +361,7 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(queryByText('Hardware')).toBeNull(); expect(queryByText('Network')).toBeNull(); - fireEvent.click(getByRole('button', { name: 'Show machine' })); + fireEvent.click(getByRole('button', { name: 'Show details' })); await waitFor(() => { expect(getByText('Hardware')).toBeInTheDocument(); @@ -854,8 +854,10 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { }); const inlineRender = render(() => ); - expect(inlineRender.getByText('Aliases')).toBeInTheDocument(); - expect(inlineRender.container.querySelector('details')).toBeNull(); + const inlineAliases = inlineRender.getByText('Aliases'); + expect(inlineAliases).toBeInTheDocument(); + expect(inlineAliases.closest('summary')).toBeNull(); + expect(inlineRender.container.querySelectorAll('details')).toHaveLength(1); expect(inlineRender.getByText('agent-inline-1')).toBeInTheDocument(); expect(inlineRender.getAllByText('inline-host.local').length).toBeGreaterThan(0); @@ -883,8 +885,9 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { }); const overflowRender = render(() => ); - expect(overflowRender.getByText('Aliases')).toBeInTheDocument(); - expect(overflowRender.container.querySelector('details')).toBeTruthy(); + const overflowAliases = overflowRender.getByText('Aliases'); + expect(overflowAliases).toBeInTheDocument(); + expect(overflowAliases.closest('summary')).toBeTruthy(); }); it('surfaces policy governance details and the safe summary', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx index 46bdebfc64..0bad6ab8ba 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx @@ -188,9 +188,9 @@ describe('ResourceDetailDrawer machine metrics history', () => { const machineSection = screen.getByTestId('resource-host-details-section'); expect(within(machineSection).getByText('Machine')).toBeInTheDocument(); expect( - within(machineSection).getByRole('button', { name: 'Hide machine' }), + within(machineSection).getByRole('button', { name: 'Hide details' }), ).toBeInTheDocument(); - expect(within(machineSection).queryByRole('button', { name: 'Show machine' })).toBeNull(); + expect(within(machineSection).queryByRole('button', { name: 'Show details' })).toBeNull(); expect(within(machineSection).getByText('richard-mac-mini.local')).toBeInTheDocument(); expect(within(machineSection).getByText('Network')).toBeInTheDocument(); expect(within(machineSection).getByText('192.168.0.42')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index e28091a25a..ddce5f52eb 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -5203,6 +5203,13 @@ describe('shared primitive guardrails', () => { } }); + it('keeps platform count grammar and route status normalization shared', () => { + expect(sharedPlatformPageSource).toContain('export const getPlatformResourceCountNoun'); + expect(sharedPlatformPageSource).toContain( + 'export const normalizePlatformResourceStatusFilter', + ); + }); + it('keeps platform table empty states on the shared shell template', () => { const registry = JSON.parse(sharedTemplateRegistrySource) as { patternGuards: Array<{ diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 727adb8acd..22150aba5d 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -1,5 +1,7 @@ import { useLocation, useSearchParams } from '@solidjs/router'; import { Show, createMemo, createSignal } from 'solid-js'; +import BoxIcon from 'lucide-solid/icons/box'; +import { ButtonLink } from '@/components/shared/Button'; import { getPlatformIcon } from '@/features/platformPage/platformIcon'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { @@ -40,7 +42,10 @@ import { } from '@/features/platformPage/agentVersion'; import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; import { updateStore } from '@/stores/updates'; -import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel'; +import { + buildInfrastructureAgentUpdatesPath, + buildInfrastructureOnboardingPath, +} from '@/components/Settings/infrastructureWorkspaceModel'; import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks'; import { asTrimmedString } from '@/utils/stringUtils'; import type { Resource } from '@/types/resource'; @@ -121,7 +126,18 @@ export function DockerPageSurface() { +
diff --git a/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx b/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx index b1c4d71036..3fb6b8ee5c 100644 --- a/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx @@ -6,12 +6,18 @@ import { StandalonePageSurface } from '../StandalonePageSurface'; const mocks = vi.hoisted(() => ({ pathname: '/standalone/machines', + searchParams: {} as Record, + setSearchParams: vi.fn(), navigate: vi.fn(), useUnifiedResources: vi.fn(), versionInfo: vi.fn(), - AgentsMachinesTable: vi.fn((props: { resources: Resource[] }) => ( -
- )), + AgentsMachinesTable: vi.fn( + (props: { + resources: Resource[]; + externalStatus?: () => string; + onExternalStatusChange?: (value: string) => void; + }) =>
, + ), AvailabilityChecksTable: vi.fn((props: { resources: Resource[] }) => (
)), @@ -37,6 +43,7 @@ vi.mock('@solidjs/router', async () => { }, }), useNavigate: () => mocks.navigate, + useSearchParams: () => [mocks.searchParams, mocks.setSearchParams], A: (props: { href: string; children: JSX.Element }) => ( {props.children} ), @@ -51,26 +58,32 @@ vi.mock('../AvailabilityChecksTable', () => ({ AvailabilityChecksTable: mocks.AvailabilityChecksTable, })); -vi.mock('@/features/platformPage/sharedPlatformPage', () => ({ - PlatformErrorState: () =>
, - PlatformSectionTabs: (props: { - active: string; - tabs: Array<{ id: string; label: string; path: string }>; - }) => ( -
tab.id).join(',')} - /> - ), - PlatformTableEmptyState: (props: { title: string; actions?: JSX.Element }) => ( -
- {props.title} - {props.actions} -
- ), - PlatformTableLoadingState: () =>
, -})); +vi.mock('@/features/platformPage/sharedPlatformPage', async () => { + const actual = await vi.importActual( + '@/features/platformPage/sharedPlatformPage', + ); + return { + ...actual, + PlatformErrorState: () =>
, + PlatformSectionTabs: (props: { + active: string; + tabs: Array<{ id: string; label: string; path: string }>; + }) => ( +
tab.id).join(',')} + /> + ), + PlatformTableEmptyState: (props: { title: string; actions?: JSX.Element }) => ( +
+ {props.title} + {props.actions} +
+ ), + PlatformTableLoadingState: () =>
, + }; +}); const resource = (overrides: Partial): Resource => ({ @@ -88,6 +101,8 @@ const resource = (overrides: Partial): Resource => beforeEach(() => { mocks.pathname = '/standalone/machines'; + mocks.searchParams = {}; + mocks.setSearchParams.mockClear(); mocks.navigate.mockClear(); mocks.versionInfo.mockReturnValue(null); mocks.useUnifiedResources.mockReturnValue({ @@ -120,6 +135,16 @@ afterEach(() => { }); describe('StandalonePageSurface', () => { + it('normalizes legacy machine status links into route-owned filter state', () => { + mocks.searchParams = { status: 'running' }; + render(() => ); + + const props = mocks.AgentsMachinesTable.mock.calls.at(-1)?.[0]; + expect(props?.externalStatus?.()).toBe('online'); + props?.onExternalStatusChange?.('offline'); + expect(mocks.setSearchParams).toHaveBeenCalledWith({ status: 'offline' }, { replace: true }); + }); + it('keeps overview focused on Pulse Agent machines only', () => { render(() => ); diff --git a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts index 0e85fd30fc..3363fa0978 100644 --- a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts +++ b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts @@ -29,6 +29,7 @@ import { PULSE_MCP_TOKEN_SETUP_PATH, PULSE_INTELLIGENCE_AGENT_TOKEN_PRESET, SETTINGS_PULSE_INTELLIGENCE_ASSISTANT_PATH, + SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH, SETTINGS_PULSE_INTELLIGENCE_PATH, isExternalAgentSetupHash, PROXMOX_DEFAULT_TAB, @@ -132,6 +133,9 @@ describe('resource link routing contract', () => { expect(SETTINGS_PULSE_INTELLIGENCE_ASSISTANT_PATH).toBe( '/settings/pulse-intelligence/assistant', ); + expect(SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH).toBe( + '/settings/pulse-intelligence/discovery', + ); expect(EXTERNAL_AGENT_SETUP_ANCHOR).toBe('external-agent-setup'); expect(EXTERNAL_AGENT_SETUP_PATH).toBe( '/settings/pulse-intelligence/assistant#external-agent-setup', diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index 3623eaca40..008a482c82 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -43,6 +43,7 @@ export const PATROL_OPERATIONS_LOOP_PATH = PATROL_CONTROL_PATH; export const SETTINGS_API_ACCESS_PATH = '/settings/security/api'; export const SETTINGS_PULSE_INTELLIGENCE_PATH = '/settings/pulse-intelligence'; export const SETTINGS_PULSE_INTELLIGENCE_ASSISTANT_PATH = `${SETTINGS_PULSE_INTELLIGENCE_PATH}/assistant`; +export const SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH = `${SETTINGS_PULSE_INTELLIGENCE_PATH}/discovery`; export const API_TOKEN_CREATE_ANCHOR = 'api-token-create'; export const API_TOKEN_PRESET_QUERY_PARAM = 'tokenPreset'; export const PULSE_INTELLIGENCE_AGENT_TOKEN_PRESET = 'pulse-intelligence-agent'; diff --git a/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts b/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts index e9e3752a25..c71b2eb5d3 100644 --- a/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/discoveryPresentation.test.ts @@ -10,6 +10,7 @@ import { getDiscoveryProvenanceIconClass, getDiscoveryProvenanceLabel, getDiscoveryProvenanceTitle, + getDiscoveryServiceContextSettingsTarget, getNetworkDiscoveryModePresentation, getDiscoveryNotesEmptyState, getDiscoveryObservedSourceLabel, @@ -104,6 +105,10 @@ describe('discoveryPresentation', () => { href: '/settings/security/api', label: 'Settings → API Access', }); + expect(getDiscoveryServiceContextSettingsTarget()).toEqual({ + href: '/settings/pulse-intelligence/discovery', + label: 'Open Service Context settings', + }); expect(getDiscoveryNoConnectedAgentMessage(false)).toBe( 'Commands not enabled. Enable Pulse commands from Settings → Infrastructure for this agent.', ); @@ -236,7 +241,16 @@ describe('discoveryPresentation', () => { service_type: 'grafana', confidence: 0.9, ports: [{ port: 3000, protocol: 'tcp', process: 'grafana', address: '0.0.0.0' }], - facts: [{ category: 'service', key: 'status', value: 'running', source: 'systemd', confidence: 1, discovered_at: '2026-06-01T00:00:00Z' }], + facts: [ + { + category: 'service', + key: 'status', + value: 'running', + source: 'systemd', + confidence: 1, + discovered_at: '2026-06-01T00:00:00Z', + }, + ], config_paths: [], data_paths: [], log_paths: [], diff --git a/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts b/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts index f5da208e28..906e18ab24 100644 --- a/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceIdentity.test.ts @@ -372,7 +372,9 @@ describe('resourceIdentity', () => { } as unknown as Agent; expect(getInfrastructureMetadataId(node, agent)).toBe('agent-discovery'); - expect(getInfrastructureDiscoveryHostname({ name: 'pve1' }, agent)).toBe('canonical-host.local'); + expect(getInfrastructureDiscoveryHostname({ name: 'pve1' }, agent)).toBe( + 'canonical-host.local', + ); }); it('resolves configured node labels with display-first precedence', () => { @@ -558,12 +560,20 @@ describe('resourceIdentity', () => { }); it('resolves canonical cluster names for Kubernetes RBAC inventory rows', () => { - for (const type of ['k8s-role', 'k8s-cluster-role', 'k8s-role-binding', 'k8s-cluster-role-binding'] as const) { + for (const type of [ + 'k8s-role', + 'k8s-cluster-role', + 'k8s-role-binding', + 'k8s-cluster-role-binding', + ] as const) { expect( getPreferredResourceClusterName( makeResource({ type, - name: type === 'k8s-role' || type === 'k8s-role-binding' ? 'api-runtime' : 'platform-monitoring', + name: + type === 'k8s-role' || type === 'k8s-role-binding' + ? 'api-runtime' + : 'platform-monitoring', kubernetes: { clusterName: 'prod-eu', context: 'prod-eu-context' }, }), ), @@ -579,7 +589,12 @@ describe('resourceIdentity', () => { it('prefers RFC 1918 private IPv4 over Tailscale and IPv6', () => { const resource = makeResource({ identity: { - ips: ['100.109.215.65', 'fd7a:115c:a1e0::f35:d741', 'fe80::1bf2:7ef4:cd4b:8d71', '192.168.0.2'], + ips: [ + '100.109.215.65', + 'fd7a:115c:a1e0::f35:d741', + 'fe80::1bf2:7ef4:cd4b:8d71', + '192.168.0.2', + ], }, }); expect(getPreferredResourceIP(resource)).toBe('192.168.0.2'); @@ -591,6 +606,21 @@ describe('resourceIdentity', () => { }); expect(getPreferredResourceIP(resource)).toBe('100.64.0.1'); }); + + it('prefers a host interface over Docker bridges, tunnels, and link-local addresses', () => { + const resource = makeResource({ + identity: { ips: ['fe80::1', '172.18.0.1', '192.168.0.113'] }, + agent: { + networkInterfaces: [ + { name: 'docker0', addresses: ['172.18.0.1/16'] }, + { name: 'tailscale0', addresses: ['100.109.215.65/32'] }, + { name: 'eth0', addresses: ['fe80::1/64', '192.168.0.113/24'] }, + ], + }, + }); + + expect(getPreferredResourceIP(resource)).toBe('192.168.0.113'); + }); }); describe('resolveGuestUrlWithIdentity', () => { diff --git a/frontend-modern/src/utils/agentResources.ts b/frontend-modern/src/utils/agentResources.ts index 2b85082616..61cbfcc310 100644 --- a/frontend-modern/src/utils/agentResources.ts +++ b/frontend-modern/src/utils/agentResources.ts @@ -43,6 +43,32 @@ export const hasDockerFacetEvidence = (value: unknown): boolean => { return Boolean(docker && hasMeaningfulFacetValue(docker)); }; +const DOCKER_RUNTIME_IDENTITY_FIELDS = ['runtime', 'runtimeVersion', 'dockerVersion'] as const; +const DOCKER_RUNTIME_INVENTORY_COUNT_FIELDS = [ + 'containerCount', + 'imageCount', + 'volumeCount', + 'networkCount', + 'nodeCount', + 'secretCount', + 'configCount', +] as const; + +export const hasDockerRuntimeHostEvidence = (resource: Resource): boolean => { + if (resource.type === 'docker-host') return true; + if (resource.type !== 'agent') return false; + const platformData = getPlatformDataRecord(resource); + const docker = asRecord(resource.docker) ?? asRecord(platformData?.docker); + if (!docker) return false; + if (DOCKER_RUNTIME_IDENTITY_FIELDS.some((field) => Boolean(asTrimmedString(docker[field])))) { + return true; + } + return DOCKER_RUNTIME_INVENTORY_COUNT_FIELDS.some((field) => { + const value = docker[field]; + return typeof value === 'number' && Number.isFinite(value) && value > 0; + }); +}; + type KubernetesContextLike = { clusterId?: string | null; name?: string | null; @@ -102,8 +128,8 @@ const hasExplicitSourceEvidence = (resource: Resource): boolean => { const platformData = getPlatformDataRecord(resource); return Boolean( resource.sources?.length || - (Array.isArray(platformData?.sources) && platformData.sources.length > 0) || - Object.keys(getSourceStatusRecord(resource) || {}).length > 0, + (Array.isArray(platformData?.sources) && platformData.sources.length > 0) || + Object.keys(getSourceStatusRecord(resource) || {}).length > 0, ); }; @@ -272,9 +298,9 @@ export const hasAgentFacet = (resource: Resource): boolean => { const discoveryTarget = resource.discoveryTarget; return Boolean( resource.agent || - getPlatformAgentRecord(resource) || - getExplicitAgentIdFromResource(resource) || - (isAgentDiscoveryResourceType(discoveryTarget?.resourceType) && discoveryTarget?.agentId), + getPlatformAgentRecord(resource) || + getExplicitAgentIdFromResource(resource) || + (isAgentDiscoveryResourceType(discoveryTarget?.resourceType) && discoveryTarget?.agentId), ); }; diff --git a/frontend-modern/src/utils/discoveryPresentation.ts b/frontend-modern/src/utils/discoveryPresentation.ts index b7a9b569cd..8446aea215 100644 --- a/frontend-modern/src/utils/discoveryPresentation.ts +++ b/frontend-modern/src/utils/discoveryPresentation.ts @@ -1,8 +1,13 @@ -import type { AvailabilityProbeSuggestion, DiscoveryFact, ResourceDiscovery } from '@/types/discovery'; +import type { + AvailabilityProbeSuggestion, + DiscoveryFact, + ResourceDiscovery, +} from '@/types/discovery'; import { getInfrastructureSettingsLocationLabel, getInfrastructureSettingsTarget, } from '@/utils/infrastructureSettingsPresentation'; +import { SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH } from '@/routing/resourceLinks'; export interface DiscoveryIdentifiedSummary { serviceName: string; @@ -113,14 +118,14 @@ export function hasMeaningfulDiscoveryContext( return Boolean( isMeaningfulDiscoveryText(discovery.service_name) || - isMeaningfulDiscoveryText(discovery.service_type) || - isMeaningfulDiscoveryText(discovery.service_version) || - isMeaningfulDiscoveryText(discovery.category) || - portCount > 0 || - hasFacts || - hasPaths || - hasSuggestedUrl || - hasSuggestedProbe, + isMeaningfulDiscoveryText(discovery.service_type) || + isMeaningfulDiscoveryText(discovery.service_version) || + isMeaningfulDiscoveryText(discovery.category) || + portCount > 0 || + hasFacts || + hasPaths || + hasSuggestedUrl || + hasSuggestedProbe, ); } @@ -315,6 +320,13 @@ export function getDiscoveryApiAccessSettingsTarget() { } as const; } +export function getDiscoveryServiceContextSettingsTarget() { + return { + href: SETTINGS_PULSE_INTELLIGENCE_DISCOVERY_PATH, + label: 'Open Service Context settings', + } as const; +} + export function getDiscoveryNoConnectedAgentMessage(commandsEnabled?: boolean): string { const infrastructureSettings = getInfrastructureSettingsLocationLabel(); diff --git a/frontend-modern/src/utils/resourceIdentity.ts b/frontend-modern/src/utils/resourceIdentity.ts index e7de713e61..3472d81a75 100644 --- a/frontend-modern/src/utils/resourceIdentity.ts +++ b/frontend-modern/src/utils/resourceIdentity.ts @@ -365,23 +365,94 @@ export const getPreferredInfrastructureDisplayName = (resource: Resource): strin const IPV4_PATTERN = /^\d{1,3}(\.\d{1,3}){3}$/; -const isRfc1918Private = (ip: string): boolean => { +const normalizeIPAddress = (value: string): string => { + const trimmed = value.trim().replace(/^\[|\]$/g, ''); + if (!trimmed) return ''; + const withoutPrefix = trimmed.split('/', 1)[0] ?? ''; + return withoutPrefix.split('%', 1)[0] ?? ''; +}; + +const isValidIPv4 = (ip: string): boolean => { if (!IPV4_PATTERN.test(ip)) return false; + return ip.split('.').every((part) => { + const value = Number(part); + return Number.isInteger(value) && value >= 0 && value <= 255; + }); +}; + +const isRfc1918Private = (ip: string): boolean => { + if (!isValidIPv4(ip)) return false; const [a, b] = ip.split('.').map(Number); return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); }; +const isCarrierGradeNat = (ip: string): boolean => { + if (!isValidIPv4(ip)) return false; + const [a, b] = ip.split('.').map(Number); + return a === 100 && b >= 64 && b <= 127; +}; + +const isUnusableIPAddress = (ip: string): boolean => { + const normalized = ip.toLowerCase(); + if (isValidIPv4(ip)) { + const [a, b] = ip.split('.').map(Number); + return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254); + } + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fe80:') || + normalized.startsWith('ff') + ); +}; + +const isVirtualNetworkInterface = (name: string): boolean => + /^(?:lo|docker\d*|br-[0-9a-f]+|virbr\d*|veth|cni|flannel|cali|podman|tailscale|zt|wg|tun|tap|utun)/i.test( + name.trim(), + ); + +const addressPreference = (ip: string): number => { + if (isUnusableIPAddress(ip)) return 100; + if (isRfc1918Private(ip)) return 0; + if (isValidIPv4(ip)) return isCarrierGradeNat(ip) ? 30 : 10; + return ip.includes(':') ? 20 : 90; +}; + +type AddressCandidate = { + ip: string; + score: number; + order: number; +}; + /** - * Pick the best LAN IPv4 from a resource's identity, preferring RFC 1918 - * private addresses over Tailscale (100.64/10) or IPv6. + * Pick the address that most likely represents the machine on its operator-facing + * network. Physical-interface addresses outrank container bridges and tunnels; + * routable IPv4 outranks link-local and IPv6 fallback addresses. */ export const getPreferredResourceIP = (resource: Resource): string | undefined => { - const ips = resource.identity?.ips ?? []; - if (ips.length === 0) return undefined; - const lanIp = ips.find((ip) => isRfc1918Private(ip)); - if (lanIp) return lanIp; - const ipv4 = ips.find((ip) => IPV4_PATTERN.test(ip)); - return ipv4; + const candidates: AddressCandidate[] = []; + let order = 0; + for (const networkInterface of resource.agent?.networkInterfaces ?? []) { + const interfacePenalty = isVirtualNetworkInterface(networkInterface.name) ? 50 : 0; + for (const value of networkInterface.addresses ?? []) { + const ip = normalizeIPAddress(value); + if (!ip) continue; + candidates.push({ ip, score: addressPreference(ip) + interfacePenalty, order: order++ }); + } + } + for (const value of resource.identity?.ips ?? []) { + const ip = normalizeIPAddress(value); + if (!ip) continue; + candidates.push({ ip, score: addressPreference(ip) + 25, order: order++ }); + } + + return candidates + .filter( + (candidate, index) => + candidates.findIndex((other) => other.ip.toLowerCase() === candidate.ip.toLowerCase()) === + index, + ) + .sort((left, right) => left.score - right.score || left.order - right.order)[0]?.ip; }; /** diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index e511a40fe1..4eb2d15721 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -155,8 +155,7 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { `git push origin "refs/tags/${TAG}" --force`, `-F target_commitish="${HEAD_SHA}"`, `historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}`, - `if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}`, - `candidate_manifest_artifact: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}`, + `if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}`, `if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}`, `permissions:`, `issues: write`, @@ -719,11 +718,7 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { if err != nil { t.Fatalf("read create-release.yml: %v", err) } - candidateWorkflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml")) - if err != nil { - t.Fatalf("read build-release-candidate.yml: %v", err) - } - createRelease := string(createReleaseBytes) + "\n" + string(candidateWorkflowBytes) + createRelease := string(createReleaseBytes) createReleaseRequired := []string{ `provenance: mode=max`, `sbom: true`, @@ -1499,77 +1494,6 @@ func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) { } } -func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { - createBytes, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) - if err != nil { - t.Fatalf("read create-release.yml: %v", err) - } - candidateBytes, err := os.ReadFile(repoFile(".github", "workflows", "build-release-candidate.yml")) - if err != nil { - t.Fatalf("read build-release-candidate.yml: %v", err) - } - validationBytes, err := os.ReadFile(repoFile(".github", "workflows", "validate-release-assets.yml")) - if err != nil { - t.Fatalf("read validate-release-assets.yml: %v", err) - } - - createWorkflow := string(createBytes) - candidateWorkflow := string(candidateBytes) - validationWorkflow := string(validationBytes) - createJob := workflowJobBlock(t, createWorkflow, "create_release") - backendJob := workflowJobBlock(t, createWorkflow, "backend_tests") - integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests") - validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets") - privateJob := workflowJobBlock(t, createWorkflow, "publish_private_pro_runtime") - - for _, needle := range []string{ - `./scripts/build-release.sh "${{ inputs.version }}"`, - `scripts/validate-release.sh "${{ inputs.version }}" --skip-docker`, - `scripts/release_candidate_manifest.py create`, - `compression-level: 0`, - `retention-days: 1`, - } { - if !strings.Contains(candidateWorkflow, needle) { - t.Fatalf("build-release-candidate.yml missing single-build contract: %s", needle) - } - } - - for _, needle := range []string{ - `Download immutable release candidate`, - `scripts/release_candidate_manifest.py verify-local`, - `needs.build_release_candidate.outputs.artifact_name`, - } { - if !strings.Contains(createJob, needle) { - t.Fatalf("create_release missing candidate promotion contract: %s", needle) - } - } - if strings.Contains(createJob, "scripts/build-release.sh") { - t.Fatal("create_release must promote the verified candidate instead of rebuilding release assets") - } - - if !strings.Contains(backendJob, "- frontend_checks") || !strings.Contains(integrationJob, "- frontend_checks") { - t.Fatal("backend and integration jobs must consume the shared verified frontend bundle") - } - if strings.Contains(integrationJob, "- backend_tests") { - t.Fatal("integration tests must run in parallel with backend tests") - } - if strings.Contains(validationJob, "- publish_docker") { - t.Fatal("release asset digest validation must run in parallel with Docker publication") - } - if !strings.Contains(privateJob, "- create_release") || strings.Contains(privateJob, "- validate_release_assets") { - t.Fatal("private Pro publication must start after release creation without waiting for asset validation") - } - for _, needle := range []string{ - `inputs.candidate_manifest_artifact != ''`, - `scripts/release_candidate_manifest.py verify-release`, - `inputs.candidate_manifest_artifact == ''`, - } { - if !strings.Contains(validationWorkflow, needle) { - t.Fatalf("validate-release-assets.yml missing fast digest contract: %s", needle) - } - } -} - func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) { content, err := os.ReadFile(repoFile(".github", "workflows", "create-release.yml")) if err != nil { @@ -1579,7 +1503,7 @@ func TestCreateReleasePublishesPrivateProRuntime(t *testing.T) { job := workflowJobBlock(t, workflow, "publish_private_pro_runtime") for _, needle := range []string{ - `needs.create_release.result == 'success'`, + `needs.validate_release_assets.result == 'success'`, `github.event.inputs.draft_only != 'true'`, `startsWith(needs.prepare.outputs.version, '6.')`, `GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}`, diff --git a/scripts/release_candidate_manifest.py b/scripts/release_candidate_manifest.py deleted file mode 100644 index 47153a0bf6..0000000000 --- a/scripts/release_candidate_manifest.py +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env python3 -"""Create and verify immutable Pulse release-candidate manifests.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import sys -from pathlib import Path -from typing import Any - - -SCHEMA_VERSION = 1 -VERSION_PATTERN = re.compile( - r"^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|alpha|beta)\.[0-9]+)?$" -) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def collect_assets(release_dir: Path) -> list[dict[str, Any]]: - if not release_dir.is_dir(): - raise ValueError(f"release directory does not exist: {release_dir}") - - assets: list[dict[str, Any]] = [] - for path in sorted(release_dir.rglob("*")): - if path.is_symlink(): - raise ValueError(f"release candidate must not contain symlinks: {path}") - if not path.is_file(): - continue - relative = path.relative_to(release_dir).as_posix() - assets.append( - { - "name": relative, - "size": path.stat().st_size, - "sha256": sha256_file(path), - } - ) - - if not assets: - raise ValueError(f"release candidate is empty: {release_dir}") - return assets - - -def validate_version(version: str) -> None: - if not VERSION_PATTERN.fullmatch(version): - raise ValueError(f"invalid release version: {version!r}") - - -def create_manifest(release_dir: Path, version: str, source_sha: str) -> dict[str, Any]: - validate_version(version) - if not re.fullmatch(r"[0-9a-f]{40}", source_sha): - raise ValueError("source SHA must be a full lowercase Git commit SHA") - return { - "schema_version": SCHEMA_VERSION, - "version": version, - "tag": f"v{version}", - "source_sha": source_sha, - "assets": collect_assets(release_dir), - } - - -def load_manifest(path: Path) -> dict[str, Any]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ValueError(f"cannot read release candidate manifest {path}: {exc}") from exc - if not isinstance(payload, dict): - raise ValueError("release candidate manifest must be a JSON object") - if payload.get("schema_version") != SCHEMA_VERSION: - raise ValueError( - f"unsupported release candidate manifest schema: {payload.get('schema_version')!r}" - ) - if not isinstance(payload.get("assets"), list) or not payload["assets"]: - raise ValueError("release candidate manifest must contain assets") - return payload - - -def manifest_assets_by_name(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: - result: dict[str, dict[str, Any]] = {} - for index, asset in enumerate(manifest["assets"]): - if not isinstance(asset, dict): - raise ValueError(f"manifest asset {index} must be an object") - name = asset.get("name") - size = asset.get("size") - digest = asset.get("sha256") - if not isinstance(name, str) or not name or Path(name).name != name: - raise ValueError(f"manifest asset {index} has invalid name: {name!r}") - if not isinstance(size, int) or size < 0: - raise ValueError(f"manifest asset {name!r} has invalid size: {size!r}") - if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): - raise ValueError(f"manifest asset {name!r} has invalid SHA-256 digest") - if name in result: - raise ValueError(f"manifest contains duplicate asset: {name}") - result[name] = asset - return result - - -def verify_manifest_identity( - manifest: dict[str, Any], expected_version: str, expected_source_sha: str -) -> None: - if manifest.get("version") != expected_version: - raise ValueError( - f"candidate version {manifest.get('version')!r} does not match {expected_version!r}" - ) - if manifest.get("tag") != f"v{expected_version}": - raise ValueError(f"candidate tag does not match v{expected_version}") - if manifest.get("source_sha") != expected_source_sha: - raise ValueError( - f"candidate source SHA {manifest.get('source_sha')!r} does not match " - f"{expected_source_sha!r}" - ) - - -def verify_local( - release_dir: Path, - manifest: dict[str, Any], - expected_version: str, - expected_source_sha: str, -) -> None: - verify_manifest_identity(manifest, expected_version, expected_source_sha) - - expected = manifest_assets_by_name(manifest) - actual = {asset["name"]: asset for asset in collect_assets(release_dir)} - if set(actual) != set(expected): - missing = sorted(set(expected) - set(actual)) - extra = sorted(set(actual) - set(expected)) - raise ValueError(f"candidate asset set mismatch: missing={missing}, extra={extra}") - for name, expected_asset in expected.items(): - actual_asset = actual[name] - if actual_asset["size"] != expected_asset["size"]: - raise ValueError(f"candidate asset size mismatch: {name}") - if actual_asset["sha256"] != expected_asset["sha256"]: - raise ValueError(f"candidate asset digest mismatch: {name}") - - -def load_release_assets(path: Path) -> list[dict[str, Any]]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ValueError(f"cannot read release asset metadata {path}: {exc}") from exc - if not isinstance(payload, list): - raise ValueError("release asset metadata must be a JSON array") - if payload and all(isinstance(item, list) for item in payload): - payload = [asset for page in payload for asset in page] - if not all(isinstance(item, dict) for item in payload): - raise ValueError("release asset metadata contains a non-object entry") - return payload - - -def verify_release(manifest: dict[str, Any], release_assets: list[dict[str, Any]]) -> None: - expected = manifest_assets_by_name(manifest) - actual: dict[str, dict[str, Any]] = {} - for index, asset in enumerate(release_assets): - name = asset.get("name") - if not isinstance(name, str) or not name: - raise ValueError(f"release asset {index} has no valid name") - if name in actual: - raise ValueError(f"release contains duplicate asset: {name}") - actual[name] = asset - - if set(actual) != set(expected): - missing = sorted(set(expected) - set(actual)) - extra = sorted(set(actual) - set(expected)) - raise ValueError(f"published asset set mismatch: missing={missing}, extra={extra}") - - for name, expected_asset in expected.items(): - actual_asset = actual[name] - if actual_asset.get("size") != expected_asset["size"]: - raise ValueError(f"published asset size mismatch: {name}") - expected_digest = f"sha256:{expected_asset['sha256']}" - if actual_asset.get("digest") != expected_digest: - raise ValueError( - f"published asset digest mismatch: {name}; " - f"expected {expected_digest}, got {actual_asset.get('digest')!r}" - ) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - create = subparsers.add_parser("create") - create.add_argument("--release-dir", type=Path, required=True) - create.add_argument("--version", required=True) - create.add_argument("--source-sha", required=True) - create.add_argument("--output", type=Path, required=True) - - local = subparsers.add_parser("verify-local") - local.add_argument("--release-dir", type=Path, required=True) - local.add_argument("--manifest", type=Path, required=True) - local.add_argument("--version", required=True) - local.add_argument("--source-sha", required=True) - - release = subparsers.add_parser("verify-release") - release.add_argument("--manifest", type=Path, required=True) - release.add_argument("--assets-json", type=Path, required=True) - release.add_argument("--version", required=True) - release.add_argument("--source-sha", required=True) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - try: - if args.command == "create": - manifest = create_manifest(args.release_dir, args.version, args.source_sha) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text( - json.dumps(manifest, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - print( - f"Release candidate manifest created: assets={len(manifest['assets'])} " - f"version={args.version} source_sha={args.source_sha}" - ) - elif args.command == "verify-local": - manifest = load_manifest(args.manifest) - verify_local(args.release_dir, manifest, args.version, args.source_sha) - print( - f"Release candidate verified locally: assets={len(manifest['assets'])} " - f"version={args.version} source_sha={args.source_sha}" - ) - else: - manifest = load_manifest(args.manifest) - verify_manifest_identity(manifest, args.version, args.source_sha) - release_assets = load_release_assets(args.assets_json) - verify_release(manifest, release_assets) - print( - f"Published release matches candidate: assets={len(manifest['assets'])} " - f"version={manifest['version']} source_sha={manifest['source_sha']}" - ) - except (OSError, ValueError) as exc: - print(f"release candidate verification failed: {exc}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/release_control/release_candidate_manifest_test.py b/scripts/release_control/release_candidate_manifest_test.py deleted file mode 100644 index a8ff0128e7..0000000000 --- a/scripts/release_control/release_candidate_manifest_test.py +++ /dev/null @@ -1,81 +0,0 @@ -import json -import sys -import tempfile -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from release_candidate_manifest import ( - create_manifest, - load_release_assets, - verify_local, - verify_release, -) - - -SOURCE_SHA = "1" * 40 - - -class ReleaseCandidateManifestTest(unittest.TestCase): - def create_release_dir(self, root: Path) -> Path: - release_dir = root / "release" - release_dir.mkdir() - (release_dir / "checksums.txt").write_text("abc\n", encoding="utf-8") - (release_dir / "pulse-v6.1.0-linux-amd64.tar.gz").write_bytes(b"archive") - return release_dir - - def test_create_and_verify_local_candidate(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - release_dir = self.create_release_dir(Path(temp_dir)) - manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA) - - self.assertEqual(manifest["tag"], "v6.1.0") - self.assertEqual( - [asset["name"] for asset in manifest["assets"]], - ["checksums.txt", "pulse-v6.1.0-linux-amd64.tar.gz"], - ) - verify_local(release_dir, manifest, "6.1.0", SOURCE_SHA) - - def test_verify_local_rejects_tampered_asset(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - release_dir = self.create_release_dir(Path(temp_dir)) - manifest = create_manifest(release_dir, "6.1.0-rc.1", SOURCE_SHA) - (release_dir / "checksums.txt").write_text("xyz\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "digest mismatch"): - verify_local(release_dir, manifest, "6.1.0-rc.1", SOURCE_SHA) - - def test_verify_release_uses_server_side_digests(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - release_dir = self.create_release_dir(Path(temp_dir)) - manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA) - release_assets = [ - { - "name": asset["name"], - "size": asset["size"], - "digest": f"sha256:{asset['sha256']}", - } - for asset in manifest["assets"] - ] - - verify_release(manifest, release_assets) - release_assets[0]["digest"] = "sha256:" + "0" * 64 - with self.assertRaisesRegex(ValueError, "published asset digest mismatch"): - verify_release(manifest, release_assets) - - def test_release_metadata_loader_flattens_paginated_arrays(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - path = Path(temp_dir) / "assets.json" - path.write_text( - json.dumps([[{"name": "a"}], [{"name": "b"}]]), - encoding="utf-8", - ) - self.assertEqual( - [asset["name"] for asset in load_release_assets(path)], - ["a", "b"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index b465cb2e94..f626a61b81 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -529,7 +529,6 @@ class ReleasePromotionPolicyTest(unittest.TestCase): demo_ssh_helper = read(".github/scripts/setup-demo-ssh.sh") demo_reachability_helper = read(".github/scripts/check-demo-reachability.sh") validation_workflow = read(".github/workflows/validate-release-assets.yml") - candidate_workflow = read(".github/workflows/build-release-candidate.yml") helper = read("scripts/trigger-release.sh") renderer = read("scripts/release_control/render_release_body.py") policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md") @@ -587,11 +586,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('-F target_commitish="${HEAD_SHA}"', content) self.assertIn('historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}', content) self.assertIn( - "if: ${{ always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}", + "if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' && (github.event.inputs.draft_only == 'true' || needs.publish_docker.result == 'success') }}", content, ) - self.assertIn("candidate_manifest_artifact:", validation_workflow) - self.assertIn("release_candidate_manifest.py verify-release", validation_workflow) self.assertIn("if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}", content) self.assertIn("issues: write", content) self.assertIn("statuses: write", content) @@ -614,9 +611,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) - self.assertIn("Validate installer signing key pins", candidate_workflow) - self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", candidate_workflow) - self.assertIn("does not trust the configured release signing key", candidate_workflow) + self.assertIn("Validate installer signing key pins", content) + self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", content) + self.assertIn("does not trust the configured release signing key", content) self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow) self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow) for demo_workflow in (update_demo_workflow, deploy_demo_workflow): @@ -863,10 +860,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Public demo is serving $PUBLIC_ASSET but the target service is serving $REMOTE_ASSET.", demo) self.assertIn("uses: ./.github/workflows/publish-docker.yml", release_workflow) self.assertIn("uses: ./.github/workflows/update-demo-server.yml", release_workflow) - self.assertIn("uses: ./.github/workflows/build-release-candidate.yml", release_workflow) - self.assertIn("Build Immutable Release Candidate", release_workflow) self.assertIn("Definitive Release Verdict", release_workflow) - self.assertNotIn("Require recent exact-SHA stable patch preflight", release_workflow) + self.assertIn("Require recent exact-SHA stable patch preflight", release_workflow) + self.assertIn("Release Dry Run v${VERSION}", release_workflow) self.assertNotIn("gh workflow run update-demo-server.yml", release_workflow) self.assertNotIn("gh workflow run publish-docker.yml", release_workflow) self.assertNotIn("preview-v6", preview_deploy) @@ -1016,7 +1012,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): " BLESS_GOVERNANCE_FIXTURES=1 python3 -m unittest release_promotion_policy_test" ) - def test_routine_stable_patch_entrypoint_is_noninteractive_and_integrated(self) -> None: + def test_routine_stable_patch_entrypoint_is_noninteractive_and_preflight_gated(self) -> None: helper = read("scripts/trigger-stable-patch.sh") policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md") contract = read("docs/release-control/v6/internal/subsystems/deployment-installability.md") @@ -1026,14 +1022,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("--dry-run", helper) self.assertIn("--derive-rollback-latest-stable", helper) self.assertIn("docs/releases/RELEASE_NOTES_v${VERSION}.md", helper) - self.assertIn("Use --dry-run only", helper) - self.assertNotIn("timedelta(hours=24)", helper) - self.assertNotIn(".createdAt >= $cutoff", helper) + self.assertIn("Release Dry Run v${VERSION}", helper) + self.assertIn("timedelta(hours=24)", helper) + self.assertIn(".createdAt >= $cutoff", helper) self.assertIn("gh workflow run create-release.yml", helper) self.assertIn("gh workflow run \"$WORKFLOW\"", helper) - self.assertIn("Single-Build Release Path", policy) self.assertIn("Routine Stable Patch Path", policy) - self.assertIn("single publish workflow performs the exact-SHA preflight", normalize_ws(policy)) + self.assertIn("exact candidate SHA within the previous 24 hours", normalize_ws(policy)) self.assertIn("An asynchronous dispatch or manual SSH deployment is not release completion.", normalize_ws(contract)) diff --git a/scripts/trigger-stable-patch.sh b/scripts/trigger-stable-patch.sh index e3ef0271ab..fd3943a459 100755 --- a/scripts/trigger-stable-patch.sh +++ b/scripts/trigger-stable-patch.sh @@ -11,9 +11,9 @@ usage() { cat <<'EOF' Usage: scripts/trigger-stable-patch.sh [--dry-run] [options] [version] -Dispatches exactly one governed workflow. The default release workflow builds -and validates an immutable candidate before publication. Use --dry-run only -when a no-public-release rehearsal is required. +Runs the noninteractive stable patch preflight and dispatches exactly one +governed workflow. Run once with --dry-run, wait for that workflow to pass, +then run again without --dry-run to publish. Options: --dry-run Dispatch Release Dry Run only. @@ -167,6 +167,27 @@ if [ "$MODE" = "dry-run" ]; then -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" else + PREFLIGHT_CUTOFF="$(python3 - <<'PY' +from datetime import datetime, timedelta, timezone + +cutoff = datetime.now(timezone.utc) - timedelta(hours=24) +print(cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")) +PY +)" + PREFLIGHT_RUN="$(gh run list \ + --workflow=release-dry-run.yml \ + --branch "$CURRENT_BRANCH" \ + --event workflow_dispatch \ + --limit 30 \ + --json displayTitle,headSha,conclusion,url,createdAt \ + | jq -c --arg sha "$LOCAL_SHA" --arg title "Release Dry Run v${VERSION}" --arg cutoff "$PREFLIGHT_CUTOFF" \ + '[.[] | select(.headSha == $sha and .displayTitle == $title and .conclusion == "success" and .createdAt >= $cutoff)] | sort_by(.createdAt) | last // empty')" + if [ -z "$PREFLIGHT_RUN" ]; then + echo "No successful exact-SHA Release Dry Run from the last 24 hours exists for v${VERSION}." >&2 + echo "Run $0 --dry-run ${VERSION}, wait for success, then rerun this command." >&2 + exit 1 + fi + python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \ @@ -196,6 +217,7 @@ else -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" + echo "Accepted preflight: $(jq -r '.url' <<<"$PREFLIGHT_RUN")" fi echo "Dispatched ${WORKFLOW:-create-release.yml} for v${VERSION} at ${LOCAL_SHA}."