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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ jobs:
- name: πŸ§ͺ Self-test validator installation recovery
run: bash scripts/install-skills-ref.test.sh

- name: πŸ§ͺ Self-test the open-PR recheck
# Proves recheck-open-prs.sh re-triggers each open PR in the one order that is safe
# (close BEFORE reopen), never leaves a PR closed when a step fails mid-sequence,
# restores an armed auto-merge without arming one that was not, and fails closed on
# an unparseable listing rather than reading it as "no open PRs". Hermetic: `gh` is
# stubbed on PATH, so nothing reaches the network or touches a real pull request.
run: ./scripts/recheck-open-prs.test.sh

- name: πŸ§ͺ Self-test the version-bump helper
# Proves bump-plugin-version.sh moves the version in ALL FOUR manifests that must
# agree, that --changed-since bumps exactly what moved and is idempotent on a
Expand Down
115 changes: 115 additions & 0 deletions .github/workflows/recheck-open-prs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: πŸ” Recheck open PRs

# A pull-request workflow runs only on that PR's own events, so every PR already open when a new
# required gate lands on `main` keeps the green `CI - Required Checks` it earned BEFORE the gate
# existed β€” and the branch rule keyed on that check name is satisfied by the stale run. Such a PR
# can merge without the new gate ever running against it, which is exactly what each gate was
# added to stop.
#
# GitHub's own mechanism for this is `strict_required_status_checks_policy` ("require branches to
# be up to date before merging"), but it is declared org-wide and Observe-only in
# devantler-tech/.github, so it is not this repository's to flip. This workflow is the
# repository-scoped equivalent: when `main` moves, ask every open PR to run again. See
# scripts/recheck-open-prs.sh for why a close-and-reopen is the only re-trigger that resolves a
# fresh merge ref, and why it needs an App token.
#
# WHY EVERY PUSH, RATHER THAN A NARROWER TRIGGER
# Deciding "did a gate change?" was tried and cannot be made correct here.
# - A `paths:` filter is capped at 300 files, so a large sync can change `ci.yaml` without the
# filter seeing it β€” the failure this workflow exists to prevent, hidden behind its trigger.
# - Diffing the pushed range instead loses a push that the concurrency group coalesced away: a
# gate change queued behind a running sweep is replaced by a later unrelated push, whose own
# range shows no gate change, and nothing ever sweeps for it.
# - Testing `ci.yaml` alone misses a gate STRENGTHENED in its implementation. `ci.yaml` runs
# `scripts/validate-manifests.sh` and friends; adding a rejection there changes what the
# required check accepts while `ci.yaml` itself is untouched. Enumerating every file that
# implements a gate is a list that goes stale silently.
# Every push it is. The cost is bounded and visible β€” each merge re-runs the open PRs' checks β€”
# and on this repository a merge already invalidates every open PR, since a plugin's version is
# its cache key and lives in files every plugin change touches.

on:
push:
branches: [main]
workflow_dispatch:
inputs:
dry-run:
description: List the pull requests that would be re-triggered, without touching them
type: boolean
default: false

concurrency:
# One recheck at a time: two overlapping passes would close the same PR twice and race each
# other's reopen. `cancel-in-progress: false` protects the pass that is already running; a
# PENDING pass discarded by a third push loses nothing, because every pass sweeps every open
# PR unconditionally, so the newest one does strictly more than the one it displaced.
#
# A dry run gets its OWN group. Actions holds one running plus one pending per group, so a dry
# run sharing this key would displace a pending sweep β€” and since it only lists, the pull
# requests that sweep would have re-triggered never receive a fresh event at all. A run that
# mutates nothing must not be able to cancel one that does.
group: recheck-open-prs${{ inputs.dry-run && '-dry-run' || '' }}
cancel-in-progress: false

permissions: {}

jobs:
recheck:
name: Re-trigger every open PR's required checks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: πŸ“„ Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

# Two token steps rather than one with conditional scopes, because the two modes need
# different SETS of permissions and not merely different levels of the same ones. A dry run
# never reaches the workflow-runs endpoint, so granting it that scope would hand an
# advertised read-only mode a permission it cannot use; and the action takes a fixed list,
# so a scope cannot be dropped by an expression the way a level can be lowered by one.
#
# Both mint an App token rather than using GITHUB_TOKEN: events produced with that token do
# not start new workflow runs, so a reopen performed with it would be silent β€” the same
# reason update-agent-skills.yaml mints one to open its PR.
- name: πŸ”‘ Generate GitHub App token (sweep)
id: app-token
if: ${{ !inputs.dry-run }}
# `contents: write` is not spare: restoring an auto-merge request with `gh pr merge --auto`
# needs it as well as pull-requests write. Without it the close and reopen would succeed
# and every re-arm would fail, so a PR that arrived with auto-merge armed would be left
# without it.
#
# `actions: read` is likewise load-bearing. Before restoring an armed auto-merge the script
# waits for a `pull_request` run from the reopen, and that read is what makes the wait safe
# β€” without it the baseline request 403s, the pull request is left untouched with its stale
# green, and the gate this workflow applies is bypassed on exactly the pull requests that
# were about to merge themselves.
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
permission-actions: read
Comment thread
devantler marked this conversation as resolved.

- name: πŸ”‘ Generate GitHub App token (dry run)
id: app-token-dry
if: ${{ inputs.dry-run }}
# Listing pull requests is all a dry run does, so those are the only scopes it gets.
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
permission-contents: read
permission-pull-requests: read

- name: πŸ” Re-trigger open pull requests
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-dry.outputs.token }}
DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }}
run: |
# shellcheck disable=SC2086 # DRY_RUN is a single optional flag or empty
./scripts/recheck-open-prs.sh --repo "${GITHUB_REPOSITORY}" --base main $DRY_RUN
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ scripts/
β”œβ”€β”€ check-plugin-version-bump.test.sh # Self-test for the gate above
β”œβ”€β”€ guard-bundled-skill-edits.sh # Gate: refuse a hand-edit to a synced skill tree, naming its upstream
β”œβ”€β”€ guard-bundled-skill-edits.test.sh # Self-test for the gate above
β”œβ”€β”€ recheck-open-prs.sh # Re-trigger every open PR's checks after a CI gate changes on main
β”œβ”€β”€ recheck-open-prs.test.sh # Self-test for the recheck above (stubs `gh`; no network)
β”œβ”€β”€ bump-plugin-version.sh # Move a plugin's version across all four manifests (the fix the gate points at)
β”œβ”€β”€ bump-plugin-version.test.sh # Self-test for the bump helper
β”œβ”€β”€ refresh-desired-state-digests.sh # Writer: recompute every digest a *.desired-state.json pins (the fix "digest must match" points at)
Expand Down Expand Up @@ -242,6 +244,47 @@ The required gate is the aggregated **`CI - Required Checks`** job (validate-man
discover-skills + validate-spec); `actionlint` above is a local-only convenience, not a CI gate. Never
weaken a check to pass β€” fix the root cause.

**Adding a gate does not retroactively apply it to open PRs β€” the recheck workflow is what does.**
A pull-request workflow runs only on that PR's own `pull_request` events, so every PR already open
when a new job joins `CI - Required Checks` keeps the green it earned *before* that job existed, and
the branch rule keyed on the check's name is satisfied by the stale run. Such a PR can merge without
the new gate ever running against it β€” which is how a stale plugin version or a hand-edited synced
skill would reach consumers past the very checks added to stop them.
[`recheck-open-prs.yaml`](.github/workflows/recheck-open-prs.yaml) closes that window: **every push
to `main`** re-triggers every open PR's checks, and it can also be dispatched by hand with a
`dry-run` input to see what a sweep would touch. So **when you add or alter a required job, the
recheck is the mechanism that makes it apply to work already in flight** β€” there is nothing extra to
remember, but there is something to notice if it ever stops running.

It sweeps unconditionally because deciding *whether* a gate changed cannot be made correct here, and
three narrower designs were tried and rejected: a `paths:` filter is capped at 300 files, so a large
sync can change `ci.yaml` without the filter seeing it; diffing the pushed range loses a push the
concurrency group coalesced away; and testing `ci.yaml` alone misses a gate strengthened in its
*implementation*, since that file runs `validate-manifests.sh` and friends and a new rejection there
changes what the required check accepts while `ci.yaml` is untouched. Each blind spot is silent,
which is worse than no trigger. **If you narrow this trigger, you are re-opening one of those three.**

Re-triggering means a **close and immediate reopen**, not a re-run: re-running a workflow replays the
original event's `GITHUB_SHA`, which for a pull request is the merge commit as it stood *before* the
gate landed. Only a fresh `pull_request` event resolves the merge ref again, and `reopened` is the one
such event that leaves the PR's head β€” and therefore any green review at that head β€” untouched. It
runs under an App token because events produced with `GITHUB_TOKEN` start no workflow runs.
[`recheck-open-prs.sh`](scripts/recheck-open-prs.sh) carries the details and never leaves a PR closed;
its self-test proves that, the close-before-reopen order, and that an armed auto-merge is restored β€”
with its original strategy and commit message β€” without one ever being armed that was not. One
subtlety is worth knowing before touching it: an armed auto-merge is restored only after a workflow run
from the reopen is observable. Until then the newest result at that commit is still the pre-gate
green, and `--auto` merges as soon as the requirements read as met β€” so arming early could merge the
pull request past the very gate the sweep is applying. When no such run appears, the script declines
to arm and says so, because an auto-merge a human restores is recoverable and a merge that skipped a
gate is not.

GitHub's own mechanism for this is `strict_required_status_checks_policy` β€” "require branches to be up
to date before merging" β€” which would block a stale PR outright rather than re-running it. It is
declared **org-wide and `Observe`-only** in `devantler-tech/.github`, so turning it on is a maintainer
decision affecting every repository, not this one's to make; the workflow above is the
repository-scoped equivalent.

## Maintenance (autonomous AI engineer)

These conventions guide the autonomous **Agentic Engineer** β€” and any agentic tool β€” doing
Expand Down
Loading
Loading