diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index cf4b8e31f..8da8032a2 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index c75240cb8..fde773c38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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; \ diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 084904784..3afb69d72 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 158f004f0..87cead7c7 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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) { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 2d6f3fb3e..4b38fa165 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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(