mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Improve platform frontend coherence
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}" \
|
||||
|
||||
@@ -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 <version>`.
|
||||
2. Dispatch `./scripts/trigger-stable-patch.sh --dry-run <version>` 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
-78
@@ -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.
|
||||
+5
-10
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+14
-13
@@ -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}
|
||||
>
|
||||
<RouteErrorBoundary>
|
||||
{props.children}
|
||||
</RouteErrorBoundary>
|
||||
<RouteErrorBoundary>{props.children}</RouteErrorBoundary>
|
||||
</AppLayout>
|
||||
</div>
|
||||
{/* AI Panel - slides in from right, pushes content.
|
||||
|
||||
@@ -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<PrimaryPlatformNavId, string> = {
|
||||
proxmox: ROOT_PROXMOX_PATH,
|
||||
|
||||
@@ -144,9 +144,9 @@ describe('App architecture', () => {
|
||||
expect(appSource).toContain('<Route path="/" component={RuntimeHomePage} />');
|
||||
expect(appSource).toContain('<Route path="/infrastructure" component={RuntimeHomePage} />');
|
||||
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(
|
||||
'<Route path={`${ROOT_PATROL_PATH}/*`} component={AIIntelligencePage} />',
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<DiscoveryTabProps> = (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<DiscoveryTabProps> = (props) => {
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
<p class="mb-1 font-medium">Service Context Disabled</p>
|
||||
<p class="text-amber-700 dark:text-amber-300">
|
||||
Enable service context in Settings -> Pulse Intelligence -> Service Context
|
||||
before using this tab.
|
||||
Enable service context to inspect what is running on this resource.
|
||||
</p>
|
||||
<ButtonLink
|
||||
href={serviceContextSettingsTarget.href}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
>
|
||||
{serviceContextSettingsTarget.label}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,18 @@ vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/Button', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/components/shared/Button')>();
|
||||
return {
|
||||
...actual,
|
||||
ButtonLink: (props: { href: string; children: unknown; class?: string }) => (
|
||||
<a href={props.href} class={props.class}>
|
||||
{props.children as never}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/ai', () => ({
|
||||
AIAPI: {
|
||||
getSettings: vi.fn(async () => ({ discovery_enabled: true })),
|
||||
|
||||
@@ -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<ResourceDetailDrawerOver
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={compactTableRow()}
|
||||
when={isPulseAgentPlatformResource(resource)}
|
||||
fallback={
|
||||
<ResourceSummaryCards
|
||||
resource={resource}
|
||||
drawer={drawer}
|
||||
showPlatformId={showPlatformId}
|
||||
/>
|
||||
<Show
|
||||
when={compactTableRow()}
|
||||
fallback={
|
||||
<ResourceSummaryCards
|
||||
resource={resource}
|
||||
drawer={drawer}
|
||||
showPlatformId={showPlatformId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InlineResourceSummaryTables
|
||||
resource={resource}
|
||||
drawer={drawer}
|
||||
showPlatformId={showPlatformId}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<InlineResourceSummaryTables
|
||||
resource={resource}
|
||||
drawer={drawer}
|
||||
showPlatformId={showPlatformId}
|
||||
/>
|
||||
<details
|
||||
data-testid="resource-technical-details"
|
||||
class="rounded border border-border bg-surface px-3 py-2"
|
||||
>
|
||||
<summary class="cursor-pointer list-none text-[11px] font-medium text-base-content">
|
||||
Technical details
|
||||
<span class="ml-2 font-normal text-muted">State, identity, and source IDs</span>
|
||||
</summary>
|
||||
<div class="mt-3 border-t border-border pt-3">
|
||||
<Show
|
||||
when={compactTableRow()}
|
||||
fallback={
|
||||
<ResourceSummaryCards
|
||||
resource={resource}
|
||||
drawer={drawer}
|
||||
showPlatformId={showPlatformId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InlineResourceSummaryTables
|
||||
resource={resource}
|
||||
drawer={drawer}
|
||||
showPlatformId={showPlatformId}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
|
||||
<div data-testid="resource-secondary-sections" class="space-y-3">
|
||||
|
||||
+3
-6
@@ -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', () => {
|
||||
|
||||
+8
-5
@@ -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(() => <ResourceDetailDrawer resource={inlineResource} />);
|
||||
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(() => <ResourceDetailDrawer resource={overflowResource} />);
|
||||
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', () => {
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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() {
|
||||
<PlatformTableEmptyState
|
||||
icon={dockerIcon()}
|
||||
title="No Docker or Podman hosts"
|
||||
description="Install the Pulse agent on a Docker or Podman host. For Docker inside Proxmox LXCs, install the agent on the Proxmox node with command execution enabled, then enable Proxmox guest Docker inventory on the Pulse server."
|
||||
description="Connect a runtime host with the Pulse agent, or enable guest Docker inventory for a Proxmox LXC."
|
||||
actions={
|
||||
<ButtonLink
|
||||
href={buildInfrastructureOnboardingPath('docker')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="gap-2"
|
||||
>
|
||||
<BoxIcon class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Set up Docker or Podman
|
||||
</ButtonLink>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -78,6 +78,11 @@ vi.mock('@/stores/updates', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@solidjs/router', () => ({
|
||||
A: (props: { href: string; children: unknown; class?: string }) => (
|
||||
<a href={props.href} class={props.class}>
|
||||
{props.children as never}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({
|
||||
get pathname() {
|
||||
return mocks.pathname;
|
||||
@@ -118,9 +123,10 @@ vi.mock('@/features/platformPage/sharedPlatformPage', async () => {
|
||||
data-tabs={props.tabs.map((tab) => tab.id).join(',')}
|
||||
/>
|
||||
),
|
||||
PlatformTableEmptyState: (props: { title: string; description: string }) => (
|
||||
PlatformTableEmptyState: (props: { title: string; description: string; actions?: unknown }) => (
|
||||
<div data-testid="platform-table-empty-state" data-title={props.title}>
|
||||
{props.description}
|
||||
{props.actions as never}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -230,7 +236,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('DockerPageSurface', () => {
|
||||
it('explains the direct host and Proxmox LXC Docker install paths when empty', () => {
|
||||
it('offers direct Docker or Podman onboarding when empty', () => {
|
||||
mocks.useUnifiedResources.mockReturnValue({
|
||||
error: () => null,
|
||||
loading: () => false,
|
||||
@@ -242,10 +248,9 @@ describe('DockerPageSurface', () => {
|
||||
|
||||
const emptyState = screen.getByTestId('platform-table-empty-state');
|
||||
expect(emptyState).toHaveAttribute('data-title', 'No Docker or Podman hosts');
|
||||
expect(emptyState).toHaveTextContent('Install the Pulse agent on a Docker or Podman host');
|
||||
expect(emptyState).toHaveTextContent('Docker inside Proxmox LXCs');
|
||||
expect(emptyState).toHaveTextContent('command execution enabled');
|
||||
expect(emptyState).toHaveTextContent('Proxmox guest Docker inventory');
|
||||
expect(emptyState).toHaveTextContent('Connect a runtime host with the Pulse agent');
|
||||
expect(emptyState).toHaveTextContent('enable guest Docker inventory for a Proxmox LXC');
|
||||
expect(emptyState).toHaveTextContent('Set up Docker or Podman');
|
||||
});
|
||||
|
||||
it('keeps overview focused on runtime hosts plus primary container workloads', () => {
|
||||
|
||||
@@ -87,6 +87,21 @@ describe('dockerPageModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('admits an agent-scoped runtime host when its Docker facet has runtime evidence', () => {
|
||||
const model = buildDockerPageModel([
|
||||
makeResource({
|
||||
id: 'tower',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
platformScopes: ['agent'],
|
||||
platformData: { docker: { runtime: 'docker' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(model.hosts.map((resource) => resource.id)).toEqual(['tower']);
|
||||
expect(model.resources.map((resource) => resource.id)).toEqual(['tower']);
|
||||
});
|
||||
|
||||
it('declares operator workflow tabs for Docker runtime inventory', () => {
|
||||
expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([
|
||||
'overview',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getInfrastructureSystemIdentityBadges,
|
||||
type ResourceBadge,
|
||||
} from '@/utils/resourceBadgePresentation';
|
||||
import { hasDockerRuntimeHostEvidence } from '@/utils/agentResources';
|
||||
|
||||
const DOCKER_HOST_TYPES = new Set<ResourceType>(['agent', 'docker-host']);
|
||||
const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']);
|
||||
@@ -65,6 +66,10 @@ export const resolveDockerPageTabId = (segment: string | undefined): DockerPageT
|
||||
const isDockerPlatform = (resource: Resource): boolean =>
|
||||
resolveResourcePlatformType(resource) === 'docker';
|
||||
|
||||
const isDockerRuntimeHost = (resource: Resource): boolean =>
|
||||
DOCKER_HOST_TYPES.has(resource.type) &&
|
||||
(isDockerPlatform(resource) || hasDockerRuntimeHostEvidence(resource));
|
||||
|
||||
// `containerState` reasons that mean the container will not stay up on its
|
||||
// own. Distinct from `exited` with exit code 0, which is just a stopped
|
||||
// container, and from `restarting`, which is in-flight.
|
||||
@@ -799,6 +804,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
|
||||
const dockerResources = resources.filter(
|
||||
(resource) =>
|
||||
isDockerPlatform(resource) ||
|
||||
isDockerRuntimeHost(resource) ||
|
||||
DOCKER_CONTAINER_TYPES.has(resource.type) ||
|
||||
DOCKER_SERVICE_TYPES.has(resource.type) ||
|
||||
DOCKER_IMAGE_TYPES.has(resource.type) ||
|
||||
@@ -810,9 +816,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
|
||||
DOCKER_TASK_TYPES.has(resource.type),
|
||||
);
|
||||
|
||||
const hosts = dockerResources.filter(
|
||||
(resource) => DOCKER_HOST_TYPES.has(resource.type) && isDockerPlatform(resource),
|
||||
);
|
||||
const hosts = dockerResources.filter(isDockerRuntimeHost);
|
||||
const containers = dockerResources
|
||||
.filter((resource) => DOCKER_CONTAINER_TYPES.has(resource.type) && isDockerPlatform(resource))
|
||||
.sort(compareDockerContainers);
|
||||
|
||||
+11
@@ -94,6 +94,17 @@ describe('platformNavigationModel', () => {
|
||||
expect(visibility.standalone).toBe(true);
|
||||
});
|
||||
|
||||
it('does not admit Docker from generic host metadata without runtime identity', () => {
|
||||
const tower = resource({
|
||||
id: 'tower',
|
||||
platformType: 'agent',
|
||||
type: 'agent',
|
||||
platformData: { docker: { hostname: 'tower', os: 'linux', containerCount: 0 } },
|
||||
});
|
||||
|
||||
expect(buildPrimaryPlatformNavigationVisibility([tower]).docker).toBe(false);
|
||||
});
|
||||
|
||||
it('does not infer Docker from TrueNAS-scoped app metadata', () => {
|
||||
const truenasApp = resource({
|
||||
id: 'truenas-app',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Accessor } from 'solid-js';
|
||||
import type { Resource, ResourceType } from '@/types/resource';
|
||||
import { hasDockerFacetEvidence, isPulseAgentPlatformResource } from '@/utils/agentResources';
|
||||
import { hasDockerRuntimeHostEvidence, isPulseAgentPlatformResource } from '@/utils/agentResources';
|
||||
import {
|
||||
ADMITTED_PLATFORM_IDS,
|
||||
SUPPORTED_PLATFORM_IDS,
|
||||
@@ -91,14 +91,8 @@ const collectExplicitPlatformScopeEvidence = (
|
||||
return [...ids];
|
||||
};
|
||||
|
||||
const addDirectDockerRuntimeEvidence = (
|
||||
ids: Set<string>,
|
||||
resource: Resource,
|
||||
platformData: Record<string, unknown> | null,
|
||||
): void => {
|
||||
const addDirectDockerRuntimeEvidence = (ids: Set<string>, resource: Resource): void => {
|
||||
const resolvedPlatformType = resolveResourcePlatformType(resource);
|
||||
const hasDockerMetadata =
|
||||
hasDockerFacetEvidence(resource.docker) || hasDockerFacetEvidence(platformData?.docker);
|
||||
if (normalizeSourcePlatformKey(resource.platformType) === 'docker') {
|
||||
ids.add('docker');
|
||||
return;
|
||||
@@ -111,7 +105,7 @@ const addDirectDockerRuntimeEvidence = (
|
||||
ids.add('docker');
|
||||
return;
|
||||
}
|
||||
if (ids.has('agent') && hasDockerMetadata) {
|
||||
if (ids.has('agent') && hasDockerRuntimeHostEvidence(resource)) {
|
||||
ids.add('docker');
|
||||
}
|
||||
};
|
||||
@@ -120,7 +114,7 @@ export function collectResourcePlatformEvidence(resource: Resource): string[] {
|
||||
const platformData = asRecord(resource.platformData);
|
||||
const ids = new Set<string>(collectExplicitPlatformScopeEvidence(resource, platformData));
|
||||
if (ids.size > 0) {
|
||||
addDirectDockerRuntimeEvidence(ids, resource, platformData);
|
||||
addDirectDockerRuntimeEvidence(ids, resource);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
@@ -148,11 +142,7 @@ export function collectResourcePlatformEvidence(resource: Resource): string[] {
|
||||
if (resource.kubernetes || asRecord(platformData?.kubernetes)) {
|
||||
ids.add('kubernetes');
|
||||
}
|
||||
if (
|
||||
hasDockerFacetEvidence(resource.docker) ||
|
||||
hasDockerFacetEvidence(platformData?.docker) ||
|
||||
DOCKER_RESOURCE_TYPES.has(resource.type)
|
||||
) {
|
||||
if (hasDockerRuntimeHostEvidence(resource) || DOCKER_RESOURCE_TYPES.has(resource.type)) {
|
||||
ids.add('docker');
|
||||
}
|
||||
if (KUBERNETES_RESOURCE_TYPES.has(resource.type)) {
|
||||
|
||||
@@ -26,13 +26,42 @@ import {
|
||||
filterPlatformResources,
|
||||
formatPlatformTableTextValue,
|
||||
getPlatformTableFiniteMetric,
|
||||
getPlatformResourceCountNoun,
|
||||
getPlatformTableWeightedColumnWidthStyle,
|
||||
normalizePlatformResourceStatusFilter,
|
||||
summarizePlatformTableValues,
|
||||
type PlatformResourceStatusFilter,
|
||||
} from '../sharedPlatformPage';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('getPlatformResourceCountNoun', () => {
|
||||
it('uses singular platform nouns for one visible total', () => {
|
||||
expect(getPlatformResourceCountNoun('machines', 1)).toBe('machine');
|
||||
expect(getPlatformResourceCountNoun('config resources', 1)).toBe('config resource');
|
||||
expect(getPlatformResourceCountNoun('VMs', 1)).toBe('VM');
|
||||
});
|
||||
|
||||
it('keeps plural platform nouns for every other count', () => {
|
||||
expect(getPlatformResourceCountNoun('machines', 0)).toBe('machines');
|
||||
expect(getPlatformResourceCountNoun('machines', 2)).toBe('machines');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePlatformResourceStatusFilter', () => {
|
||||
it('normalizes route-specific legacy status vocabulary', () => {
|
||||
expect(normalizePlatformResourceStatusFilter('running')).toBe('online');
|
||||
expect(normalizePlatformResourceStatusFilter('healthy')).toBe('online');
|
||||
expect(normalizePlatformResourceStatusFilter('warning')).toBe('degraded');
|
||||
expect(normalizePlatformResourceStatusFilter('stopped')).toBe('offline');
|
||||
});
|
||||
|
||||
it('falls back to the unfiltered state for unknown route values', () => {
|
||||
expect(normalizePlatformResourceStatusFilter('unknown-value')).toBe('all');
|
||||
expect(normalizePlatformResourceStatusFilter(undefined)).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
const makeResource = (
|
||||
partial: Partial<Resource> & Pick<Resource, 'id' | 'type' | 'status'>,
|
||||
): Resource => ({
|
||||
|
||||
@@ -607,6 +607,17 @@ const ONLINE_STATUSES = new Set<string>(['online', 'running']);
|
||||
const OFFLINE_STATUSES = new Set<string>(['offline', 'stopped']);
|
||||
const DEGRADED_STATUSES = new Set<string>(['degraded', 'warning', 'paused']);
|
||||
|
||||
export const normalizePlatformResourceStatusFilter = (
|
||||
value: string | string[] | null | undefined,
|
||||
): PlatformResourceStatusFilter => {
|
||||
const normalized = (Array.isArray(value) ? value[0] : value)?.trim().toLowerCase();
|
||||
if (!normalized || normalized === 'all') return 'all';
|
||||
if (ONLINE_STATUSES.has(normalized) || normalized === 'healthy') return 'online';
|
||||
if (DEGRADED_STATUSES.has(normalized) || normalized === 'attention') return 'degraded';
|
||||
if (OFFLINE_STATUSES.has(normalized)) return 'offline';
|
||||
return 'all';
|
||||
};
|
||||
|
||||
const mapResourceStatusToTriad = (
|
||||
status: string | undefined,
|
||||
): Exclude<PlatformResourceStatusFilter, 'all'> | 'unknown' => {
|
||||
@@ -752,22 +763,35 @@ export const PlatformTableResetFiltersButton: Component<{
|
||||
// Compact operator-facing counter shown at the right of the toolbar so
|
||||
// users can read total / matching at a glance, mirroring v5's dense
|
||||
// dashboard counters without spawning a card grid.
|
||||
export const getPlatformResourceCountNoun = (rowNoun: string, count: number): string => {
|
||||
if (count !== 1) return rowNoun;
|
||||
const words = rowNoun.split(' ');
|
||||
const last = words.at(-1) ?? rowNoun;
|
||||
if (last.length > 1 && last.toLowerCase().endsWith('s')) {
|
||||
words[words.length - 1] = last.slice(0, -1);
|
||||
}
|
||||
return words.join(' ');
|
||||
};
|
||||
|
||||
const PlatformResourceCounter: Component<{ visible: number; total: number; rowNoun: string }> = (
|
||||
props,
|
||||
) => (
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show
|
||||
when={props.visible !== props.total}
|
||||
fallback={
|
||||
<>
|
||||
{props.total} {props.rowNoun}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.visible} of {props.total} {props.rowNoun}
|
||||
</Show>
|
||||
</span>
|
||||
);
|
||||
) => {
|
||||
const totalNoun = () => getPlatformResourceCountNoun(props.rowNoun, props.total);
|
||||
return (
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show
|
||||
when={props.visible !== props.total}
|
||||
fallback={
|
||||
<>
|
||||
{props.total} {totalNoun()}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.visible} of {props.total} {totalNoun()}
|
||||
</Show>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export function PlatformTableToolbar<T extends string | number>(props: {
|
||||
search: () => string;
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
getRaidStateVariant,
|
||||
} from '@/utils/raidPresentation';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { getPreferredResourceIP } from '@/utils/resourceIdentity';
|
||||
import {
|
||||
RESOURCE_METADATA_CHANGED_EVENT,
|
||||
type ResourceMetadataChangedDetail,
|
||||
@@ -1156,6 +1157,8 @@ export const AgentsMachinesTable: Component<{
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
targetAgentVersion?: string | null;
|
||||
externalStatus?: () => PlatformResourceStatusFilter;
|
||||
onExternalStatusChange?: (value: PlatformResourceStatusFilter) => void;
|
||||
}> = (props) => {
|
||||
const [locallyRemovedResourceIds, setLocallyRemovedResourceIds] = createSignal<
|
||||
Record<string, boolean>
|
||||
@@ -1167,6 +1170,8 @@ export const AgentsMachinesTable: Component<{
|
||||
resources: visibleMachineResources,
|
||||
initialStatus: 'all' as PlatformResourceStatusFilter,
|
||||
filter: filterAgentMachineResources,
|
||||
externalStatus: props.externalStatus,
|
||||
onExternalStatusChange: props.onExternalStatusChange,
|
||||
});
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const [sortKey, setSortKey] = createSignal<AgentMachineSortKey>('name');
|
||||
@@ -1476,7 +1481,8 @@ export const AgentsMachinesTable: Component<{
|
||||
const networkInterfaces = () => getAgentMachineNetworkInterfaceDetails(machine);
|
||||
const diskIOTotal = () => getAgentMachineDiskIOTotal(machine);
|
||||
const diskIODetails = () => getAgentMachineDiskIODetails(machine);
|
||||
const primaryIp = () => getAgentMachinePrimaryIp(machine);
|
||||
const primaryIp = () =>
|
||||
getPreferredResourceIP(machine) ?? getAgentMachinePrimaryIp(machine);
|
||||
const lastSeenValue = () =>
|
||||
isAgentlessMachine(machine)
|
||||
? availabilityFor(machine)?.lastChecked
|
||||
@@ -1492,7 +1498,13 @@ export const AgentsMachinesTable: Component<{
|
||||
!columnVisibility.isColumnVisible('ip'),
|
||||
!columnVisibility.isColumnVisible('lastSeen'),
|
||||
);
|
||||
const ipValues = () => getAgentMachineIpValues(machine);
|
||||
const ipValues = () => {
|
||||
const preferred = getPreferredResourceIP(machine);
|
||||
const values = getAgentMachineIpValues(machine);
|
||||
return preferred
|
||||
? [preferred, ...values.filter((value) => value !== preferred)]
|
||||
: values;
|
||||
};
|
||||
const raidArrays = () => getAgentMachineRaidArrayDetails(machine);
|
||||
const raidSummary = () => getAgentMachineRaidSummary(machine);
|
||||
const temperature = () => getAgentMachineTemperatureCelsius(machine);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Show, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js';
|
||||
import { useLocation, useNavigate } from '@solidjs/router';
|
||||
import { useLocation, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import ActivityIcon from 'lucide-solid/icons/activity';
|
||||
import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
|
||||
import ServerIcon from 'lucide-solid/icons/server';
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
PlatformSectionTabs,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableLoadingState,
|
||||
normalizePlatformResourceStatusFilter,
|
||||
type PlatformResourceStatusFilter,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
|
||||
import { STANDALONE_PATH, buildStandalonePath } from '@/routing/resourceLinks';
|
||||
@@ -118,6 +120,7 @@ const resolveStandaloneTab = (pathname: string): StandaloneTabId =>
|
||||
export function StandalonePageSurface() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { resources, loading, error, refetch } = useUnifiedResources({
|
||||
query: STANDALONE_RESOURCE_QUERY,
|
||||
cacheKey: 'standalone-workspace',
|
||||
@@ -128,6 +131,12 @@ export function StandalonePageSurface() {
|
||||
|
||||
const model = createMemo(() => buildStandalonePageModel(resources()));
|
||||
const activeTab = createMemo(() => resolveStandaloneTab(location.pathname));
|
||||
const machineStatusFilter = createMemo(() =>
|
||||
normalizePlatformResourceStatusFilter(searchParams.status),
|
||||
);
|
||||
const setMachineStatusFilter = (status: PlatformResourceStatusFilter) => {
|
||||
setSearchParams({ status: status === 'all' ? null : status }, { replace: true });
|
||||
};
|
||||
const machinePosture = createMemo(() => buildStandalonePostureSummary(model().machines));
|
||||
const availabilityPosture = createMemo(() =>
|
||||
buildStandalonePostureSummary(model().availabilityChecks),
|
||||
@@ -331,6 +340,8 @@ export function StandalonePageSurface() {
|
||||
emptyTitle="No machines"
|
||||
emptyDescription="Install Pulse Agent on Linux, macOS, Windows, or Unraid systems for full CPU, memory, disk, and network telemetry."
|
||||
targetAgentVersion={agentUpdateTargetVersion()}
|
||||
externalStatus={machineStatusFilter}
|
||||
onExternalStatusChange={setMachineStatusFilter}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,18 @@ import { StandalonePageSurface } from '../StandalonePageSurface';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pathname: '/standalone/machines',
|
||||
searchParams: {} as Record<string, string>,
|
||||
setSearchParams: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
useUnifiedResources: vi.fn(),
|
||||
versionInfo: vi.fn(),
|
||||
AgentsMachinesTable: vi.fn((props: { resources: Resource[] }) => (
|
||||
<div data-testid="agents-machines-table" data-resource-count={props.resources.length} />
|
||||
)),
|
||||
AgentsMachinesTable: vi.fn(
|
||||
(props: {
|
||||
resources: Resource[];
|
||||
externalStatus?: () => string;
|
||||
onExternalStatusChange?: (value: string) => void;
|
||||
}) => <div data-testid="agents-machines-table" data-resource-count={props.resources.length} />,
|
||||
),
|
||||
AvailabilityChecksTable: vi.fn((props: { resources: Resource[] }) => (
|
||||
<div data-testid="availability-checks-table" data-resource-count={props.resources.length} />
|
||||
)),
|
||||
@@ -37,6 +43,7 @@ vi.mock('@solidjs/router', async () => {
|
||||
},
|
||||
}),
|
||||
useNavigate: () => mocks.navigate,
|
||||
useSearchParams: () => [mocks.searchParams, mocks.setSearchParams],
|
||||
A: (props: { href: string; children: JSX.Element }) => (
|
||||
<a href={props.href}>{props.children}</a>
|
||||
),
|
||||
@@ -51,26 +58,32 @@ vi.mock('../AvailabilityChecksTable', () => ({
|
||||
AvailabilityChecksTable: mocks.AvailabilityChecksTable,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/platformPage/sharedPlatformPage', () => ({
|
||||
PlatformErrorState: () => <div data-testid="platform-error-state" />,
|
||||
PlatformSectionTabs: (props: {
|
||||
active: string;
|
||||
tabs: Array<{ id: string; label: string; path: string }>;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="standalone-section-tabs"
|
||||
data-active={props.active}
|
||||
data-tabs={props.tabs.map((tab) => tab.id).join(',')}
|
||||
/>
|
||||
),
|
||||
PlatformTableEmptyState: (props: { title: string; actions?: JSX.Element }) => (
|
||||
<div data-testid="platform-table-empty-state">
|
||||
<span>{props.title}</span>
|
||||
{props.actions}
|
||||
</div>
|
||||
),
|
||||
PlatformTableLoadingState: () => <div data-testid="platform-table-loading-state" />,
|
||||
}));
|
||||
vi.mock('@/features/platformPage/sharedPlatformPage', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/features/platformPage/sharedPlatformPage')>(
|
||||
'@/features/platformPage/sharedPlatformPage',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
PlatformErrorState: () => <div data-testid="platform-error-state" />,
|
||||
PlatformSectionTabs: (props: {
|
||||
active: string;
|
||||
tabs: Array<{ id: string; label: string; path: string }>;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="standalone-section-tabs"
|
||||
data-active={props.active}
|
||||
data-tabs={props.tabs.map((tab) => tab.id).join(',')}
|
||||
/>
|
||||
),
|
||||
PlatformTableEmptyState: (props: { title: string; actions?: JSX.Element }) => (
|
||||
<div data-testid="platform-table-empty-state">
|
||||
<span>{props.title}</span>
|
||||
{props.actions}
|
||||
</div>
|
||||
),
|
||||
PlatformTableLoadingState: () => <div data-testid="platform-table-loading-state" />,
|
||||
};
|
||||
});
|
||||
|
||||
const resource = (overrides: Partial<Resource>): Resource =>
|
||||
({
|
||||
@@ -88,6 +101,8 @@ const resource = (overrides: Partial<Resource>): 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(() => <StandalonePageSurface />);
|
||||
|
||||
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(() => <StandalonePageSurface />);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }}`,
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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}."
|
||||
|
||||
Reference in New Issue
Block a user