From 4daf37be112ca5569fa4d3d33983d1a939c98381 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 14:11:14 -0700 Subject: [PATCH 1/2] Move the Release Task's Job Scopes to the Caller The hub's first release through build-release-task.yml failed at startup: build-nuget declared id-token: write at job level, and a called job's permissions block is validated against the caller's grant before its if: runs, so a caller that does not grant that scope fails the whole workflow even with the job disabled. github-release carried the same trap for contents and actions write, which a smoke build under a read-only pull request token could never grant. Both blocks go, the caller grants what the targets it enables need, and the doc says so. --- .github/workflows/build-release-task.yml | 14 +++++--------- docs/reusable-workflows.md | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index a2278bfc..caa6aef5 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -231,11 +231,9 @@ jobs: if: ${{ inputs.enable_nuget }} needs: [get-version, validate-release] runs-on: ubuntu-latest - # OIDC trusted publishing through NuGet/login needs id-token: write, granted by the caller on its call to this task. - # It is declared alongside contents: read, since a job-level permissions: block zeroes every unlisted scope, and this job's checkout steps need it. - permissions: - contents: read - id-token: write + # OIDC trusted publishing through NuGet/login needs id-token: write, and the caller grants it on its call to this task when it enables nuget. + # No job-level permissions: block here, because a called job's block is validated against the caller's grant before its if: runs. + # A block naming id-token would fail every caller that does not grant it at startup, including a smoke build under a read-only pull request token. steps: - name: Checkout code step @@ -534,10 +532,8 @@ jobs: if: ${{ inputs.github && !inputs.smoke && !failure() && !cancelled() }} runs-on: ubuntu-latest needs: [get-version, validate-release, build-executable, build-nuget, build-pypi, build-docker] - # The release upload and the artifact-delete cleanup both write with GITHUB_TOKEN, and the caller grants these. - permissions: - contents: write - actions: write + # The release upload and the artifact-delete cleanup both write with GITHUB_TOKEN, and the caller grants contents: write and actions: write on a publish. + # No job-level permissions: block here, for the reason build-nuget gives: a block is validated against the caller's grant before if: runs, and a smoke caller holds a read-only token. steps: diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index e77e709b..e3b0b1ce 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -420,7 +420,7 @@ A repo whose publisher needs the release-gate decision reaches `publish-plan-tas ## Adopting the Release Chain -A downstream repo replaces the whole of its `.github/workflows/build-release-task.yml`, and every per-target leaf task it carries (`build-executable-task.yml`, `build-nugetlibrary-task.yml`, `build-pypilibrary-task.yml`, `build-docker-task.yml`), with a caller stub in its own `publish-release.yml` reaching the hub tasks by pin. `test-pull-request.yml`'s smoke job calls `build-release-task.yml` the same way, with `smoke: true` and the paths-filter's `enable_*` outputs. Nothing here lands as a catalog snippet in this pull request, since a caller stub's pin can only name a released hub commit and no release yet carries `build-release-task.yml` ([Pinning][pinning]). The snippet follows the release that first ships it, tracked in the [Rollout][rollout] section below. +A downstream repo replaces the whole of its `.github/workflows/build-release-task.yml`, and every per-target leaf task it carries (`build-executable-task.yml`, `build-nugetlibrary-task.yml`, `build-pypilibrary-task.yml`, `build-docker-task.yml`), with a caller stub in its own `publish-release.yml` reaching the hub tasks by pin. `test-pull-request.yml`'s smoke job calls `build-release-task.yml` the same way, with `smoke: true` and the paths-filter's `enable_*` outputs. Nothing here lands as a catalog snippet in this pull request, since a caller stub's pin can only name a released hub commit and no release yet carries `build-release-task.yml` ([Pinning][pinning]). The snippet follows the release that first ships it, tracked in the [Rollout][rollout] section below. The task declares no job-level `permissions:` of its own, because a called job's block is validated against the caller's grant before its `if:` runs and would fail a caller that does not grant it at startup. The caller therefore grants what the targets it enables need: `contents: write` and `actions: write` on a publish, `id-token: write` where `nuget: true`, and nothing beyond `contents: read` on a smoke build, where a Dependabot pull request holds a read-only token. The stub keeps its own trigger policy exactly as today: `workflow_dispatch` plus a main-only weekly `schedule` for a Docker repo, or `workflow_dispatch` plus a paths-filtered `push` to `main` for a NuGet or PyPI repo whose merges should auto-publish. What moves to the hub is the release-gate decision, the build/version/publish job graph, and the Docker core, never the trigger. This is the full shape, a NuGet-library repo whose merges publish: From a244452097a84f2289e70aafed2d0668c9869335 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 14:15:37 -0700 Subject: [PATCH 2/2] Say Which Input Each Caller Grant Answers To --- .github/workflows/build-release-task.yml | 6 ++++-- docs/reusable-workflows.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index caa6aef5..d49b927e 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -231,7 +231,8 @@ jobs: if: ${{ inputs.enable_nuget }} needs: [get-version, validate-release] runs-on: ubuntu-latest - # OIDC trusted publishing through NuGet/login needs id-token: write, and the caller grants it on its call to this task when it enables nuget. + # OIDC trusted publishing through NuGet/login needs id-token: write, and the caller grants it on its call to this task when it sets nuget: true, a real push. + # A build-only run (enable_nuget without nuget, or any smoke build) never reaches NuGet/login and needs no such grant. # No job-level permissions: block here, because a called job's block is validated against the caller's grant before its if: runs. # A block naming id-token would fail every caller that does not grant it at startup, including a smoke build under a read-only pull request token. steps: @@ -532,7 +533,8 @@ jobs: if: ${{ inputs.github && !inputs.smoke && !failure() && !cancelled() }} runs-on: ubuntu-latest needs: [get-version, validate-release, build-executable, build-nuget, build-pypi, build-docker] - # The release upload and the artifact-delete cleanup both write with GITHUB_TOKEN, and the caller grants contents: write and actions: write on a publish. + # The release upload and the artifact-delete cleanup both write with GITHUB_TOKEN, and the caller grants contents: write and actions: write when it sets github: true on a non-smoke run. + # A caller publishing only to a registry, or smoke building, leaves this job disabled and grants neither. # No job-level permissions: block here, for the reason build-nuget gives: a block is validated against the caller's grant before if: runs, and a smoke caller holds a read-only token. steps: diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index e3b0b1ce..46c4cfc5 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -420,7 +420,7 @@ A repo whose publisher needs the release-gate decision reaches `publish-plan-tas ## Adopting the Release Chain -A downstream repo replaces the whole of its `.github/workflows/build-release-task.yml`, and every per-target leaf task it carries (`build-executable-task.yml`, `build-nugetlibrary-task.yml`, `build-pypilibrary-task.yml`, `build-docker-task.yml`), with a caller stub in its own `publish-release.yml` reaching the hub tasks by pin. `test-pull-request.yml`'s smoke job calls `build-release-task.yml` the same way, with `smoke: true` and the paths-filter's `enable_*` outputs. Nothing here lands as a catalog snippet in this pull request, since a caller stub's pin can only name a released hub commit and no release yet carries `build-release-task.yml` ([Pinning][pinning]). The snippet follows the release that first ships it, tracked in the [Rollout][rollout] section below. The task declares no job-level `permissions:` of its own, because a called job's block is validated against the caller's grant before its `if:` runs and would fail a caller that does not grant it at startup. The caller therefore grants what the targets it enables need: `contents: write` and `actions: write` on a publish, `id-token: write` where `nuget: true`, and nothing beyond `contents: read` on a smoke build, where a Dependabot pull request holds a read-only token. +A downstream repo replaces the whole of its `.github/workflows/build-release-task.yml`, and every per-target leaf task it carries (`build-executable-task.yml`, `build-nugetlibrary-task.yml`, `build-pypilibrary-task.yml`, `build-docker-task.yml`), with a caller stub in its own `publish-release.yml` reaching the hub tasks by pin. `test-pull-request.yml`'s smoke job calls `build-release-task.yml` the same way, with `smoke: true` and the paths-filter's `enable_*` outputs. Nothing here lands as a catalog snippet in this pull request, since a caller stub's pin can only name a released hub commit and no release yet carries `build-release-task.yml` ([Pinning][pinning]). The snippet follows the release that first ships it, tracked in the [Rollout][rollout] section below. The task declares no job-level `permissions:` of its own, because a called job's block is validated against the caller's grant before its `if:` runs and would fail a caller that does not grant it at startup. The caller therefore grants only what its enabled paths write with: `contents: write` and `actions: write` when it sets `github: true` on a non-smoke run (the release upload and the artifact cleanup), `id-token: write` when it sets `nuget: true` (a real push through `NuGet/login`), and nothing beyond `contents: read` on a build-only or smoke run, where a Dependabot pull request holds a read-only token. The stub keeps its own trigger policy exactly as today: `workflow_dispatch` plus a main-only weekly `schedule` for a Docker repo, or `workflow_dispatch` plus a paths-filtered `push` to `main` for a NuGet or PyPI repo whose merges should auto-publish. What moves to the hub is the release-gate decision, the build/version/publish job graph, and the Docker core, never the trigger. This is the full shape, a NuGet-library repo whose merges publish: