Skip to content

ci: add stale workflow to triage inactive issues and PRs#5668

Closed
nopoz wants to merge 1928 commits into
odysseus-dev:devfrom
nopoz:ci/stale-workflow
Closed

ci: add stale workflow to triage inactive issues and PRs#5668
nopoz wants to merge 1928 commits into
odysseus-dev:devfrom
nopoz:ci/stale-workflow

Conversation

@nopoz

@nopoz nopoz commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds .github/workflows/stale.yml, a two-tier actions/stale configuration that marks inactive issues and PRs with an inactive label, comments to say why, and closes them 14 days later if nothing changes. No existing workflow is modified.

tier applies to marked inactive after closed after that total notice
1 PRs labelled needs work 14 days idle 14 days 28 days
2 all other PRs 60 days idle 14 days 74 days
2 issues 45 days idle 14 days 59 days

What the first run would actually touch, as of 2026-07-21. These drift daily, so treat them as a snapshot rather than fixed numbers:

marked on the first run
Tier 1 PRs 127
Tier 2 PRs 0, nothing is 60 days idle yet
Issues 220

Nothing is closed on the first run. The earliest anything could close is 14 days after it is marked.

The two tiers exist because the backlog contains two very different populations:

Tier 1, PRs labelled needs work. check-pr-description.js applies that label when a PR body is missing required template sections and removes it automatically once fixed, re-checking on every push. 146 open PRs still carry it, median idle 44 days. The author was told exactly what to fix and did not come back.

Tier 2, everything else. Of the 232 tier-2-eligible PRs idle past 30 days, 219 carry ready for review and 209 are clean and unconflicted. Those are stalled on review latency, not on the contributor. That is why tier-2 PRs are set at 60 days rather than 30, and why the tier-2 message explicitly says that waiting on a maintainer review is a valid reason to keep a PR open.

The numbers are proposals. They are single-line edits. If you want tier 2 at 30 days to clear the backlog a month sooner, or issues left alone entirely, say so and I will change them.

Some details worth knowing before this runs:

  • Every marked and every closed item gets a comment. A label change on its own sends no GitHub notification, so the comments are the entire notification mechanism.
  • Any comment or push clears inactive and resets the clock.
  • The label is inactive, deliberately not the action's default Stale, because this repo already has a stale pr label that means "out of sync with base, needs a rebase" and is owned by the Flag unmergeable PRs job.
  • operations-per-run is load-bearing and commented as such in the file: 400 for tier 1, 600 for tier 2. actions/stale shares one hardcoded Actions-cache key across invocations and records every item it examines, so a step that exhausts its budget leaves behind a cursor that makes the next step skip those items. A step that completes a full pass clears the cursor. Tier 2 gets the larger budget because it carries the issue backlog. Combined, the two stay at or under the 1000 requests/hour GITHUB_TOKEN limit, and only the first few runs come close.
  • Exempt labels: blocked, help wanted, good first issue, plus security and pinned reserved as escape hatches.

Please run the workflow manually with dry_run: true (the default) from the Actions tab before letting the schedule fire. That reports what it would touch without changing anything.

Linked Issue

Fixes #5667

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • CI / tooling / configuration

Checklist

  • I searched open issues and open PRs and this is not a duplicate.
  • My changes are limited to the scope described above, with no unrelated refactors or whitespace churn.
  • The action is pinned by full commit SHA with a version comment, matching the convention in the existing workflows.
  • No visual change. This adds a GitHub Actions workflow and touches no application code, templates, or styles.

How to Test

Static validation, reproducing .github/workflows/workflow-security.yml at the same pinned versions. Fetch actionlint 1.7.12 using the version and checksum this repo's CI already pins, then run it against the new file; install zizmor 1.25.2, the same version the CI job installs, and run it with --offline --min-severity=low.

curl -fsSL -o actionlint.tar.gz \
  https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8  actionlint.tar.gz" | sha256sum -c -
tar -xzf actionlint.tar.gz actionlint
./actionlint .github/workflows/stale.yml

pip install zizmor==1.25.2
zizmor --offline --min-severity=low .github/workflows/stale.yml

Both pass with no findings. zizmor is also clean under --persona=pedantic, which is stricter than the CI gate.

Behavioural check, and the one I would like you to run before this is enabled:

  1. Merge, then open the Actions tab and select the stale workflow.
  2. Click "Run workflow". Leave dry_run ticked (it defaults to true).
  3. Read the log. It lists every issue and PR it would mark or close and makes no changes at all.
  4. If the counts look wrong, adjust the days-before-* values and re-run the dry run.

The schedule is daily at 03:00 UTC. Until you are satisfied with the dry run, the workflow can be disabled from the Actions tab without reverting the merge.

pewdiepie-archdaemon and others added 30 commits June 27, 2026 21:12
Keep an unhealthy MemoryVectorStore instance available for health reporting instead of discarding it as disabled. This lets health checks report a degraded/down vector-store state while preserving focused regression coverage for initializer behavior.
bananatruck and others added 15 commits July 16, 2026 13:06
Add Arch-specific package installation and NVIDIA runtime configuration
to the Docker setup guide. Cover passthrough verification, the NVIDIA
Compose overlay, and the distinction between GPU passthrough and
CUDA-backed model serving

Refs #831
chore: sync upstream changes from odysseus-dev/odysseus
fix(docker): bump Docker CLI to a patched release
fix(docker): bump Docker CLI to a patched release
* feat(models): define capability schema and readers

* fix(models): harden Google catalog probing

Restrict native catalog probing to the Gemini host, keep provider keys out of request URLs, filter non-chat model resources, and preserve the manual refresh default in the built-in Google add flow.
…(#5474)

* security(url-safety): reject RFC 6598 shared address space in strict mode

Strict mode (block_private=True) is a full SSRF lockdown, but it only
rejected is_private and is_loopback targets. CPython does not classify RFC
6598 shared/CGNAT space (100.64.0.0/10) as is_private (it is "shared", not
"private"), so a public redirect into 100.64.0.1 passed the per-hop guard
and still issued the request to a potentially internal CGNAT service.

not is_global would also exclude it, but only on CPython 3.11.10+/3.12.4+/
3.13+; the CI matrix runs 3.11/3.12, so reject the range explicitly to stay
correct across patch levels and the 3.14 runtime image. Default local-first
mode is unchanged. Adds strict-mode coverage for shared, non-global, and
public targets.

* docs(url-safety): correct CGNAT is_global rationale in strict-mode comment

The prior comment claimed `not is_global` catches 100.64.0.0/10 only on
CPython 3.11.10+/3.12.4+/3.13+. That is inaccurate for CGNAT: is_global
is False for 100.64.0.1 on every supported version (verified 3.10-3.14).
The version-fragility applies to other ranges gh-113171 touched, not CGNAT.
The explicit range reject is still the right choice; restate the reason as
is_private not covering shared space, and not coupling strict mode to
is_global's broader, cross-version definition. No behavior change.
…… (#5491)

* fix(llm): enhance fallback logic to handle empty completions and improve metadata handling

* fix(llm): stream tool call deltas immediately
Slice 2f of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves note_routes.py into
routes/note/, leaving a backward-compat sys.modules shim at the old path.
Pure file reorganization, no behavior change.

The shim uses sys.modules replacement (same pattern as the merged gallery
#4903, research #4975, memory #5007, history #5090, and contacts #5227
slices) so that `import routes.note_routes`, `from routes.note_routes import
X`, `importlib.import_module(...)`, and the `import ... as note_routes` +
`monkeypatch.setattr(note_routes, "SessionLocal", ...)` pattern used by
test_note_reminder_fire_scope.py / test_notes_fail_closed_auth.py all
operate on the same module object the application uses.

The canonical module does NOT depend on the shim — routes/note/note_routes.py
imports only from core/, src/, and stdlib. The outbound email cross-domain
imports (routes.email_routes._get_email_config, routes.email_helpers.
_send_smtp_message) are function-local lazy imports that keep resolving
through the email module's own path (email is not yet migrated).

One source-introspection test site repointed to the new canonical path:
- test_model_helper_owner_scope.py (shared with history; history entry
  already repointed in #5090, note entry repointed here)

Adds tests/test_note_routes_shim.py to pin the sys.modules shim contract
(legacy and canonical paths resolve to the same module object; monkeypatch
via legacy alias reaches the canonical module).

Verified: compileall clean; full suite 4487 passed, 3 skipped.
@github-actions github-actions Bot added the ready for review Description complete — ready for maintainer review label Jul 21, 2026
@nopoz
nopoz force-pushed the ci/stale-workflow branch from 84a4bf6 to 493ae41 Compare July 21, 2026 06:37
@RaresKeY

Copy link
Copy Markdown
Member

I pushed a safer rollout for the stale workflow. Scheduled runs now default to dry-run and publish a linked candidate queue in the Actions summary, with the two tiers running in separate API windows.

We can dogfood the queue for a few weeks, tune exemptions when something should stay open, then set STALE_AUTOMATION_MODE=live. Setting it to off pauses scheduled processing. Feedback welcome.

@RosenTomov RosenTomov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Ran the "how to test" checks from the description and also dispatched stale.yml as a workflow on a throwaway repo (dry run, thresholds zeroed so fresh items register).

https://github.com/RosenTomov/actions-test/actions/runs/29816100643 t1
https://github.com/RosenTomov/actions-test/actions/runs/29816132590 t2

The 60-day tier-2 threshold already defers the risky cohort: the oldest
review-ready PR is 47 days idle, so nothing in that group can close
before day 27. That runway is the rollout period, and it needs no flag
anyone has to remember to flip.

Drop STALE_AUTOMATION_MODE and the live-run guard. The schedule runs
live; workflow_dispatch with dry_run still previews on demand without
changing anything, and a workflow can be disabled from the Actions tab
if it needs pausing.

Size the dry-run budget separately: the action counts an operation per
label and per comment even when debug-only suppresses the API call, and
a preview labels nothing, so its candidate set is the whole backlog
rather than the day's arrivals. A live-sized cap would silently truncate
it. Nothing is written, so the cost is the per-page reads only.

Publish the queue on every run, not just previews, so a live run leaves
a record of what it touched. Include title, last-activity date, and
counts, all already present in the action's output.
@nopoz

nopoz commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Kept the cron split, the tier input, and the queue summary. The cron split in particular is better than what I had: I was treating the combined 1000 req/hr token limit as a constraint to design around, and separate hourly windows just removes it.

I did drop STALE_AUTOMATION_MODE, and I want to explain that properly rather than just push it.

The 60-day tier-2 threshold already does what the flag was protecting against. The oldest review-ready PR is 47 days idle, so the earliest any of that cohort can be marked is day 13 and the earliest any can close is day 27. Tier-2 PRs marked on day 0: zero. The four-week runway is already in the number, and it costs nothing to maintain.

Against that, a mode variable is a standing obligation. If nobody flips it, the workflow sits inert while the merged PR, the workflow file, and the inactive label all imply the queue is being managed. That failure is quiet and it is the likely one. The CodeQL switch sat waiting on an admin action from 06 to 21 July and left dev with a seven-day scanning gap when the two halves did not land together. I would rather not build a second thing that depends on someone remembering.

workflow_dispatch with dry_run still gives the preview, on demand, any time, changing nothing. That covers "let me see what it would do" without anyone needing to remember to undo it later. If it needs pausing, disabling the workflow from the Actions tab is the native lever.

The one place the flag genuinely bought time is issues: 229 marked on day 0, first closures on day 14. That is the fastest-moving part of this. If that is too quick, the honest lever is the threshold rather than a mode, and issues 45 to 60 would give roughly the same runway as the PR tiers with a one-line change. I left it at 45 because issues are the cheapest thing to get wrong (nothing is lost, reopening is trivial, 14 days of notice, any comment cancels), but I have no strong attachment to the number and would take 60 if you prefer.

Two other changes in the same commit. The dry-run budget is now sized separately, because the action counts an operation per label and per comment even when debug-only suppresses the API call, and a preview labels nothing so its candidate set is the whole backlog rather than the day's arrivals. A live-sized cap silently truncates the preview, which is the opposite of what a preview is for. Nothing is written, so the higher cap costs only the per-page reads. And the queue summary now publishes on live runs too, with title, last-activity date, and counts, so each run leaves a record of what it actually touched.

@RaresKeY

Copy link
Copy Markdown
Member

Two things on policy, then two concrete P2s.

First, the Actions variable is not owner-only; anyone with write access can manage it. But it still gives us a deliberate review period before this goes live. The native workflow switch is fine as an emergency stop once we know it is safe, but it disables previews too, so it does not replace a controlled rollout.

Second, I do not think Issues and PRs should have the same posture. PRs are implementation attempts; Issues are the open map of the problems themselves. I am more willing to close an abandoned implementation than close a useful problem as not planned and lose it from the open structure.

Also, the workflow is broader than stale status plus failing checks and no author action. Tier 2 includes review-ready PRs, and any non-exempt Issue can be marked after 45 quiet days and closed 14 days later. It does not distinguish reporter inactivity from maintainer inactivity.

There are also two P2s in the current implementation:

  1. The live caps cannot finish either advertised first-run queue. A newly stale item costs four counted operations, so 127 tier-1 items need at least 508 against a cap of 400, and 229 Issues need at least 916 against 600, before pagination. Both runs truncate, then the two differently filtered tiers share the same _state cursor and can skip or overwrite each other’s progress.

  2. The queue’s last activity value is not the item’s last activity. The action rewrites updated_at when it marks an item stale, then that rewritten value is published in the summary. Marked rows show the run date, and closed rows reflect the bot’s earlier stale update, so the review queue cannot support the decision it is meant to support.

My main concern is that we are racing ourselves. We would be putting good problems onto a deterministic closure path before both the workflow and the thing meant to help us review it are reliable.

Rework to a single actions/stale step selecting only PRs where the ball
is with the contributor:

  any-of-pr-labels: needs work, merge conflict, stale pr

Clean, review-ready PRs carry none of these, so review latency on our
side can no longer put a PR on the closure path. Verified against the
live queue: the selector marks 165 PRs and leaves all 613 review-ready
and 8 approved PRs untouched. Issues stay age-based (45d), the
whole-backlog case.

Collapsing the two tiers into one invocation removes the shared _state
cursor hazard by construction, so a truncated run is now safe and just
resumes next time.

Fixes confirmed by running the action locally:
- operations-per-run sized at the real cost. A newly stale item consumes
  4 counted operations (2 reads that run even under debug-only, 2
  writes), so a run makes real API calls in dry mode too. 800 keeps both
  modes under the GITHUB_TOKEN 1000/hr limit; the backlog drains over a
  few daily runs via the cache cursor.
- the review queue shows created_at, not updated_at. The action rewrites
  updated_at to the run time when it marks an item, so the previous
  "last activity" column showed today's date for every marked row.
- the summary strips the action's output to {number,title,created_at,
  pull_request} before use. That output serialises the full option set,
  repo token included; the raw value is never echoed.

Scheduled runs stay dry until STALE_AUTOMATION_MODE=live, giving a review
period before anything closes.
@nopoz

nopoz commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Both confirmed by running the action locally: at cap 40 it marked 10 items for 41 ops, each showing _operationsConsumed: 4, and the emitted updated_at is the run time. The outputs also carry repoToken in plaintext.

Reworked to a single step. It now selects PRs only where the ball is with the contributor (needs work, merge conflict, stale pr); against the live queue that is 165 PRs, and every clean review-ready and approved PR is left untouched. Issues stay age-based. One invocation, so the shared-cursor problem is gone by construction. Ops sized to the real 4-per-item cost, date column switched to created_at, outputs stripped before the summary. Kept the STALE_AUTOMATION_MODE dry-run gate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a stale workflow to triage the inactive PR and issue backlog