Improve platform frontend coherence

This commit is contained in:
rcourtman
2026-07-09 22:23:21 +01:00
parent 8dda0b6efa
commit f5c427a4e1
47 changed files with 756 additions and 1049 deletions
@@ -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
+135 -59
View File
@@ -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
-10
View File
@@ -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
+5 -46
View File
@@ -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.
@@ -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.
@@ -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.
+1 -21
View File
@@ -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
View File
@@ -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.
+7 -6
View File
@@ -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 -&gt; Pulse Intelligence -&gt; 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">
@@ -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 -&gt; Pulse Intelligence -&gt; 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', () => {
@@ -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', () => {
@@ -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);
@@ -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', () => {
+31 -5
View File
@@ -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();
+80 -9
View File
@@ -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 }}`,
-248
View File
@@ -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))
+25 -3
View File
@@ -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}."