diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 93b643152..98b7e4960 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1289,7 +1289,10 @@ jobs: - validate_release_assets if: ${{ always() && needs.prepare.result == 'success' && needs.create_release.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 + # GitHub's release API requires write-level repository access to read + # assets from an unpublished draft release. The called workflow only + # performs GET requests, but a read-scoped GITHUB_TOKEN receives 403. + contents: write uses: ./.github/workflows/install-sh-smoke.yml secrets: inherit with: diff --git a/.github/workflows/install-sh-smoke.yml b/.github/workflows/install-sh-smoke.yml index fb215f050..f52a7bf85 100644 --- a/.github/workflows/install-sh-smoke.yml +++ b/.github/workflows/install-sh-smoke.yml @@ -94,6 +94,10 @@ jobs: smoke: runs-on: ubuntu-24.04 timeout-minutes: 15 + permissions: + # Unpublished draft release metadata and assets are unavailable to a + # read-scoped GITHUB_TOKEN even though this job only performs GETs. + contents: write steps: - name: Checkout repository (for README key extraction) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 07c950d2f..a8995408c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1771,6 +1771,13 @@ That same reusable-validation call boundary also owns permission handoff. `.github/workflows/validate-release-assets.yml` call the write scopes it requests (`contents: write` and `issues: write`), rather than inheriting the release pipeline's top-level read-only default and failing at workflow startup. +The staged installer-smoke boundary owns the same fail-closed handoff for +unpublished assets. GitHub requires write-level repository access to read draft +release metadata and asset bytes, even when the consumer only performs GET +requests. The `install_sh_smoke` call in `.github/workflows/create-release.yml` +and the `smoke` job in `.github/workflows/install-sh-smoke.yml` must therefore +grant `contents: write`; the workflow-level default remains read-only and the +smoke job must not receive unrelated write scopes. That same validation status boundary must preserve release identity when it annotates a draft or failed release. Every release-body or draft-state PATCH from `.github/workflows/validate-release-assets.yml` must carry the intended diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 2d2e3a2b0..c531fb06a 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -433,6 +433,10 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { t.Fatalf("create-release.yml job %s must skip historical backfill and draft-only runs before invoking downstream workflow_call", job) } } + installSmokeJob := workflowJobBlock(t, workflow, "install_sh_smoke") + if !strings.Contains(installSmokeJob, "contents: write") { + t.Fatal("create-release.yml install_sh_smoke must grant contents: write so the called workflow can read unpublished draft assets") + } readinessJob := workflowJobBlock(t, workflow, "release_readiness") if !strings.Contains(readinessJob, publishedReleaseGuard) { t.Fatal("release_readiness must skip historical backfill and draft-only runs") @@ -1828,7 +1832,8 @@ func TestBuildReleasePackagesPulseMcpForAllPlatforms(t *testing.T) { // cannot return. func TestInstallShSmokeWorkflowPresent(t *testing.T) { - assertFileContainsAll(t, repoFile(".github", "workflows", "install-sh-smoke.yml"), + workflowPath := repoFile(".github", "workflows", "install-sh-smoke.yml") + assertFileContainsAll(t, workflowPath, // Inputs and triggers. `name: install.sh Smoke (Release Assets)`, `workflow_call:`, @@ -1863,6 +1868,15 @@ func TestInstallShSmokeWorkflowPresent(t *testing.T) { `curl -fsS http://127.0.0.1:7655/api/version`, `Installed version mismatch. Expected`, ) + + workflowBytes, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("read install-sh-smoke workflow: %v", err) + } + smokeJob := workflowJobBlock(t, string(workflowBytes), "smoke") + if !strings.Contains(smokeJob, "contents: write") { + t.Fatal("install-sh-smoke.yml smoke job must grant contents: write to read unpublished draft release assets") + } } func TestPromoteFloatingTagsReachableViaWorkflowCall(t *testing.T) {