mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
6998908d2a
Downstream release-note syndication repeats the asset check banner even when installed health or release convergence is not qualified. Report asset checks only and state the remaining evidence boundaries for both draft and post-publication banners. Change-source: pulse-maintainer
662 lines
29 KiB
YAML
662 lines
29 KiB
YAML
name: Validate Release Assets
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
tag:
|
|
description: 'Release tag (e.g., v4.29.0)'
|
|
required: true
|
|
type: string
|
|
version:
|
|
description: 'Version number without v prefix (e.g., 4.29.0)'
|
|
required: true
|
|
type: string
|
|
release_id:
|
|
description: 'GitHub release ID'
|
|
required: true
|
|
type: string
|
|
draft:
|
|
description: 'Whether the release is still a draft'
|
|
required: true
|
|
type: boolean
|
|
target_commitish:
|
|
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:
|
|
inputs:
|
|
tag:
|
|
description: 'Release tag (e.g., v4.29.0)'
|
|
required: true
|
|
type: string
|
|
version:
|
|
description: 'Version number without v prefix (e.g., 4.29.0)'
|
|
required: true
|
|
type: string
|
|
release_id:
|
|
description: 'GitHub release ID'
|
|
required: true
|
|
type: string
|
|
draft:
|
|
description: 'Set to true to run against a draft release'
|
|
required: true
|
|
type: boolean
|
|
target_commitish:
|
|
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
|
|
|
|
jobs:
|
|
validate:
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 45
|
|
permissions:
|
|
contents: write
|
|
issues: write
|
|
statuses: write
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Determine release context
|
|
id: context
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
INPUT_TAG: ${{ inputs.tag }}
|
|
INPUT_VERSION: ${{ inputs.version }}
|
|
INPUT_RELEASE_ID: ${{ inputs.release_id }}
|
|
INPUT_DRAFT: ${{ inputs.draft }}
|
|
INPUT_COMMIT: ${{ inputs.target_commitish }}
|
|
run: |
|
|
python3 <<'EOF' > context.env
|
|
import json, os, re, sys
|
|
|
|
event_name = os.environ.get("EVENT_NAME", "")
|
|
result = {}
|
|
|
|
if event_name == "release":
|
|
with open(os.environ["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
release = data.get("release") or {}
|
|
result["tag"] = release.get("tag_name", "")
|
|
tag = result["tag"]
|
|
result["version"] = tag[1:] if tag.startswith("v") else tag
|
|
result["release_id"] = str(release.get("id", ""))
|
|
result["target_commitish"] = release.get("target_commitish", "")
|
|
result["draft"] = str(release.get("draft", False)).lower()
|
|
else:
|
|
result["tag"] = os.environ.get("INPUT_TAG", "")
|
|
result["version"] = os.environ.get("INPUT_VERSION", "")
|
|
result["release_id"] = os.environ.get("INPUT_RELEASE_ID", "")
|
|
result["target_commitish"] = os.environ.get("INPUT_COMMIT", "")
|
|
draft_value = os.environ.get("INPUT_DRAFT", "false")
|
|
result["draft"] = str(draft_value).lower()
|
|
|
|
version_pattern = r"[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|alpha|beta)\.[0-9]+)?"
|
|
valid = (
|
|
isinstance(result["tag"], str)
|
|
and isinstance(result["version"], str)
|
|
and isinstance(result["target_commitish"], str)
|
|
and re.fullmatch(f"v{version_pattern}", result["tag"])
|
|
and re.fullmatch(version_pattern, result["version"])
|
|
and result["tag"] == f"v{result['version']}"
|
|
and re.fullmatch(r"[0-9]+", result["release_id"])
|
|
and re.fullmatch(r"[0-9a-f]{40}", result["target_commitish"])
|
|
and result["draft"] in {"true", "false"}
|
|
)
|
|
if not valid:
|
|
sys.stderr.write("::error::Release metadata must contain one exact tag, version, release ID, source commit, and draft state.\n")
|
|
sys.exit(1)
|
|
|
|
result["should_run"] = "true"
|
|
|
|
for key, value in result.items():
|
|
print(f"{key}={value}")
|
|
EOF
|
|
cat context.env >> "$GITHUB_OUTPUT"
|
|
cat context.env
|
|
|
|
- name: Validate release body integrity
|
|
id: body_integrity
|
|
if: steps.context.outputs.should_run == 'true'
|
|
continue-on-error: true
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
RELEASE_JSON_FILE=$(mktemp)
|
|
RELEASE_BODY_FILE=$(mktemp)
|
|
gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \
|
|
> "$RELEASE_JSON_FILE"
|
|
jq -r '.body // ""' "$RELEASE_JSON_FILE" > "$RELEASE_BODY_FILE"
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "${WORKFLOW_OUTPUT_2}" \
|
|
--validate-body-file "$RELEASE_BODY_FILE"
|
|
|
|
- name: Quarantine malformed release body
|
|
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'failure'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.tag }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.target_commitish }}
|
|
WORKFLOW_OUTPUT_3: ${{ steps.context.outputs.release_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
PATCH_PAYLOAD=$(mktemp)
|
|
RELEASE_JSON_FILE=$(mktemp)
|
|
|
|
jq -n \
|
|
--arg tag "${WORKFLOW_OUTPUT_1}" \
|
|
--arg target_commitish "${WORKFLOW_OUTPUT_2}" \
|
|
'{draft: true, tag_name: $tag, target_commitish: $target_commitish}' \
|
|
> "$PATCH_PAYLOAD"
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_3}" \
|
|
-X PATCH \
|
|
--input "$PATCH_PAYLOAD" > "$RELEASE_JSON_FILE"
|
|
|
|
ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$RELEASE_JSON_FILE")
|
|
ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$RELEASE_JSON_FILE")
|
|
ACTUAL_DRAFT=$(jq -r '.draft // false' "$RELEASE_JSON_FILE")
|
|
if [ "$ACTUAL_RELEASE_TAG" != "${WORKFLOW_OUTPUT_1}" ]; then
|
|
echo "::error::Body-integrity quarantine detached release tag ${ACTUAL_RELEASE_TAG}; expected ${WORKFLOW_OUTPUT_1}."
|
|
exit 1
|
|
fi
|
|
if [ "$ACTUAL_TARGET_COMMITISH" != "${WORKFLOW_OUTPUT_2}" ]; then
|
|
echo "::error::Body-integrity quarantine changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${WORKFLOW_OUTPUT_2}."
|
|
exit 1
|
|
fi
|
|
if [ "$ACTUAL_DRAFT" != "true" ]; then
|
|
echo "::error::Malformed release body was not quarantined as a draft."
|
|
exit 1
|
|
fi
|
|
|
|
curl --fail-with-body --silent --show-error -X POST \
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${{ github.repository }}/statuses/${WORKFLOW_OUTPUT_2}" \
|
|
-d '{
|
|
"state": "failure",
|
|
"target_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
|
|
"description": "Release body integrity failed",
|
|
"context": "Release Asset Validation"
|
|
}'
|
|
|
|
- name: Download immutable candidate manifest
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && 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
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
gh api --paginate \
|
|
"repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}/assets?per_page=100" \
|
|
--slurp > "$RUNNER_TEMP/release-assets.json"
|
|
gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \
|
|
--jq '.body // ""' > "$RUNNER_TEMP/release-body.md"
|
|
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' && steps.body_integrity.outcome == 'success' && inputs.candidate_manifest_artifact == ''
|
|
id: download
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.tag }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.release_id }}
|
|
run: |
|
|
echo "Downloading all assets from release ${WORKFLOW_OUTPUT_1}..."
|
|
|
|
mkdir -p release
|
|
cd release
|
|
|
|
# Get asset info (id and name) - API works with GITHUB_TOKEN for draft releases
|
|
# Use --paginate to handle releases with >30 assets
|
|
ASSETS_JSON=$(gh api --paginate "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_2}/assets")
|
|
|
|
if [ "$(echo "$ASSETS_JSON" | jq '. | length')" -eq 0 ]; then
|
|
echo "::error::No assets found in release"
|
|
echo "has_assets=false" >> $GITHUB_OUTPUT
|
|
exit 1
|
|
fi
|
|
|
|
echo "has_assets=true" >> $GITHUB_OUTPUT
|
|
echo "Found $(echo "$ASSETS_JSON" | jq '. | length') assets"
|
|
|
|
# Download each asset using the API (works with draft releases)
|
|
echo "$ASSETS_JSON" | jq -r '.[] | "\(.id) \(.name)"' | while read -r asset_id filename; do
|
|
echo "Downloading $filename (ID: $asset_id)..."
|
|
|
|
# Use GitHub API to download with authentication
|
|
curl -L -H "Authorization: token $GH_TOKEN" \
|
|
-H "Accept: application/octet-stream" \
|
|
"https://api.github.com/repos/${{ github.repository }}/releases/assets/$asset_id" \
|
|
-o "$filename"
|
|
|
|
if [ $? -eq 0 ] && [ -f "$filename" ]; then
|
|
echo "✓ Downloaded $filename ($(du -h "$filename" | cut -f1))"
|
|
else
|
|
echo "::error::Failed to download $filename"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "All assets downloaded:"
|
|
ls -lh
|
|
|
|
- name: Install Docker
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && inputs.candidate_manifest_artifact == ''
|
|
run: docker --version
|
|
|
|
- name: Pull Docker image (with retry logic)
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && inputs.candidate_manifest_artifact == ''
|
|
id: docker
|
|
continue-on-error: true
|
|
env:
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.tag }}
|
|
run: |
|
|
IMAGE="rcourtman/pulse:${WORKFLOW_OUTPUT_1}"
|
|
echo "Attempting to pull Docker image: $IMAGE"
|
|
echo "Docker Hub CDN propagation can take 2-5 minutes after push..."
|
|
echo ""
|
|
|
|
# Retry logic: 10 attempts with exponential backoff
|
|
# Total time: ~10 minutes max
|
|
MAX_ATTEMPTS=10
|
|
ATTEMPT=1
|
|
WAIT_TIME=30
|
|
|
|
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
|
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Pulling image..."
|
|
|
|
if docker pull "$IMAGE" 2>/dev/null; then
|
|
echo "✓ Docker image available: $IMAGE"
|
|
echo "image_available=true" >> $GITHUB_OUTPUT
|
|
python3 scripts/write_github_output.py image "$IMAGE"
|
|
exit 0
|
|
fi
|
|
|
|
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
|
echo "⚠️ Image not yet available, waiting ${WAIT_TIME}s before retry..."
|
|
sleep $WAIT_TIME
|
|
WAIT_TIME=$((WAIT_TIME * 2)) # Exponential backoff
|
|
if [ $WAIT_TIME -gt 120 ]; then
|
|
WAIT_TIME=120 # Cap at 2 minutes
|
|
fi
|
|
fi
|
|
|
|
ATTEMPT=$((ATTEMPT + 1))
|
|
done
|
|
|
|
echo "⚠️ Docker image not available after $MAX_ATTEMPTS attempts"
|
|
echo "⚠️ Will skip Docker image validation"
|
|
echo "image_available=false" >> $GITHUB_OUTPUT
|
|
|
|
- name: Run validation script
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success'
|
|
id: validate
|
|
env:
|
|
CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.version }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.target_commitish }}
|
|
WORKFLOW_OUTPUT_3: ${{ steps.docker.outputs.image_available }}
|
|
WORKFLOW_OUTPUT_4: ${{ steps.docker.outputs.image }}
|
|
run: |
|
|
set +e
|
|
set -o pipefail
|
|
echo "Running validation script..."
|
|
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 "${WORKFLOW_OUTPUT_1}" \
|
|
--source-sha "${WORKFLOW_OUTPUT_2}" \
|
|
--release-body-file "$RUNNER_TEMP/release-body.md" 2>&1 | tee "$OUTPUT_FILE"
|
|
elif [ "${WORKFLOW_OUTPUT_3}" = "true" ]; then
|
|
echo "Running full validation (Docker + assets)..."
|
|
scripts/validate-release.sh \
|
|
"${WORKFLOW_OUTPUT_1}" \
|
|
"${WORKFLOW_OUTPUT_4}" \
|
|
"release" 2>&1 | tee "$OUTPUT_FILE"
|
|
else
|
|
echo "Running assets-only validation (Docker image not available)..."
|
|
scripts/validate-release.sh \
|
|
"${WORKFLOW_OUTPUT_1}" \
|
|
--skip-docker \
|
|
"release" 2>&1 | tee "$OUTPUT_FILE"
|
|
fi
|
|
|
|
VALIDATION_EXIT_CODE=${PIPESTATUS[0]}
|
|
|
|
echo "VALIDATION_OUTPUT<<EOF" >> $GITHUB_OUTPUT
|
|
cat "$OUTPUT_FILE" >> $GITHUB_OUTPUT
|
|
echo "EOF" >> $GITHUB_OUTPUT
|
|
|
|
if [ $VALIDATION_EXIT_CODE -eq 0 ]; then
|
|
echo "validation_passed=true" >> $GITHUB_OUTPUT
|
|
echo "✅ Validation PASSED"
|
|
else
|
|
echo "validation_passed=false" >> $GITHUB_OUTPUT
|
|
echo "❌ Validation FAILED"
|
|
fi
|
|
|
|
exit $VALIDATION_EXIT_CODE
|
|
|
|
- name: Set commit status - Success
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && steps.validate.outputs.validation_passed == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.target_commitish }}
|
|
run: |
|
|
curl --fail-with-body --silent --show-error -X POST \
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${{ github.repository }}/statuses/${WORKFLOW_OUTPUT_1}" \
|
|
-d '{
|
|
"state": "success",
|
|
"target_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
|
|
"description": "All release assets validated successfully",
|
|
"context": "Release Asset Validation"
|
|
}'
|
|
|
|
- name: Update release body - Success
|
|
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'success' && steps.validate.outputs.validation_passed == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.draft }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.release_id }}
|
|
WORKFLOW_OUTPUT_3: ${{ steps.context.outputs.version }}
|
|
WORKFLOW_OUTPUT_4: ${{ steps.context.outputs.tag }}
|
|
WORKFLOW_OUTPUT_5: ${{ steps.context.outputs.target_commitish }}
|
|
run: |
|
|
set -euo pipefail
|
|
echo "✅ Validation passed - updating release description"
|
|
|
|
INITIAL_STATE="${WORKFLOW_OUTPUT_1}"
|
|
if [ "$INITIAL_STATE" = "true" ]; then
|
|
HEADER_LINE="## ✅ Release Asset Validation: PASSED"
|
|
STATUS_LINE="**Status**: Release asset checks passed ✅"
|
|
INTRO_LINE="The required release asset checks passed."
|
|
else
|
|
HEADER_LINE="## ✅ Release Asset Validation (Post-Publish): PASSED"
|
|
STATUS_LINE="**Status**: Live release assets re-validated ✅"
|
|
INTRO_LINE="Assets were revalidated after publication due to a release edit."
|
|
fi
|
|
|
|
CURRENT_RELEASE_JSON=$(mktemp)
|
|
CURRENT_BODY_FILE=$(mktemp)
|
|
CLEAN_BODY_FILE=$(mktemp)
|
|
NEW_BODY_FILE=$(mktemp)
|
|
PATCH_PAYLOAD=$(mktemp)
|
|
PATCH_RESPONSE=$(mktemp)
|
|
PATCHED_BODY_FILE=$(mktemp)
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_2}" \
|
|
> "$CURRENT_RELEASE_JSON"
|
|
jq -r '.body // ""' "$CURRENT_RELEASE_JSON" > "$CURRENT_BODY_FILE"
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "${WORKFLOW_OUTPUT_3}" \
|
|
--validate-body-file "$CURRENT_BODY_FILE" \
|
|
--output "$CLEAN_BODY_FILE"
|
|
|
|
read -r -d '' VALIDATION_BLOCK <<'EOF' || true
|
|
<!-- VALIDATION_STATUS_START -->
|
|
HEADER_PLACEHOLDER
|
|
|
|
INTRO_PLACEHOLDER
|
|
|
|
STATUS_PLACEHOLDER
|
|
**Validated**: TIMESTAMP_PLACEHOLDER
|
|
**Workflow**: WORKFLOW_LINK_PLACEHOLDER
|
|
|
|
### Validation Summary
|
|
- All required assets present ✓
|
|
- Checksums verified ✓
|
|
- Version strings correct ✓
|
|
- Binary architectures validated ✓
|
|
|
|
Asset validation alone does not establish installed-service health,
|
|
release convergence, clean soak or approval for stable publication.
|
|
|
|
<!-- VALIDATION_STATUS_END -->
|
|
EOF
|
|
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//HEADER_PLACEHOLDER/$HEADER_LINE}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//STATUS_PLACEHOLDER/$STATUS_LINE}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//INTRO_PLACEHOLDER/$INTRO_LINE}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//TIMESTAMP_PLACEHOLDER/$(date -u +"%Y-%m-%d %H:%M:%S UTC")}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//WORKFLOW_LINK_PLACEHOLDER/[${{ github.workflow }} #${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})}"
|
|
|
|
printf '%s\n\n' "$VALIDATION_BLOCK" > "$NEW_BODY_FILE"
|
|
cat "$CLEAN_BODY_FILE" >> "$NEW_BODY_FILE"
|
|
jq -n \
|
|
--rawfile body "$NEW_BODY_FILE" \
|
|
--arg tag "${WORKFLOW_OUTPUT_4}" \
|
|
--arg target_commitish "${WORKFLOW_OUTPUT_5}" \
|
|
'{body: $body, tag_name: $tag, target_commitish: $target_commitish}' \
|
|
> "$PATCH_PAYLOAD"
|
|
|
|
curl --fail-with-body --silent --show-error -X PATCH \
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_2}" \
|
|
--data-binary "@$PATCH_PAYLOAD" > "$PATCH_RESPONSE"
|
|
|
|
ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$PATCH_RESPONSE")
|
|
ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$PATCH_RESPONSE")
|
|
if [ "$ACTUAL_RELEASE_TAG" != "${WORKFLOW_OUTPUT_4}" ]; then
|
|
echo "::error::Validation release body update detached release tag ${ACTUAL_RELEASE_TAG}; expected ${WORKFLOW_OUTPUT_4}."
|
|
exit 1
|
|
fi
|
|
if [ "$ACTUAL_TARGET_COMMITISH" != "${WORKFLOW_OUTPUT_5}" ]; then
|
|
echo "::error::Validation release body update changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${WORKFLOW_OUTPUT_5}."
|
|
exit 1
|
|
fi
|
|
jq -r '.body // ""' "$PATCH_RESPONSE" > "$PATCHED_BODY_FILE"
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "${WORKFLOW_OUTPUT_3}" \
|
|
--validate-body-file "$PATCHED_BODY_FILE" \
|
|
--expected-body-file "$CLEAN_BODY_FILE"
|
|
|
|
- name: Set commit status - Failure
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false')
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.target_commitish }}
|
|
run: |
|
|
curl --fail-with-body --silent --show-error -X POST \
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${{ github.repository }}/statuses/${WORKFLOW_OUTPUT_1}" \
|
|
-d '{
|
|
"state": "failure",
|
|
"target_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
|
|
"description": "Release asset validation failed",
|
|
"context": "Release Asset Validation"
|
|
}'
|
|
|
|
- name: Delete all release assets on failure
|
|
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false')
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }}
|
|
run: |
|
|
echo "❌ Validation failed - deleting all release assets"
|
|
|
|
ASSET_IDS=$(curl -s -H "Authorization: token $GH_TOKEN" \
|
|
"https://api.github.com/repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}/assets" \
|
|
| jq -r '.[].id')
|
|
|
|
if [ -n "$ASSET_IDS" ]; then
|
|
echo "$ASSET_IDS" | while read -r asset_id; do
|
|
if [ -n "$asset_id" ] && [ "$asset_id" != "null" ]; then
|
|
echo "Deleting asset ID: $asset_id"
|
|
curl -X DELETE \
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
"https://api.github.com/repos/${{ github.repository }}/releases/assets/$asset_id"
|
|
fi
|
|
done
|
|
echo "✓ Asset deletion process completed"
|
|
else
|
|
echo "No assets to delete"
|
|
fi
|
|
|
|
- name: Update release body - Failure
|
|
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false')
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
WORKFLOW_OUTPUT_1: ${{ steps.context.outputs.release_id }}
|
|
WORKFLOW_OUTPUT_2: ${{ steps.context.outputs.version }}
|
|
WORKFLOW_OUTPUT_3: ${{ steps.validate.outputs.VALIDATION_OUTPUT }}
|
|
WORKFLOW_OUTPUT_4: ${{ steps.context.outputs.tag }}
|
|
WORKFLOW_OUTPUT_5: ${{ steps.context.outputs.target_commitish }}
|
|
run: |
|
|
set -euo pipefail
|
|
echo "❌ Validation failed - updating release description"
|
|
|
|
HEADER_LINE="## ❌ Release Asset Validation: FAILED"
|
|
STATUS_LINE="**Status**: ⛔ Draft release blocked until validation passes"
|
|
INTRO_LINE="Release assets failed validation checks. All assets have been deleted to prevent publishing an invalid release."
|
|
|
|
CURRENT_RELEASE_JSON=$(mktemp)
|
|
CURRENT_BODY_FILE=$(mktemp)
|
|
CLEAN_BODY_FILE=$(mktemp)
|
|
NEW_BODY_FILE=$(mktemp)
|
|
PATCH_PAYLOAD=$(mktemp)
|
|
PATCH_RESPONSE=$(mktemp)
|
|
PATCHED_BODY_FILE=$(mktemp)
|
|
|
|
gh api "repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \
|
|
> "$CURRENT_RELEASE_JSON"
|
|
jq -r '.body // ""' "$CURRENT_RELEASE_JSON" > "$CURRENT_BODY_FILE"
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "${WORKFLOW_OUTPUT_2}" \
|
|
--validate-body-file "$CURRENT_BODY_FILE" \
|
|
--output "$CLEAN_BODY_FILE"
|
|
|
|
VALIDATION_OUTPUT="${WORKFLOW_OUTPUT_3}"
|
|
if [ -n "$VALIDATION_OUTPUT" ]; then
|
|
ERRORS=$(echo "$VALIDATION_OUTPUT" | grep -i '\[ERROR\]' | head -20 || echo "See workflow logs for details")
|
|
else
|
|
ERRORS="Validation script failed to run. Check workflow logs."
|
|
fi
|
|
|
|
read -r -d '' VALIDATION_BLOCK <<'EOF' || true
|
|
<!-- VALIDATION_STATUS_START -->
|
|
HEADER_PLACEHOLDER
|
|
|
|
INTRO_PLACEHOLDER
|
|
|
|
STATUS_PLACEHOLDER
|
|
**Failed**: TIMESTAMP_PLACEHOLDER
|
|
**Workflow**: WORKFLOW_LINK_PLACEHOLDER
|
|
|
|
### What Happened
|
|
The automated validation process detected issues with the uploaded release assets. This could be due to:
|
|
- Missing required files
|
|
- Checksum mismatches
|
|
- Incorrect version strings in binaries
|
|
- Corrupted or incomplete uploads
|
|
|
|
### Action Required
|
|
1. Review the validation errors in the workflow logs
|
|
2. Fix the underlying issues in the release build process
|
|
3. Re-upload the corrected assets
|
|
4. Validation will run automatically when assets are edited
|
|
|
|
### Validation Errors (first 20 lines)
|
|
```
|
|
ERRORS_PLACEHOLDER
|
|
```
|
|
|
|
[View full workflow logs for complete details](WORKFLOW_URL_PLACEHOLDER)
|
|
|
|
<!-- VALIDATION_STATUS_END -->
|
|
EOF
|
|
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//HEADER_PLACEHOLDER/$HEADER_LINE}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//STATUS_PLACEHOLDER/$STATUS_LINE}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//INTRO_PLACEHOLDER/$INTRO_LINE}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//TIMESTAMP_PLACEHOLDER/$(date -u +"%Y-%m-%d %H:%M:%S UTC")}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//WORKFLOW_LINK_PLACEHOLDER/[${{ github.workflow }} #${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//WORKFLOW_URL_PLACEHOLDER/${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}}"
|
|
VALIDATION_BLOCK="${VALIDATION_BLOCK//ERRORS_PLACEHOLDER/$ERRORS}"
|
|
|
|
printf '%s\n\n' "$VALIDATION_BLOCK" > "$NEW_BODY_FILE"
|
|
cat "$CLEAN_BODY_FILE" >> "$NEW_BODY_FILE"
|
|
jq -n \
|
|
--rawfile body "$NEW_BODY_FILE" \
|
|
--arg tag "${WORKFLOW_OUTPUT_4}" \
|
|
--arg target_commitish "${WORKFLOW_OUTPUT_5}" \
|
|
'{body: $body, tag_name: $tag, target_commitish: $target_commitish}' \
|
|
> "$PATCH_PAYLOAD"
|
|
|
|
curl --fail-with-body --silent --show-error -X PATCH \
|
|
-H "Authorization: token $GH_TOKEN" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${{ github.repository }}/releases/${WORKFLOW_OUTPUT_1}" \
|
|
--data-binary "@$PATCH_PAYLOAD" > "$PATCH_RESPONSE"
|
|
|
|
ACTUAL_RELEASE_TAG=$(jq -r '.tag_name // empty' "$PATCH_RESPONSE")
|
|
ACTUAL_TARGET_COMMITISH=$(jq -r '.target_commitish // empty' "$PATCH_RESPONSE")
|
|
if [ "$ACTUAL_RELEASE_TAG" != "${WORKFLOW_OUTPUT_4}" ]; then
|
|
echo "::error::Validation failure body update detached release tag ${ACTUAL_RELEASE_TAG}; expected ${WORKFLOW_OUTPUT_4}."
|
|
exit 1
|
|
fi
|
|
if [ "$ACTUAL_TARGET_COMMITISH" != "${WORKFLOW_OUTPUT_5}" ]; then
|
|
echo "::error::Validation failure body update changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${WORKFLOW_OUTPUT_5}."
|
|
exit 1
|
|
fi
|
|
jq -r '.body // ""' "$PATCH_RESPONSE" > "$PATCHED_BODY_FILE"
|
|
python3 scripts/release_control/render_release_body.py \
|
|
--version "${WORKFLOW_OUTPUT_2}" \
|
|
--validate-body-file "$PATCHED_BODY_FILE" \
|
|
--expected-body-file "$CLEAN_BODY_FILE"
|
|
|
|
- name: Fail the workflow
|
|
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false')
|
|
run: |
|
|
echo "::error::Release asset validation failed. Draft assets are deleted; published releases remain immutable for explicit remediation."
|
|
exit 1
|
|
|
|
- name: Fail malformed release body
|
|
if: always() && steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'failure'
|
|
run: |
|
|
echo "::error::Release body integrity failed. Draft releases are quarantined; published releases remain immutable for explicit remediation."
|
|
exit 1
|