Keep published release validation non-destructive

This commit is contained in:
pulse-triage[bot]
2026-08-29 10:27:08 +01:00
parent 505dcd571f
commit ba73a5474e
5 changed files with 57 additions and 43 deletions
+9 -41
View File
@@ -137,7 +137,7 @@ jobs:
--validate-body-file "$RELEASE_BODY_FILE"
- name: Quarantine malformed release body
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'failure'
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true' && steps.body_integrity.outcome == 'failure'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -365,7 +365,7 @@ jobs:
}'
- name: Update release body - Success
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && steps.validate.outputs.validation_passed == 'true'
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 }}
run: |
@@ -472,37 +472,12 @@ jobs:
}'
- name: Delete all release assets on failure
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false')
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 }}
run: |
echo "❌ Validation failed - deleting all release assets"
INITIAL_STATE="${{ steps.context.outputs.draft }}"
if [ "$INITIAL_STATE" != "true" ]; then
echo "Release was published; reverting to draft before deleting assets..."
RELEASE_JSON=$(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/${{ steps.context.outputs.release_id }}" \
-d "$(jq -n \
--arg tag "${{ steps.context.outputs.tag }}" \
--arg target_commitish "${{ steps.context.outputs.target_commitish }}" \
'{draft: true, tag_name: $tag, target_commitish: $target_commitish}')")
ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty')
ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r '.target_commitish // empty')
if [ "$ACTUAL_RELEASE_TAG" != "${{ steps.context.outputs.tag }}" ]; then
echo "::error::Validation failure draft update detached release tag ${ACTUAL_RELEASE_TAG}; expected ${{ steps.context.outputs.tag }}."
exit 1
fi
if [ "$ACTUAL_TARGET_COMMITISH" != "${{ steps.context.outputs.target_commitish }}" ]; then
echo "::error::Validation failure draft update changed target_commitish ${ACTUAL_TARGET_COMMITISH}; expected ${{ steps.context.outputs.target_commitish }}."
exit 1
fi
echo "Release marked as draft to block downloads during remediation."
sleep 5
fi
ASSET_IDS=$(curl -s -H "Authorization: token $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets" \
| jq -r '.[].id')
@@ -522,23 +497,16 @@ jobs:
fi
- name: Update release body - Failure
if: steps.context.outputs.should_run == 'true' && steps.body_integrity.outcome == 'success' && (failure() || steps.validate.outputs.validation_passed == 'false')
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 }}
run: |
set -euo pipefail
echo "❌ Validation failed - updating release description"
INITIAL_STATE="${{ steps.context.outputs.draft }}"
if [ "$INITIAL_STATE" = "true" ]; then
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."
else
HEADER_LINE="## ❌ Release Asset Validation (Post-Publish): FAILED"
STATUS_LINE="**Status**: ⛔ Release reverted to draft while assets are rebuilt"
INTRO_LINE="A published release edit introduced invalid assets. The release has been reverted to draft and assets were deleted to stop further downloads."
fi
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)
@@ -638,11 +606,11 @@ jobs:
- 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. All assets have been deleted."
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. The release was quarantined as a draft without deleting its assets."
echo "::error::Release body integrity failed. Draft releases are quarantined; published releases remain immutable for explicit remediation."
exit 1
+2
View File
@@ -401,6 +401,8 @@ COPY --from=release_payload /amd64/bin/pulse-agent-* /opt/pulse/bin/
COPY --from=release_payload /amd64/bin/pulse /opt/pulse/bin/pulse-linux-amd64
COPY --from=release_payload /arm64/bin/pulse /opt/pulse/bin/pulse-linux-arm64
RUN chmod 755 /opt/pulse/scripts/*.sh /opt/pulse/scripts/*.ps1 && \
find /opt/pulse/bin -maxdepth 1 -type f -name 'pulse-agent-*' \
! -name '*.sig' ! -name '*.sshsig' -exec chmod 755 {} + && \
if [ "$TARGETARCH" = "arm64" ]; then \
ln -sf pulse-linux-arm64 /opt/pulse/bin/pulse; \
ln -sf /opt/pulse/bin/pulse-agent-linux-arm64 /usr/local/bin/pulse-agent; \
@@ -110,6 +110,15 @@ container. The promotion resolver, manual release trigger, rendered release
body, current release packet, and shipped upgrade guide must agree on those
deployment-specific paths.
Images assembled from immutable release payload contexts must restore
executable mode on copied Unified Agent binaries before creating the
architecture-resolved `/usr/local/bin/pulse-agent` link. Detached signature
sidecars remain non-executable. Release validation may delete invalid assets
and rewrite validation annotations only while a release is still a draft. A
post-publication edit is observation, not authority to mutate or destroy an
immutable release; failed revalidation records a failing status and requires an
explicit corrective release path.
The accelerated exact-SHA release worker must preserve release-gate fidelity
under its own resource envelope. Bounded frontend static checks and integration
image preparation may overlap, but the full frontend test suite and the
@@ -202,6 +202,7 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) {
"FROM prebuilt-runtime-base AS runtime_prebuilt",
"COPY --from=release_payload /amd64/bin/pulse /opt/pulse/bin/pulse-linux-amd64",
"COPY --from=release_payload /arm64/bin/pulse /opt/pulse/bin/pulse-linux-arm64",
"! -name '*.sig' ! -name '*.sshsig' -exec chmod 755 {} +",
"FROM alpine:3.20@sha256:",
"AS agent_runtime_prebuilt",
} {
@@ -716,13 +717,27 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) {
`--validate-body-file "$RELEASE_BODY_FILE"`,
`--expected-body-file "$CLEAN_BODY_FILE"`,
`Quarantine malformed release body`,
`The release was quarantined as a draft without deleting its assets.`,
`Draft releases are quarantined; published releases remain immutable for explicit remediation.`,
`name: Update release body - Success
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true'`,
`name: Delete all release assets on failure
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true'`,
`name: Update release body - Failure
if: steps.context.outputs.should_run == 'true' && steps.context.outputs.draft == 'true'`,
}
for _, needle := range validationRequired {
if !strings.Contains(validationWorkflow, needle) {
t.Fatalf("validate-release-assets.yml missing required status publication contract: %s", needle)
}
}
for _, forbidden := range []string{
"Release was published; reverting to draft before deleting assets",
"A published release edit introduced invalid assets",
} {
if strings.Contains(validationWorkflow, forbidden) {
t.Fatalf("published release validation must not retain mutation path %q", forbidden)
}
}
}
func TestCurrentStablePatchReleasePacketTracksInstallMetadata(t *testing.T) {
@@ -1484,7 +1484,27 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("--expected-body-file \"$CLEAN_BODY_FILE\"", validation_workflow)
self.assertIn("Quarantine malformed release body", validation_workflow)
self.assertIn(
"The release was quarantined as a draft without deleting its assets.",
"Draft releases are quarantined; published releases remain immutable for explicit remediation.",
validation_workflow,
)
for step in (
"Quarantine malformed release body",
"Update release body - Success",
"Delete all release assets on failure",
"Update release body - Failure",
):
self.assertIn(
f"- name: {step}\n"
" if: steps.context.outputs.should_run == 'true' && "
"steps.context.outputs.draft == 'true'",
validation_workflow,
)
self.assertNotIn(
"Release was published; reverting to draft before deleting assets",
validation_workflow,
)
self.assertNotIn(
"A published release edit introduced invalid assets",
validation_workflow,
)
self.assertIn(