From d10116069553ff93b7d75fa6c48ed3681895590f Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:21:48 -0400 Subject: [PATCH] fix(repo-standard): one reusable workflow = one authority ceiling (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repo-standard.yml @ #54 raised its permission floor above `contents: read` (sbom needs `contents: write`, scorecard needs `actions: read`). GitHub validates a called reusable workflow's permission requests as the UNION of all its jobs, at load time, before any `if:` — so every caller, even a read-only spell/security PR run, had to grant the superset or hit a zero-jobs `startup_failure`. That is why #54 could not start and why conformance#19 is blocked. Restore one reusable workflow = one authority ceiling: - repo-standard.yml — stays `contents: read` (osv, dependency-review, spell, test, descriptor); drops the scorecard/sbom jobs + inputs - repo-scorecard.yml — `contents: read` + `actions: read` (scorecard) - repo-release.yml — `contents: write` (sbom); adds an `upload-release-assets` input so callers can take the SBOM artifact without a release upload Callers opt into the elevated ceilings with dedicated, event-gated jobs, so a PR run never instantiates write authority. Add repo-standard-selftest.yml: calls each reusable workflow at its intended ceiling. GitHub validates the permission union at load time, so a future revision that raises a floor fails here, pre-merge, instead of in every downstream repo — the self-test guard #55/#56 identified as missing. It also doubles as the reference caller template. Fixes #55. Closes #56. Unblocks conformance#19. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/repo-release.yml | 41 ++++++++++ .github/workflows/repo-scorecard.yml | 50 ++++++++++++ .github/workflows/repo-standard-selftest.yml | 57 ++++++++++++++ .github/workflows/repo-standard.yml | 83 ++++---------------- 4 files changed, 163 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/repo-release.yml create mode 100644 .github/workflows/repo-scorecard.yml create mode 100644 .github/workflows/repo-standard-selftest.yml diff --git a/.github/workflows/repo-release.yml b/.github/workflows/repo-release.yml new file mode 100644 index 0000000..218dcc5 --- /dev/null +++ b/.github/workflows/repo-release.yml @@ -0,0 +1,41 @@ +# repo-release — release-side artifacts (SPDX SBOM → release assets). +# +# Split out of repo-standard.yml (#55): the SBOM upload needs `contents: write`, the +# highest ceiling of the old monolith. Because a caller must grant the UNION of every +# job's permissions at load time, leaving this in repo-standard forced even a read-only +# spell run to grant contents: write — the escalation that made #54 fail to start. +# One reusable workflow, one authority ceiling: a caller grants contents: write here and +# nowhere else, on a dedicated job gated to release/publish (non-PR) events. +name: repo-release + +on: + workflow_call: + inputs: + upload-release-assets: + description: "Attach the SBOM to the GitHub Release (needs contents: write). False = artifact only (e.g. self-test / no release)." + type: boolean + default: true + +permissions: + contents: read + +jobs: + sbom: + # Release-side: runs on push/tags/release, not PR. + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Generate SBOM (SPDX) + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + format: spdx-json + artifact-name: sbom.spdx.json + output-file: sbom.spdx.json + upload-artifact: true + upload-release-assets: ${{ inputs.upload-release-assets }} diff --git a/.github/workflows/repo-scorecard.yml b/.github/workflows/repo-scorecard.yml new file mode 100644 index 0000000..5d21e84 --- /dev/null +++ b/.github/workflows/repo-scorecard.yml @@ -0,0 +1,50 @@ +# repo-scorecard — OSSF Scorecard supply-chain posture (report-only). +# +# Split out of repo-standard.yml (#55): Scorecard needs `actions: read`, one step above +# the standard `contents: read` floor. Because a reusable workflow's caller must grant +# the UNION of every job's permissions at load time, folding this into repo-standard +# would force ~every repo (scorecard was default-on) to grant actions: read even for a +# read-only spell/security run. Keeping it here — one reusable workflow, one authority +# ceiling — lets a caller grant exactly this ceiling, on a dedicated job. +# +# A caller opts in by adding a job that `uses:` this workflow and grants +# `contents: read` + `actions: read`, gated to non-PR events (Scorecard scores the +# repo, not a diff). See repo-standard-selftest.yml for the reference caller. +name: repo-scorecard + +on: + workflow_call: {} + +permissions: + contents: read + +jobs: + scorecard: + # Scorecard runs on push/schedule, not PR — it scores the repo, not a diff. + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + # Report-only: publish_results=false and no SARIF upload (upload needs Advanced + # Security on private repos), so results are kept as an artifact. That keeps the + # perms minimal (contents+actions read) so callers don't need security-events. + - name: OSSF Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: false + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: scorecard-results + path: results.sarif + retention-days: 5 diff --git a/.github/workflows/repo-standard-selftest.yml b/.github/workflows/repo-standard-selftest.yml new file mode 100644 index 0000000..401b027 --- /dev/null +++ b/.github/workflows/repo-standard-selftest.yml @@ -0,0 +1,57 @@ +# repo-standard-selftest — the reference caller, and the guard #55 lacked. +# +# repo-standard.yml (and its siblings) are reusable workflows, but nothing in this repo +# CALLED them — so #54 raised repo-standard's permission floor above contents: read and +# passed this repo's CI, only failing once a downstream caller bumped its pin (#55, #56). +# +# This workflow calls each reusable workflow with exactly its intended authority ceiling. +# GitHub validates the permission union at load time, before `if:`, so a future revision +# that raises a floor fails HERE, pre-merge, instead of in every downstream repo. It is +# also the copy-paste caller template each repo's .github/workflows/standard.yml follows. +name: repo-standard-selftest + +on: + pull_request: + paths: + - ".github/workflows/repo-standard.yml" + - ".github/workflows/repo-scorecard.yml" + - ".github/workflows/repo-release.yml" + - ".github/workflows/repo-standard-selftest.yml" + push: + branches: [main] + +# No ambient authority — every capability is granted per-job, scoped to the workflow it +# invokes. This is the pattern callers copy. +permissions: {} + +jobs: + # Contract: repo-standard.yml starts with ONLY contents: read (the #55 regression). + standard: + uses: ./.github/workflows/repo-standard.yml + permissions: + contents: read + with: + security: true + spell: false # this repo has no cspell.json + test: false + descriptor: false + + # Contract: repo-scorecard.yml's ceiling is exactly contents: read + actions: read. + # Gated to non-PR so PR runs never instantiate the elevated grant. + scorecard: + if: ${{ github.event_name != 'pull_request' }} + uses: ./.github/workflows/repo-scorecard.yml + permissions: + contents: read + actions: read + + # Contract: repo-release.yml's ceiling is exactly contents: write. upload-release-assets + # is false so the self-test exercises the permission contract without creating a release + # asset on this repo. Gated to non-PR (same as a real release caller). + release: + if: ${{ github.event_name != 'pull_request' }} + uses: ./.github/workflows/repo-release.yml + permissions: + contents: write + with: + upload-release-assets: false diff --git a/.github/workflows/repo-standard.yml b/.github/workflows/repo-standard.yml index 6a9a120..ee06ceb 100644 --- a/.github/workflows/repo-standard.yml +++ b/.github/workflows/repo-standard.yml @@ -4,16 +4,22 @@ # See bounded-systems/conformance for the caller template + rollout. # # Behaviors (all opt-in): -# security (default true) — OSV-Scanner (report-only) + dependency-review (PR gate) -# scorecard (default true) — OSSF Scorecard supply-chain posture (report-only) -# sbom (default false) — SPDX SBOM (uploads release assets; needs contents: write) -# spell (default false) — cspell dictionary/allowlist gate (needs a cspell.json) -# test (default false) — set up `runtime` and run `test-command` +# security (default true) — OSV-Scanner (report-only) + dependency-review (PR gate) +# spell (default false) — cspell dictionary/allowlist gate (needs a cspell.json) +# test (default false) — set up `runtime` and run `test-command` +# descriptor (default false) — README managed blocks must match trellis.json # -# All actions SHA-pinned (org policy). Invocations copied from prx's proven -# osv-scanner.yml / dependency-review.yml / scorecard.yml / sbom.yml, generalized -# to be language-agnostic. scorecard/sbom raise the security floor org-wide — see -# workflow-standardization: they previously ran on ~2 of 72 repos. +# AUTHORITY FLOOR — this workflow is `contents: read` and every job stays within it, +# so a caller only ever needs to grant `contents: read`. GitHub validates a called +# reusable workflow's permission requests as the UNION of all its jobs, at load time, +# before any `if:` — so a single job needing more would force every caller to over-grant +# (that was #55). Jobs that need more authority therefore live in sibling reusable +# workflows, one per ceiling, so a read-only caller is never forced above the floor: +# scorecard → repo-scorecard.yml (adds actions: read) +# sbom → repo-release.yml (adds contents: write) +# Callers opt into those with a dedicated, event-gated job (see #55 / #56). +# +# All actions SHA-pinned (org policy). name: repo-standard on: @@ -23,14 +29,6 @@ on: description: "Run OSV-Scanner + dependency-review." type: boolean default: true - scorecard: - description: "Run OSSF Scorecard (report-only supply-chain posture)." - type: boolean - default: true - sbom: - description: "Generate an SPDX SBOM (uploads to release assets; caller must grant contents: write)." - type: boolean - default: false spell: description: "Spell-gate: fail on tokens not in the dictionaries or the repo's cspell.json words allowlist (needs a cspell.json in the repo)." type: boolean @@ -108,57 +106,6 @@ jobs: with: fail-on-severity: high - scorecard: - # OSSF Scorecard runs on push/schedule (not PR — it scores the repo, not a diff). - if: ${{ inputs.scorecard && github.event_name != 'pull_request' }} - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - steps: - - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - # Report-only: publish_results=false and no SARIF upload (upload needs Advanced - # Security on private repos), so results are kept as an artifact. That keeps the - # perms minimal (contents+actions read) so callers don't need security-events. - - name: OSSF Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: false - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() - with: - name: scorecard-results - path: results.sarif - retention-days: 5 - - sbom: - # Opt-in (default false): uploads to release assets, so the caller must grant - # contents: write. Meant for repos that publish releases. - if: ${{ inputs.sbom && github.event_name != 'pull_request' }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Generate SBOM (SPDX) - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - with: - format: spdx-json - artifact-name: sbom.spdx.json - output-file: sbom.spdx.json - upload-artifact: true - upload-release-assets: true - spell: # Opt-in (default false): fails on any token not in the dictionaries or the # repo's cspell.json `words` allowlist. Catches nonsense / promotional