diff --git a/.github/workflows/add-to-kanban.yml b/.github/workflows/add-to-kanban.yml index 07283e30..2a3a432b 100644 --- a/.github/workflows/add-to-kanban.yml +++ b/.github/workflows/add-to-kanban.yml @@ -9,6 +9,11 @@ on: jobs: add-to-project: runs-on: ubuntu-latest + # NO GITHUB_TOKEN AT ALL (saadqbal, #2181). Every call in this job authenticates + # as the App, so the workflow token needs nothing -- and an empty grant is the + # only version of that claim a reader can check. Free, and it means the least- + # privilege story covers both credentials in the job rather than just the loud one. + permissions: {} steps: # Board writes authenticate as the tracebloc-release-train App (backend#2036), # not a human's PAT. `owner:` yields an ORG-scoped installation token; a @@ -25,6 +30,60 @@ jobs: app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} + # SCOPED TO THIS REPO, or the two reads below land org-wide (saadqbal, + # #2181). `owner:` alone does not narrow anything -- run 32239403796 says + # so in as many words: "Input 'repositories' is not set. Creating token for + # all repositories owned by tracebloc." A token calling itself + # least-privilege while carrying issue+PR read across all 19 installed + # repos is the claim this PR exists to stop making. + # + # `organization_projects` is an ORG-level permission and is not affected by + # repo scoping, so the board write should be unchanged -- but that is an + # assumption, and it is the same class of assumption that broke the first + # attempt, so the verification run is what settles it rather than this + # comment. If it is wrong the failure is LOUD (see below), which is what + # makes trying it cheap. + repositories: ${{ github.event.repository.name }} + # Least privilege (backend#2166): without any `permission-*` the token + # carries the App's FULL installation grant. actions/add-to-project needs + # THREE scopes, not one: it must RESOLVE the triggering issue/PR node + # before it can add it to the board, so it needs read on both content + # types (this workflow fires on `issues` and `pull_request`) in addition + # to the project write. Projects-write alone leaves the node unresolvable + # -- the add fails with "Could not resolve to a node with the global id". + # + # WHAT IS ACTUALLY DEMONSTRATED, and what is not. Stated narrowly because + # two earlier versions of this paragraph each overclaimed in a different + # direction, and this text is copied verbatim into 17 repos -- a wrong + # argument here is a wrong argument 17 times, in a byte-compared file + # nobody re-derives. + # + # DEMONSTRATED: a MISSING READ scope fails loudly. Run 32239403796 on this + # branch, at commit 218f0b13 (projects-write only), errored with + # `Could not resolve to a node with the global id` and the job went RED -- + # `add-to-project` routes GraphQL errors through `setFailed`. + # + # NOT DEMONSTRATED: the case the FIRST version of this comment described -- + # the token resolving the node fine and then 403ing the BOARD WRITE. No run + # has ever produced it. So "fails loudly" is proven for the read scopes and + # is an expectation, not a measurement, for the write. + # + # AND ONE RUN THAT LOOKED LIKE EVIDENCE IS NOT (aptracebloc). The previous + # wording cited run 32237283072 as a second scope failure. It is not one: + # it ran on `develop`, whose file at that sha passes NO `permission-*` at + # all (the App's full grant), and it failed on + # `Could not resolve to a node with the global id of I_kwDONNfQt88...` -- + # a node a fully-privileged token also cannot see, i.e. an issue that no + # longer exists (this workflow fires on `issues: transferred`). Run + # 32237067262, the SAME develop sha, succeeded 2m34s earlier. A dead node, + # not a permission. + # + # The proof this is right is therefore still a LANDED CARD, not a passing + # mint: a mint can succeed with a scope the board write then needs and + # lacks, and that is the one path nothing here has exercised. + permission-issues: read + permission-pull-requests: read + permission-organization-projects: write - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 with: diff --git a/.github/workflows/backend-fixtures-drift.yml b/.github/workflows/backend-fixtures-drift.yml index 0ab93a36..4c5fc8eb 100644 --- a/.github/workflows/backend-fixtures-drift.yml +++ b/.github/workflows/backend-fixtures-drift.yml @@ -19,12 +19,24 @@ name: Backend fixtures drift # org Actions secret. Until that secret exists the job SKIPS with a warning # instead of failing: the gate is inert, not red (same activation model as # the public PII gate's denylist secret). +# +# THAT ACTIVATION PHASE IS OVER. The secret has existed since 2026-08-05 and +# this check is being armed as a required context (backend#2212), so the +# skip-with-a-warning path is now a fail-open: a *required* check that exits 0 +# when it cannot run is worse than an advisory one, because it also looks +# solved. It now FAILS instead -- see the step below for the three-way split +# and why a public repo needs an override label rather than a silent pass. on: push: branches: [develop, main] pull_request: branches: [develop, main] + # labeled/unlabeled so the skip-fixtures-drift override actually re-runs the + # gate. The default types are opened/synchronize/reopened, so without these + # applying the label would change nothing until the next push -- exactly the + # bug Bugbot caught on version-bump-gate-caller.yml's skip-version-gate. + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] workflow_dispatch: permissions: @@ -42,13 +54,69 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Three-way, because this repo is PUBLIC and the two reasons the token can + # be missing are not the same thing: + # + # token present -> run the check (the normal path) + # absent, Dependabot PR -> PASS with a ::notice::. Dependabot runs get + # DEPENDABOT-scoped secrets, never Actions + # secrets, so the token is empty on them + # however correctly it is set -- and unlike a + # fork, head.repo.fork is FALSE, so they would + # otherwise land in the misconfiguration branch + # below and block every security bump (Bugbot, + # averaging-service#367). Safe to defer here: + # a dependency bump cannot alter + # internal/api/testdata/*.json, and if it did, + # internal/api/contracts_test.go replays every + # fixture through the real decode paths under + # the REQUIRED `Test` check, with no token. + # Divergence from the pinned backend ref is + # re-checked by the push run on develop/main, + # where Actions secrets are available. Set the + # token as a Dependabot secret too and this + # branch stops being reached. + # absent, fork PR -> FAIL. GitHub does not expose repo secrets to + # forks by design, so the check genuinely + # cannot run. A maintainer verifies the + # fixtures by hand and applies + # `skip-fixtures-drift`, which is a permanent + # artifact on the PR -- the same model as + # skip-fr-gate. Passing forks silently would + # fail open on exactly the contributions that + # warrant the most scrutiny. + # absent, not a fork -> FAIL. The secret was removed, rotated or + # expired. That is a misconfiguration, and it + # used to read as a clean run. + # + # Every ${{ }} goes through env:, never into the run: body. - name: scripts/sync-backend-fixtures.sh --check env: BACKEND_CONTRACTS_TOKEN: ${{ secrets.BACKEND_CONTRACTS_TOKEN }} + IS_FORK: ${{ github.event.pull_request.head.repo.fork || false }} + # The PR author, not github.actor: stable across re-runs of the same PR. + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-fixtures-drift') }} run: | - if [ -z "$BACKEND_CONTRACTS_TOKEN" ]; then - echo "::warning::BACKEND_CONTRACTS_TOKEN is not set — skipping the backend fixtures drift check." \ - "Add a read-only (Contents: read) token for tracebloc/backend as a repo/org Actions secret to activate this gate." + set -uo pipefail + + if [ "$OVERRIDE" = "true" ]; then + echo "::warning title=Backend fixtures drift OVERRIDDEN::The skip-fixtures-drift label is applied, so this gate did NOT verify internal/api/testdata/*.json against tracebloc/backend. Whoever applied the label is asserting they checked the fixtures by hand. The label stays on the PR as the record." exit 0 fi - ./scripts/sync-backend-fixtures.sh --check + + if [ -n "$BACKEND_CONTRACTS_TOKEN" ]; then + exec ./scripts/sync-backend-fixtures.sh --check + fi + + if [ "$PR_AUTHOR" = "dependabot[bot]" ]; then + echo "::notice title=Backend fixtures drift deferred::Dependabot runs receive Dependabot-scoped secrets, not Actions secrets, so BACKEND_CONTRACTS_TOKEN is empty here by design. A dependency bump cannot alter internal/api/testdata/*.json, and internal/api/contracts_test.go replays every fixture through the real decode paths under the required Test check without needing a token. Drift against the pinned backend ref is re-checked by the push run on develop/main. Set BACKEND_CONTRACTS_TOKEN as a Dependabot secret too for full coverage here." + exit 0 + fi + + if [ "$IS_FORK" = "true" ]; then + echo "::error title=Backend fixtures drift could not run::This PR is from a fork, and GitHub does not expose BACKEND_CONTRACTS_TOKEN to forks, so the vendored fixtures could not be checked against tracebloc/backend. A maintainer must verify internal/api/testdata/*.json by hand and apply the 'skip-fixtures-drift' label. Refusing to report green on a check that did not run (backend#2212)." + else + echo "::error title=BACKEND_CONTRACTS_TOKEN is missing::The secret is not readable on this run, so the backend fixtures drift check did not execute. It is expected on develop/main PRs from this repo -- if it was rotated or removed, restore a read-only (Contents: read) token for tracebloc/backend as a repo/org Actions secret. This step used to exit 0 here, which reported a check that never ran as a passing one (backend#2212)." + fi + exit 1