Skip to content

feat(pipeline): let a stage be granted permissions beyond the baseline - #58

Merged
pedromvgomes merged 2 commits into
mainfrom
feat/stage-permissions
Aug 25, 2026
Merged

feat(pipeline): let a stage be granted permissions beyond the baseline#58
pedromvgomes merged 2 commits into
mainfrom
feat/stage-permissions

Conversation

@pedromvgomes

@pedromvgomes pedromvgomes commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Why

Every orchestrator stage job gets a fixed baseline — contents: read + packages: read for CI, contents: write + packages: write for CD — and a called workflow can only ever narrow what its caller grants. A leaf needing a scope the baseline withholds cannot ask for it back. The template said so outright:

A stage needing more than this is a gt change.

This is that change, and wardnet is the repository it unblocks. Two of its eight pr.yml leaves need more:

leaf needs why
build-daemon security-events: write codeql-action/upload-sarif for clippy SARIF
tests-e2e packages: write --cache-to type=registry,...buildcache

Neither would fail loudly on migration. The SARIF upload and the cache export would just stop happening while the pipeline stayed green — which is precisely the "green forever" defect this rollout exists to remove. Without this the only honest option was pipeline.ci.enabled: false, leaving wardnet off the CI pipeline indefinitely.

What

pipeline:
  ci:
    stages: [preflight, build, test, end2end]
    stage_permissions:
      build:
        security-events: write   # codeql-action/upload-sarif
      end2end:
        packages: write          # --cache-to type=registry
  • Merged over the baseline: a named scope is raised (packages: readwrite), everything unnamed is untouched.
  • Per-stage. Only the named stage is widened; a grant never reaches its siblings.
  • Available for CD too (pipeline.cd.stage_permissions), since that template carried the same "is a gt change today" note.
  • Rendered comments explain a widened stage differently from a baseline one, so the diff says why a job holds a write scope.

Validation

Stage names and the scope/level vocabulary are both checked at sync time. The scope list is enumerated rather than passed through deliberately: GitHub ignores an unknown scope silently, so code-scanning: write would render a job that reads as widened, passes review, and fails later at the one API call it was meant to permit.

Rejected: a stage that isn't enabled, an unknown scope, an invalid level, an empty scope map, and any grant while the pipeline is disabled.

Backwards compatibility

This is the part worth checking rather than trusting, so I checked it. I rendered every file on origin/main gt and on this branch and diffed:

  • With comments stripped, both orchestrators are byte-identical. No directive moves, no repo's behaviour changes.
  • The field is omitempty, so no existing .gt-repo.yaml gains a key and the "only overrides are written" rule holds.
  • The comments do change, so the next fleet sync is a comment-only diff in ci-orchestration.yml (and cd- where enabled). Expected, and worth knowing before it shows up across 17 repos.

Scopes render sorted, so Go's randomised map iteration can't make a re-render look like drift — there's a test asserting 20 consecutive renders are identical.

Tests

  • baseline pinned for every CI and CD stage, so a future change can't silently rewrite the fleet
  • widening adds a withheld scope, raises an existing one, and leaves sibling stages untouched
  • CD widening
  • deterministic rendering
  • five new validation cases

go test ./..., go vet ./..., gofmt all clean.

Every orchestrator stage job is granted a fixed baseline — contents: read
+ packages: read for CI, contents: write + packages: write for CD — and a
called workflow can only ever NARROW what its caller grants. A leaf
needing a scope the baseline withholds cannot ask for it back, so until
now the template said so outright: "a stage needing more than this is a
gt change."

This is that change. wardnet is the repository it blocks: its build leaf
uploads clippy SARIF via codeql-action/upload-sarif, which needs
security-events: write, and its end2end leaf pushes a registry build
cache, which needs packages: write. Neither would have failed loudly on
migration — the upload and the cache export would simply have stopped
happening while the pipeline stayed green. The alternative was keeping
wardnet off the CI pipeline entirely.

A stage may now be widened by name:

    pipeline:
      ci:
        stage_permissions:
          build:
            security-events: write
          end2end:
            packages: write

The grant is merged over the baseline, so a named scope is raised
(packages: read -> write) and everything unnamed is untouched. Only the
named stage is affected; a grant never reaches its siblings.

Stage names and the scope/level vocabulary are both validated. An
unknown scope is rejected rather than rendered, because GitHub ignores
one silently: the job would read as widened, pass review, and fail later
at the single API call it was meant to permit.

The field is omitempty and the baseline is unchanged, so no existing spec
gains a key and no rendered directive moves. Verified by rendering every
file on both trees and diffing: with comments stripped the orchestrators
are byte-identical. The comments themselves do change, so the next sync
across the fleet is a comment-only diff.

Scopes are sorted, so map iteration order cannot make a re-render look
like drift.
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — see error output below

📦 Full bulwark output — complete scan and coverage logs.

gt governs itself, so changing the orchestrator template drifts gt's own
committed workflow and `gt repo check` fails — which is what the
governance stage reported on this branch.

Comment-only, exactly as predicted for the rest of the fleet: the three
stage jobs now point at pipeline.ci.stage_permissions instead of saying a
wider stage is a gt change. No directive moves.

gt_version stays v1.0.0. It has not moved since #31 and this is not the
change that should move it, so the sync ran from a build stamped v1.0.0
rather than letting an unversioned one repoint v1 workflows at v0 — which
sync refuses outright, correctly.
@pedromvgomes
pedromvgomes merged commit ea34c1b into main Aug 25, 2026
11 checks passed
@pedromvgomes
pedromvgomes deleted the feat/stage-permissions branch August 25, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant