diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ec5a4060e..442b29d03 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -773,6 +773,21 @@ jobs: RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url') fi + RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}") + ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty') + ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r '.target_commitish // empty') + RELEASE_URL=$(echo "$RELEASE_JSON" | jq -r '.html_url') + + if [ "$ACTUAL_RELEASE_TAG" != "$TAG" ]; then + echo "::error::Draft release ${RELEASE_ID} is bound to tag ${ACTUAL_RELEASE_TAG}, expected ${TAG}." + exit 1 + fi + + if [ "$ACTUAL_TARGET_COMMITISH" != "$HEAD_SHA" ]; then + echo "::error::Draft release ${RELEASE_ID} target_commitish is ${ACTUAL_TARGET_COMMITISH}, expected ${HEAD_SHA}." + exit 1 + fi + rm -f "$NOTES_FILE" echo "release_url=${RELEASE_URL}" >> $GITHUB_OUTPUT @@ -939,10 +954,11 @@ jobs: needs: - prepare - create_release - if: ${{ 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' }} permissions: contents: write issues: write + statuses: write uses: ./.github/workflows/validate-release-assets.yml secrets: inherit with: diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index 1e0163d7d..828ac63cb 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -57,6 +57,7 @@ jobs: permissions: contents: write issues: write + statuses: write steps: - name: Checkout repository @@ -242,7 +243,7 @@ jobs: - name: Set commit status - Success if: steps.context.outputs.should_run == 'true' && steps.validate.outputs.validation_passed == 'true' run: | - curl -X POST \ + curl --fail-with-body --silent --show-error -X POST \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/${{ github.repository }}/statuses/${{ steps.context.outputs.target_commitish }}" \ @@ -313,7 +314,7 @@ jobs: - name: Set commit status - Failure if: steps.context.outputs.should_run == 'true' && (failure() || steps.validate.outputs.validation_passed == 'false') run: | - curl -X POST \ + curl --fail-with-body --silent --show-error -X POST \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/${{ github.repository }}/statuses/${{ steps.context.outputs.target_commitish }}" \ diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index add304b2d..7866e686f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -36,6 +36,7 @@ server-side update execution surfaces. 13. `.github/workflows/publish-helm-chart.yml` 14. `.github/workflows/release-dry-run.yml` 15. `.github/workflows/update-demo-server.yml` +16. `.github/workflows/validate-release-assets.yml` 16. `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml` 17. `docs/RELEASE_NOTES.md` 18. `docs/releases/` @@ -113,7 +114,7 @@ server-side update execution surfaces. ## Extension Points 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, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, download endpoint checksum/signature header proof, 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/render_release_body.py`, `scripts/release_control/resolve_release_promotion.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/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, 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, 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/render_release_body.py`, `scripts/release_control/resolve_release_promotion.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/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` 3. Add or change shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh` 4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts` 5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index efe81795b..532effc48 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2655,6 +2655,7 @@ ".github/workflows/publish-helm-chart.yml", ".github/workflows/release-dry-run.yml", ".github/workflows/update-demo-server.yml", + ".github/workflows/validate-release-assets.yml", "cmd/pulse-control-plane/main.go", "docker-compose.yml", "Dockerfile", @@ -2784,6 +2785,7 @@ ".github/workflows/publish-helm-chart.yml", ".github/workflows/release-dry-run.yml", ".github/workflows/update-demo-server.yml", + ".github/workflows/validate-release-assets.yml", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 476b4ddc1..6bf7e0494 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -100,8 +100,13 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { if err != nil { t.Fatalf("read create-release.yml: %v", err) } + validationContent, err := os.ReadFile(repoFile(".github", "workflows", "validate-release-assets.yml")) + if err != nil { + t.Fatalf("read validate-release-assets.yml: %v", err) + } workflow := string(content) + validationWorkflow := string(validationContent) required := []string{ `historical_asset_backfill_only:`, `description: 'Repair an already-published release packet in place without rebuilding binaries'`, @@ -131,10 +136,15 @@ 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: ${{ 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' }}`, `if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}`, `permissions:`, `issues: write`, + `statuses: write`, + `ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty')`, + `ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r '.target_commitish // empty')`, + `Draft release ${RELEASE_ID} is bound to tag ${ACTUAL_RELEASE_TAG}, expected ${TAG}.`, + `Draft release ${RELEASE_ID} target_commitish is ${ACTUAL_TARGET_COMMITISH}, expected ${HEAD_SHA}.`, `./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}"`, `./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"`, } @@ -150,6 +160,17 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { if strings.Contains(workflow, `provenance: false`) { t.Fatal("create-release.yml must not disable release-image provenance") } + + validationRequired := []string{ + `statuses: write`, + `curl --fail-with-body --silent --show-error -X POST`, + `"context": "Release Asset Validation"`, + } + for _, needle := range validationRequired { + if !strings.Contains(validationWorkflow, needle) { + t.Fatalf("validate-release-assets.yml missing required status publication contract: %s", needle) + } + } } func TestBackfillReleaseWorkflowRepairsPublishedAssetsWithoutRebuilds(t *testing.T) { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 2885708ae..6bd872ade 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -327,6 +327,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): def test_release_workflow_enforces_rc_lineage_soak_and_v5_notice(self) -> None: content = read(".github/workflows/create-release.yml") + validation_workflow = read(".github/workflows/validate-release-assets.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") @@ -373,9 +374,21 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('Retargeting existing draft tag ${TAG}', content) self.assertIn('-F target_commitish="${HEAD_SHA}"', content) self.assertIn('historical_asset_backfill_only=${HISTORICAL_ASSET_BACKFILL_ONLY}', content) - self.assertIn("if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}", content) + self.assertIn( + "if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }}", + content, + ) self.assertIn("if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}", content) self.assertIn("issues: write", content) + self.assertIn("statuses: write", content) + self.assertIn("statuses: write", validation_workflow) + self.assertIn("curl --fail-with-body --silent --show-error -X POST", validation_workflow) + self.assertIn('"context": "Release Asset Validation"', validation_workflow) + self.assertIn('ACTUAL_RELEASE_TAG=$(echo "$RELEASE_JSON" | jq -r \'.tag_name // empty\')', content) + self.assertIn( + 'ACTUAL_TARGET_COMMITISH=$(echo "$RELEASE_JSON" | jq -r \'.target_commitish // empty\')', + content, + ) self.assertIn('./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}"', content) self.assertIn('./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"', content) self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content)