Skip to content

docs: plan the pnpm release-age retry deferred from go-udap - #20

Closed
robinbowes wants to merge 7 commits into
mainfrom
docs/pnpm-release-age-retry
Closed

robinbowes wants to merge 7 commits into
mainfrom
docs/pnpm-release-age-retry

Conversation

@robinbowes

Copy link
Copy Markdown

Adds docs/plans/dependabot-pnpm-release-age-retry/README.md, writing up a requirement that was deferred here on 2026-07-25 and then never recorded.

yo61/go-udap decision 2026-07-25 chose Last Light over a per-repo workflow — correctly, the concern is fleet-wide — and cited a path in this repo for the requirement. That file was never created and this repo contains no reference to minimumReleaseAge anywhere. go-udap#187 hit the same failure again on 2026-08-04 as a result.

The problem

pnpm 11 enforces minimumReleaseAge (default 1440 min) on pnpm install --frozen-lockfile, rejecting lockfile entries published inside that window. Dependabot regenerates the lockfile on every bump, so it routinely pulls in a package published minutes earlier:

[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 2 lockfile entries failed verification:
  baseline-browser-mapping@2.11.12 was published at 2026-08-03T16:28:56.000Z,
    within the minimumReleaseAge cutoff (2026-08-03T09:44:20.214Z)

Nothing is broken. The same lockfile passes untouched a few hours later.

Dependabot's own cooldown cannot prevent this — it gates the direct bump targets, while these failures come from transitively re-resolved deps. On go-udap#176 the direct deps were 8–10 days old and correctly gated; the breakage was a coordinated @radix-ui/* release published ~3h earlier.

Why it needs new machinery

Every retry path today is event-driven — attempt N+1 fires when pr.checks_failed arrives after our push. Here nothing pushes, and no event fires when a clock passes.

No existing DiagnosisClass fits either. upstream-broken is closest (not our fault, self-heals, attempt-free) but is detected from baseChecksState and waits for a base-goes-green event that never comes. The doc has the full table.

The useful part

The clear time is computable from the error text — no npm query, no polling:

minimumReleaseAge = runStartedAt - cutoff
retryAt           = max(published_at) + minimumReleaseAge

So one scheduled rerun at retryAt, via rerun-failed-jobs. No commit, no sandbox.

What the doc rules out

  • Regenerating the lockfile — re-resolution can pull something younger, restarting the clock. go-udap#185 → #187 is the worked example: @dependabot recreate discarded a fully green run and produced one failing on two different fresh packages.
  • Merging the base branch — creates an unsigned commit, terminal on the 22 yo61 repos enforcing required_signatures. See docs: plan signed branch updates for signature-enforcing repos #19.
  • minimumReleaseAge: 0 — discards supply-chain protection to dodge a self-resolving delay.

Four open questions are left explicit, the main one being where a one-shot timer at an arbitrary computed timestamp lives, given the crons are fixed-schedule sweeps.

🤖 Generated with Claude Code

cliftonc and others added 7 commits August 2, 2026 17:46
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dependency-PR-resilience and stuck-PR-recovery work replaced state read
from six sites over seven stores with one resolved `PrState` snapshot per
dispatch plus pure decisions over it. The spec documents that well; the docs
site did not. The four workflow pages each restated a fragment — `requires-human`
five times across two pages, the run lock three times, the dispatch gate twice —
none of them named the snapshot, showed the ordering of the guards, or drew
anything, and nowhere did the site say why a team should care.

New `/docs/pr-state` page in a Concepts nav section above Workflows. It leads
with the team problem rather than the refactor: a pull request is the unit of a
release workflow, several actors work it at once, and without one agreed answer
you get duplicate pushes, repeated escalation comments, reviews of a tree being
rewritten, and PRs that stop silently. Then the mechanism, anchored on a real
run's PR-state panel.

Five hand-authored SVG diagrams (no mermaid on this site, and docs pages are
.astro rather than markdown): the choke point, the ten-guard ladder colour-coded
by exit kind, the fix attempt machine, the review resolver's three verdicts and
the check each leaves, and the dependency lane with both daily crons. The ladder
is generated from a frontmatter array so it cannot drift out of order.
`generate-md.mjs` strips `svg` from the .md mirrors, so every diagram has a prose
or table equivalent beside it — the SVG is never the sole carrier of a fact. The
diagrams hold a `min-width` floor so a phone scrolls the card instead of scaling
8px labels into illegibility.

The four workflow pages lose the duplicated prose and link here instead. Their
"the four PR-scoped workflows" wording is now false — the set is derived from
each workflow's own `pr_scoped: true` key, so an overlay fork keeps the gate —
and is softened to name the packaged four as an example.

Also fixes a pre-existing break in the prev/next chain: Configuration's
"Previous" pointed at Slack integration, skipping the whole Workflows section.

Docs-only. No `apps/server/spec` changes — the spec is already current here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A GitHub App is installed per ACCOUNT, and each installation mints its own
tokens. The harness threaded one statically-configured
`GITHUB_APP_INSTALLATION_ID` into every mint and every App-authed Octokit, so
everything against a second account failed: token mints 422'd ("at least one
repository ... is not accessible to the parent installation"), and every
harness-side call — comments, reactions, check runs, `.lastlight/` fetches,
post-review — 404'd.

Installations are now DISCOVERED and resolved per repository owner.

- `InstallationDirectory` (engine/github/installations.ts) is the single
  owner→installation authority. Fed by every webhook's `payload.installation`
  and by `GET /app/installations` under an App JWT; concurrent misses share one
  request and negatives are cached briefly, so a cron fan-out over N repos costs
  one call, not N. Suspended installations are withheld from resolution (they
  403 every mint) but stay listed, flagged, for the admin surface.

- `GitHubClient` resolves its Octokit per owner, memoized per installation.
  Every method already took `owner` first, so ~50 call sites — `DispatchDeps`,
  the router, dispatcher, pr-state, review-check, repo-config — are unchanged.
  The read-only chat tools get the same treatment; the two `search` tools read
  the account from the query's `repo:`/`org:`/`user:` qualifier.

- The per-run mint resolves from `githubAccess.owner`. An owner with no usable
  installation fails the phase immediately, naming the ACCOUNT — no sandbox, no
  API call.

- Installation repo discovery is keyed by installation id. Those webhooks are
  per-account: against one flat set a second org's `created` reset the managed
  list to just that org and its `deleted` cleared it entirely. `suspend` /
  `unsuspend` are handled too — previously they fell through silently.

- `GITHUB_APP_INSTALLATION_ID` is now OPTIONAL, kept only as the fallback for
  when the JWT lookup itself fails, so an existing single-installation
  deployment keeps its old behaviour with no .env edit.

- `GET /admin/api/managed-repos` reports every installation (account, id, repo
  count, selection, suspended) plus `uninstalledOwners`; Config → Managed repos
  renders them and warns when a managedRepos owner has no installation — the
  condition is visible before it becomes a failed run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The installations pane named an account and an id and left you to build the
URL by hand — which is the one thing you actually want to do next from every
question it raises (change the repo grant, un-suspend, uninstall).

The path shape depends on the account type and guessing wrong 404s: an org
install lives at `/organizations/<login>/settings/installations/<id>`, a
personal one at a viewer-scoped `/settings/installations/<id>`. So
`installationSettingsUrl()` builds it server-side from the account type, and
returns undefined when that type isn't known yet — a record seeded from a
webhook that carried no `account.type` — so the pane renders plain text rather
than a link that may not resolve. `note()` now takes `account.type` from the
payload and backfills it, so a webhook-learned install is linkable too.

The `uninstalledOwners` warning also gets `appInstallUrl` (derived from
`botName`, which IS the App slug), so it offers the fix instead of only naming
the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The go-udap decision of 2026-07-25 deferred this requirement here and
recorded a path for it; the document was never written, and go-udap#187
hit the same failure again on 2026-08-04.

pnpm 11 enforces minimumReleaseAge on install --frozen-lockfile,
rejecting lockfile entries published inside the window. Dependabot's
cooldown gates only direct bump targets, so transitively re-resolved
deps still trip it. Nothing is broken and the same lockfile passes once
the clock advances.

Every retry path today is event-driven, and no event fires when a clock
passes. Records that the clear time is computable from the error text
(published_at + (runStartedAt - cutoff)), that the action is
rerun-failed-jobs with no commit, and that regenerating the lockfile
restarts the clock rather than clearing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
robinbowes added a commit to yo61/go-udap that referenced this pull request Aug 4, 2026
The 2026-07-25 decision cited
lastlight/docs/plans/dependabot-pnpm-release-age-retry.md as the home of
the deferred requirement. That file was never written — lastlight held
no reference to minimumReleaseAge at all — which is why go-udap#187 hit
the same failure again on 2026-08-04.

The plan now exists as a directory, matching lastlight's convention
(yo61/lastlight#20). Update the pointer to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
robinbowes added a commit to yo61/go-udap that referenced this pull request Aug 4, 2026
* ci: remove local Dependabot auto-merge workflow

The workflow has never enabled auto-merge on any PR. Its guard tests
package-ecosystem == 'npm', but dependabot/fetch-metadata emits the
internal resolver name npm_and_yarn. Run 30897608171 shows update-type
and directory both matching and only the ecosystem comparison failing;
timeline events for #173, #178, #180, #184, #186 and #187 confirm no
auto-merge was ever enabled by it.

The failure was invisible because the check reports SUCCESS on every PR
— the job succeeds and only the step inside is skipped by its own if:.

Last Light's dependabot-pr-merge workflow already owns the classify ->
label -> auto-merge decision across managed repos and does enable
auto-merge here, so removing this changes no behaviour. dependabot-
automerge is not a required context, so the ruleset is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record Last Light signed-branch-update constraint

main enforces required_signatures, evaluated over every commit reachable
in a PR. Last Light builds branch-update merges with git in a sandbox
and configures no commit signing, so those commits arrive unsigned and
block the PR they were meant to unblock — as happened to #185.

Records that Last Light should use the update-branch API (server-side,
web-flow signed) or rerun-failed-jobs, rather than local git merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: point the pnpm-retry decision at the plan that now exists

The 2026-07-25 decision cited
lastlight/docs/plans/dependabot-pnpm-release-age-retry.md as the home of
the deferred requirement. That file was never written — lastlight held
no reference to minimumReleaseAge at all — which is why go-udap#187 hit
the same failure again on 2026-08-04.

The plan now exists as a directory, matching lastlight's convention
(yo61/lastlight#20). Update the pointer to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: verify update-branch produces a signed commit

The decision recorded update-branch's signing behaviour as documented
generally but unconfirmed for this endpoint. Running it against #189 and
#190 produced merge commits committed by GitHub <noreply@github.com>
with verified=true, reason=valid.

Also notes that update-branch writes "Merge branch 'main' into X" while
a sandbox merge writes "Merge remote-tracking branch 'origin/main' into
X", so the two are distinguishable in history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@robinbowes

Copy link
Copy Markdown
Author

Superseded by nearform#269, for the same reason as #19: this is a fork, and the work belongs on the upstream tracker rather than as a fork-local plan directory.

@robinbowes robinbowes closed this Aug 4, 2026
@robinbowes
robinbowes deleted the docs/pnpm-release-age-retry branch August 4, 2026 17: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.

2 participants