Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 46 additions & 24 deletions .github/workflows/dependabot-digest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ name: Dependabot Digest
# scheduled job should route around.

on:
# Daily schedule (cron '0 14 * * *') removed 2026-10-01: every run failed
# on token scope. Restore it once dev-env#149 lands the GitHub App.
schedule:
# 14:00 UTC daily — morning in US Pacific, so the queue is current when
# the day starts.
- cron: '0 14 * * *'
# A scheduled workflow runs only on the default branch, so a pull request
# cannot exercise it. This is how the first run gets triggered and how the
# job is tested after a change.
Expand All @@ -36,37 +38,57 @@ jobs:
permissions:
contents: read
# Writes the digest issue in this repository. The PR data itself is read
# with the per-owner tokens below, which carry no write access anywhere.
# with the per-owner app tokens, which carry no write access anywhere.
issues: write
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Mint a token for smartwatermelon
id: token-smartwatermelon
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.DIGEST_APP_CLIENT_ID }}
private-key: ${{ secrets.DIGEST_APP_PRIVATE_KEY }}
owner: smartwatermelon # zizmor: ignore[github-app] whole-fleet survey; no repo subset exists
permission-checks: read
permission-contents: read
permission-pull-requests: read
permission-statuses: read

- name: Mint a token for nightowlstudiollc
id: token-nightowlstudiollc
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.DIGEST_APP_CLIENT_ID }}
private-key: ${{ secrets.DIGEST_APP_PRIVATE_KEY }}
owner: nightowlstudiollc # zizmor: ignore[github-app] whole-fleet survey; no repo subset exists
permission-checks: read
permission-contents: read
permission-pull-requests: read
permission-statuses: read

- name: Mint a token for twistedmelonman
id: token-twistedmelonman
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.DIGEST_APP_CLIENT_ID }}
private-key: ${{ secrets.DIGEST_APP_PRIVATE_KEY }}
owner: twistedmelonman # zizmor: ignore[github-app] whole-fleet survey; no repo subset exists
permission-checks: read
permission-contents: read
permission-pull-requests: read
permission-statuses: read

- name: Build and publish the digest
env:
# One token per owner: a fine-grained PAT is "limited to access
# resources owned by a single user or organization" (GitHub docs), so
# three owners need three tokens. Each needs Pull requests, Contents,
# Commit statuses and Metadata — all read-only, on all repositories
# for that owner.
#
# Those four are the most a fine-grained token can be given, and they
# are NOT enough to survey private repos: a fine-grained PAT has no
# Checks permission at all
# (github.com/orgs/community/discussions/129512), so it cannot read
# GitHub Actions results there. GitHub does not error — it returns
# statusCheckRollup with HTTP 200 and nulls every CheckRun, which
# would turn failing builds into "nothing failing". collect.sh
# refuses such a response rather than under-report.
#
# Resolving that needs a classic PAT (write access fleet-wide), a
# GitHub App (which does have Checks), or accepting public-repo-only
# coverage. See docs/runbooks/dependabot-digest-tokens.md.
DIGEST_TOKEN_SMARTWATERMELON: ${{ secrets.DIGEST_TOKEN_SMARTWATERMELON }}
DIGEST_TOKEN_NIGHTOWLSTUDIOLLC: ${{ secrets.DIGEST_TOKEN_NIGHTOWLSTUDIOLLC }}
DIGEST_TOKEN_TWISTEDMELONMAN: ${{ secrets.DIGEST_TOKEN_TWISTEDMELONMAN }}
# Per-owner app tokens minted above. Why an app, not PATs:
# docs/runbooks/dependabot-digest-credentials.md
DIGEST_TOKEN_SMARTWATERMELON: ${{ steps.token-smartwatermelon.outputs.token }}
DIGEST_TOKEN_NIGHTOWLSTUDIOLLC: ${{ steps.token-nightowlstudiollc.outputs.token }}
DIGEST_TOKEN_TWISTEDMELONMAN: ${{ steps.token-twistedmelonman.outputs.token }}
# A token that has lost private-repo access still answers searches
# successfully, returning only public results. Naming one private
# repo per owner lets the collector prove it can still see private
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This repo is the **dev-env infrastructure repository** — it contains documenta
- `docs/token-rotation.md` — Where each `CLAUDE_CODE_OAUTH_TOKEN` lives and when it expires; never contains a token
- `docs/runbooks/fleet-probe-token-scopes.md` — The two fine-grained-PAT properties a fleet probe needs (`Administration: Read-only` + All-repositories), and why an under-scoped token returns wrong numbers instead of errors
- `scripts/org-migration/` — Snapshot/transfer/verify tooling for the 2026-09 org migration; tests in `scripts/org-migration/tests/run-tests.sh`
- `scripts/dependabot-digest/` — Collects open Dependabot PRs across all three owners and upserts one digest issue describing the queue; run by `.github/workflows/dependabot-digest.yml`. Tokens are installed by hand: see `docs/runbooks/dependabot-digest-tokens.md`
- `scripts/dependabot-digest/` — Collects open Dependabot PRs across all three owners and upserts one digest issue describing the queue; run by `.github/workflows/dependabot-digest.yml`. Credentials (a GitHub App) are installed by hand: see `docs/runbooks/dependabot-digest-credentials.md`
- `.claude/` — Project-specific Claude Code configuration templates
- `.claude/config.sh.template` — Template for project configuration (Node version, required tools, deployment secrets, build/deploy hooks)
- `.project-hooks/pre-commit` and `.project-hooks/pre-push` — Project-specific git hook extensions, run by the global hooks at `~/.config/git/hooks/` when executable
Expand Down
105 changes: 105 additions & 0 deletions docs/runbooks/dependabot-digest-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Dependabot digest: GitHub App credentials

The `Dependabot Digest` workflow (`.github/workflows/dependabot-digest.yml`)
reads open Dependabot pull requests across all three owners and rewrites one
issue in `smartwatermelon/dev-env` describing the queue. It authenticates as a
GitHub App, `dependabot-digest-swm`, and mints one short-lived installation
token per owner on each run with `actions/create-github-app-token`.

**These steps are yours, not an agent's.** Creating an app, generating its key
and installing a secret are human operations.

## Why a GitHub App and not a fine-grained PAT

A fine-grained PAT has no Checks permission at all
(<https://github.com/orgs/community/discussions/129512>), so it cannot read
GitHub Actions results on a private repository however it is scoped. GitHub
does not error: it answers `statusCheckRollup` with HTTP 200 and the correct
`totalCount`, then nulls every CheckRun. Measured 2026-09-11 on
`nightowlstudiollc/kebab-tax-netlify#280`, 11 of 12 contexts came back null, so
seven failing builds read as "1 failing check". An app does have Checks, and
one installation per owner covers all three owners, which a PAT cannot.
`collect.sh` still refuses any rollup with a nulled context, which is the
proof that this path works: a published digest means every check was read.

## The app

- **Name**: `dependabot-digest-swm`, installed on `smartwatermelon`,
`nightowlstudiollc` and `twistedmelonman`, **All repositories** each.
- **Repository permissions**, all read-only: Checks, Contents, Metadata,
Pull requests, Commit statuses. Nothing else. Webhook inactive.
- The private key lives in 1Password vault `Automation`, item `DIGEST_APP`
(fields `client_id`, `private_key`). Use the attached
`dependabot-digest-swm.2026-10-01.private-key.pem`, not the `private_key`
field: 1Password flattens a pasted PEM to one line, which is unusable.

## Install the secrets

Both go on `smartwatermelon/dev-env` as repository secrets, because that is
where the workflow runs:

```bash
gh secret set DIGEST_APP_CLIENT_ID --repo smartwatermelon/dev-env
op read "op://Automation/DIGEST_APP/dependabot-digest-swm.2026-10-01.private-key.pem" \
| gh secret set DIGEST_APP_PRIVATE_KEY --repo smartwatermelon/dev-env
```

The first command prompts for the client ID. The second pipes the key file
straight from 1Password, so its line breaks survive.

Repository secrets rather than org secrets, deliberately: `dev-env` is the only
repo that runs this, and org-level secrets do not reach private repos on the
free plan (see `docs/token-rotation.md`). The key does not expire, so there is
no rotation row to add; rotate it by generating a new key on the app's page.

## Verify

First capture what the answer should be, using your own credentials as the
reference. Your local `gh` login can read all three owners, so this is the
known-good result the workflow must reproduce:

```bash
bash scripts/dependabot-digest/run-digest.sh --dry-run > /tmp/digest-local.md
grep -c '^| ' /tmp/digest-local.md # rows in the queue table
```

Then trigger a run by hand — a scheduled workflow only runs on the default
branch, so this is also how the very first digest gets created:

```bash
gh workflow run dependabot-digest.yml --repo smartwatermelon/dev-env
sleep 30
gh run list --workflow dependabot-digest.yml --repo smartwatermelon/dev-env --limit 1
```

**Do not trigger a second run within a few minutes of the first.** The digest
finds its issue partly through GitHub's body-search index, which lags creation
by an unbounded amount. A second run landing before the index catches up is
covered by an unindexed issue listing, but there is no reason to lean on the
fallback while verifying.

Then compare the published issue against the local reference:

```bash
gh issue list --repo smartwatermelon/dev-env --search 'dependabot-digest in:body' --state open
gh issue view <number> --repo smartwatermelon/dev-env --json body --jq '.body' > /tmp/digest-ci.md
diff /tmp/digest-local.md /tmp/digest-ci.md
```

Differences in counts and timestamps are expected — the queue moves between the
two runs. What must **not** differ is which owners appear. An installation that
is missing from one owner, or limited to selected repositories, can still
return an empty result set rather than an error.

The comparison, not the green run, is the evidence. A green run means the
scripts did not crash; only the diff shows the workflow saw the same fleet you
can see. The collector's private-repo probe catches a credential that lost private
access, but it cannot catch one that was scoped to the wrong owner.

## If the digest goes stale

GitHub disables scheduled workflows in public repositories after 60 days with
no repository activity. The digest issue carries its own generation timestamp
for this reason: a date more than a day or two old means the schedule stopped,
not that the queue is quiet. Re-enable it with `gh workflow enable
dependabot-digest.yml --repo smartwatermelon/dev-env`.
Loading
Loading