From 3df05605d6476dc017356b240dddf2b59697352f Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 18 Sep 2026 20:32:46 -0500 Subject: [PATCH] Scope permissions per job so sync-device-links can start again The hourly device-links sync has failed at startup on every run since 2026-09-08 -- 10 days of msh.to link changes never reaching production. The published deviceLinks is still stamped generatedAt 2026-09-04T17:22:29Z. Cause is a reusable-workflow permissions mismatch. The `publish` job calls deploy.yml, which declares `issues: write` for its failure-reporting step. A called workflow cannot request a permission its caller does not hold, so GitHub rejected the run before any job started -- conclusion `startup_failure`, with no job log and no annotation explaining it, which is why this sat unnoticed while every other workflow stayed green. Rather than grant `issues: write` at workflow level, each job now declares exactly what it needs and the workflow floor drops to read-only: floor contents: read sync contents: write -- commits and pushes the catalog publish contents: read, issues: write -- exactly what deploy.yml declares This keeps issue-write away from `sync`, which is the job that fetches an external URL and pushes a commit, and it is also what zizmor's excessive-permissions audit wants: the file went from 4 findings to 0. Granting rather than removing is the right direction. Dropping `issues: write` from deploy.yml would have silenced the startup error by disabling its failure notification, which deploy.yml's own comment explains is load-bearing -- without it a failed deploy produces two failures and no alert, at the one moment an alert matters. This was latent rather than newly written. sync-device-links.yml only calls deploy.yml on the v2 side; the pre-merge master version did not (`git show 797c024:.github/workflows/sync-device-links.yml` has no such call). On v2 the schedule never ran, because GitHub fires `schedule:` only from the default branch -- so the pair first executed when v2 merged to master, and failed on its first scheduled run. Co-Authored-By: Claude Opus 5 --- .github/workflows/sync-device-links.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-links.yml b/.github/workflows/sync-device-links.yml index 9c24137..a361c69 100644 --- a/.github/workflows/sync-device-links.yml +++ b/.github/workflows/sync-device-links.yml @@ -15,12 +15,17 @@ concurrency: group: repo-write cancel-in-progress: false +# Read-only floor. Both jobs below declare exactly what they need, so nothing inherits a write +# scope it has no use for. permissions: - contents: write + contents: read jobs: sync: runs-on: ubuntu-latest + # Commits and pushes the refreshed catalog; that is the only elevated scope it needs. + permissions: + contents: write outputs: changed: ${{ steps.commit.outputs.changed }} sha: ${{ steps.commit.outputs.sha }} @@ -83,6 +88,16 @@ jobs: publish: needs: sync if: needs.sync.outputs.changed == 'true' + # Exactly what deploy.yml declares, and no more. A called workflow cannot request a permission + # its caller does not hold, so omitting `issues: write` here rejects the entire run before any + # job starts -- conclusion `startup_failure`, with no job log and no annotation saying why. + # That is what has been happening on every scheduled run since 2026-09-08. + # + # Set on this job rather than at workflow level so `sync` -- which fetches an external URL and + # pushes a commit -- never holds issue write access it has no use for. + permissions: + contents: read + issues: write uses: ./.github/workflows/deploy.yml with: ref: ${{ needs.sync.outputs.sha }}