Skip to content

Commit 3431bed

Browse files
baozhoutaoclaude
andauthored
ci: retire the stale-issues workflow under enforce-or-remove (#8957)
Eight months, 236 scheduled runs, zero successes, zero consumers and zero external authors on the open board. Every run failed in `Set up job` before `actions/stale` started, because the workflow's SHA pin resolved to no commit, so the job never once swept anything. A declared automation nobody uses is removed, not repaired (director seat, decision batch 89). The workflow's three declared references go with it, and the exception entry goes in THIS commit rather than a follow-up: - `scripts/check-action-ref-convention.mjs` — the `DECLARED_EXCEPTIONS` entry naming the workflow and `actions/stale`. That gate's rule 2 fails when an entry matches nothing, and it runs on every pull request, so deleting the workflow and leaving the entry would have reddened `main` for everyone. Verified counterfactually: scanning this tree with the entry still present reports one stale exception. - `content/docs/guide/ci-cd-pipeline.md` — the Workflow Inventory row, the section, and the prose naming the file. The page is pinned to `.github/workflows/` in both directions, so all three had to move. - `scripts/__tests__/workflow-cache-save-bound.test.ts` — the unbounded-job entry. Its lookup asserts the job still exists. Two suites read the real workflow off disk and are repaired, not deleted: the behaviour they pin still needs pinning. `check-action-ref-convention.test.ts` reproduces both the stale-exception verdict and the "silences only the entry that names it" case over synthetic tables and other workflows, which no longer depends on one particular file surviving. Part of objectui#8465, whose one SHA pin this disposes of. Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 452f1f7 commit 3431bed

6 files changed

Lines changed: 136 additions & 196 deletions

File tree

‎.github/workflows/stale.yml‎

Lines changed: 0 additions & 118 deletions
This file was deleted.

‎content/docs/guide/ci-cd-pipeline.md‎

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ one has its own section below.
5050
| `cross-repo-issue-closer.yml` | Cross-repo Issue Closer | PR `closed` (acts only when merged) | No — runs after merge |
5151
| `changeset-release.yml` | Changeset Release | Push to `main` (publish half); 6-hourly cron `0 */6 * * *`; manual (version-PR refresh half) | n/a |
5252
| `changelog.yml` | Auto Changelog | Manual dispatch only — nothing triggers it automatically | n/a |
53-
| `stale.yml` | Stale Issues & PRs | Daily cron `0 0 * * *`; manual | n/a |
5453
| `shadcn-check.yml` | Check Shadcn Components | Weekly cron `0 9 * * 1`; manual | n/a |
5554
| `check-links.yml` | Check Links | Weekly cron `17 4 * * 0`; manual | n/a — reports, never gates |
5655
| `published-dist-gate.yml` | Published Dist Tooling Scan | Nightly cron `41 3 * * *`; push to `main` touching the gate; manual | No — the blocking copy runs on the publish path, not here |
@@ -411,9 +410,17 @@ not a reason, and the gate does not read it as one.
411410
[objectui#8465](https://github.com/objectstack-ai/objectui/issues/8465). There were 13 distinct
412411
action references in this directory. Exactly **one** was spelled differently from the other twelve —
413412
a commit SHA on `actions/stale` — and it was the only reference in the repository that had **never
414-
resolved**: 236 scheduled runs of `stale.yml` since 2026-01-16, **0 successes**, every one failing
415-
in `Set up job`, unnoticed for eight months because nothing downstream consumes that job. The broken
416-
reference itself is [objectui#8126](https://github.com/objectstack-ai/objectui/issues/8126).
413+
resolved**: 236 scheduled runs of the stale-issues workflow since 2026-01-16, **0 successes**, every
414+
one failing in `Set up job`, unnoticed for eight months because nothing downstream consumes that
415+
job. The broken reference itself was
416+
[objectui#8126](https://github.com/objectstack-ai/objectui/issues/8126).
417+
418+
That workflow no longer exists. [objectui#8548](https://github.com/objectstack-ai/objectui/issues/8548)
419+
retired it under enforce-or-remove — a declared automation with zero successes, zero consumers and no
420+
external authors on the open board is removed rather than repaired — and #8126 closed with it. The
421+
`DECLARED_EXCEPTIONS` entry that had covered its SHA pin was deleted in the same commit: an entry
422+
matching nothing is red under the second rule below, so a deletion that left it behind would have
423+
reddened this gate on `main` for every pull request.
417424

418425
This is **not** an argument that SHA pinning is wrong — it is normally the *more* secure spelling and
419426
supply-chain guidance recommends it. The failure was the *shape*: one ref written in a form nothing
@@ -2189,21 +2196,6 @@ secrets from fork-originated runs. The usual hazard of `pull_request_target` doe
21892196
the job never checks out the head ref and never executes anything from the PR — it reads the body
21902197
and calls the issues API.
21912198

2192-
### Stale Issues (`stale.yml`)
2193-
2194-
**Trigger:** Daily at 00:00 UTC (cron), or manual dispatch.
2195-
2196-
| Resource | Stale after | Close after | Exempt labels |
2197-
|----------|-------------|-------------|---------------|
2198-
| Issues | 60 days | 7 days | `pinned`, `security`, `critical`, `bug`, `enhancement` |
2199-
| Pull Requests | 45 days | 14 days | `pinned`, `security`, `in-progress`, `blocked` |
2200-
2201-
The two exemption lists are set separately (`exempt-issue-labels` and `exempt-pr-labels`) and
2202-
neither is a subset of the other: `critical`, `bug` and `enhancement` exempt issues only,
2203-
`in-progress` and `blocked` exempt pull requests only. This page used to state one merged list
2204-
— `pinned`, `security`, `critical`, `in-progress` — which was wrong in both directions for
2205-
both resources ([#3724](https://github.com/objectstack-ai/objectui/issues/3724)).
2206-
22072199
### Half-State Patrol (`half-state-patrol.yml`)
22082200

22092201
**Trigger:** Four times a day at `:37` past the hour (cron `37 1,7,13,19 * * *`), manual dispatch,

‎scripts/__tests__/check-action-ref-convention.test.ts‎

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -154,16 +154,47 @@ describe('the gate can fail (non-vacuity)', () => {
154154
});
155155

156156
it('goes red on a stale exception — an entry that matches nothing', () => {
157-
const result = scanMutated(
158-
(files) => {
159-
// The stale.yml SHA becomes a tag: exactly what objectui#8126 landing
160-
// one way would do. The entry must then be deleted, loudly.
161-
const name = 'stale.yml';
162-
files.set(name, files.get(name)!.replace(/actions\/stale@[0-9a-f]{40}/, 'actions/stale@v9'));
157+
// This case used to mutate the real `stale.yml`, whose SHA pin was the only
158+
// entry DECLARED_EXCEPTIONS ever held. objectui#8548 deleted that workflow
159+
// and the entry with it, so the table is empty and the shape has to be
160+
// reproduced over a synthetic one — which is stricter, not weaker: the
161+
// assertion no longer depends on one particular workflow surviving.
162+
//
163+
// Both halves are pinned, because only the pair says the entry is doing
164+
// work. An entry that never silences anything would satisfy the second half
165+
// on its own.
166+
const declared = [
167+
{
168+
workflow: 'control-bytes.yml',
169+
action: 'actions/checkout',
170+
issue: 'objectui#8465',
171+
reason: 'synthetic entry for the non-vacuity proof below — not a real exception',
163172
},
164-
);
165-
expect(result.offenders).toEqual([]);
166-
expect(result.stale.map((e: { workflow: string }) => e.workflow)).toEqual(['stale.yml']);
173+
];
174+
const shaPin = (files: Map<string, string>) => {
175+
const name = 'control-bytes.yml';
176+
files.set(
177+
name,
178+
files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'a'.repeat(40)),
179+
);
180+
};
181+
182+
// Matching: the ref is off-convention and the entry names it, so nothing is
183+
// reported from either direction.
184+
const matching = scanMutated(shaPin, declared);
185+
expect(matching.offenders).toEqual([]);
186+
expect(matching.stale).toEqual([]);
187+
188+
// Orphaned: the workflow the entry names is deleted — literally what
189+
// objectui#8548 did to the real table. The entry now matches nothing and
190+
// must be reported, loudly, so that deleting a workflow cannot leave a
191+
// permanently red `main` behind it.
192+
const orphaned = scanMutated((files) => {
193+
shaPin(files);
194+
files.delete('control-bytes.yml');
195+
}, declared);
196+
expect(orphaned.offenders).toEqual([]);
197+
expect(orphaned.stale.map((e: { workflow: string }) => e.workflow)).toEqual(['control-bytes.yml']);
167198
});
168199

169200
it('goes red when the census collapses, instead of reporting a clean tree', () => {
@@ -180,18 +211,31 @@ describe('the gate can fail (non-vacuity)', () => {
180211
const fake = [
181212
{ workflow: 'control-bytes.yml', action: 'actions/checkout', issue: 'objectui#1', reason: 'x'.repeat(50) },
182213
];
214+
// Two off-convention refs, in two different workflows, and `fake` names only
215+
// the first. The second is the whole point of the case: until objectui#8548
216+
// it was `stale.yml`'s real SHA pin, which the tree no longer carries, so it
217+
// is injected here instead. ⛔ Do not drop it and keep only the silenced
218+
// half — a gate that reported nothing at all would pass that alone.
183219
const result = scanMutated((files) => {
184-
const name = 'control-bytes.yml';
185-
files.set(name, files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main'));
220+
files.set(
221+
'control-bytes.yml',
222+
files.get('control-bytes.yml')!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main'),
223+
);
224+
files.set(
225+
'shadcn-check.yml',
226+
files
227+
.get('shadcn-check.yml')!
228+
.replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'd'.repeat(40)),
229+
);
186230
}, fake);
187231
// The ref `fake` covers is silenced...
188232
expect(result.offenders.map((o: { ref: string }) => o.ref)).not.toContain('actions/checkout@main');
189233
expect(result.stale).toEqual([]);
190-
// ...and ONLY that one: `fake` replaced the real table, so stale.yml's SHA
191-
// is now undeclared and reported. Proof that what silences a reference is an
192-
// entry naming it, not the gate being lax about off-convention spellings.
234+
// ...and ONLY that one: the second workflow's SHA is undeclared and is
235+
// reported. Proof that what silences a reference is an entry naming it, not
236+
// the gate being lax about off-convention spellings.
193237
expect(result.offenders.map((o: { file: string; kind: string }) => `${o.file} ${o.kind}`)).toEqual([
194-
'stale.yml sha',
238+
'shadcn-check.yml sha',
195239
]);
196240
});
197241
});

‎scripts/__tests__/ci-cd-pipeline-doc.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ describe('ci-cd-pipeline.md — workflow inventory', () => {
145145
`content/docs/guide/ci-cd-pipeline.md names them:\n` +
146146
undocumented.map((f) => ` - ${f}`).join('\n') +
147147
`\n\nAdd a section to that page — a heading that contains the file name ` +
148-
`(e.g. "### Stale Issues (\`stale.yml\`)"), what triggers it, and whether it can ` +
148+
`(e.g. "### Hook Self-Tests (\`hook-selftests.yml\`)"), what triggers it, and whether it can ` +
149149
`block a merge — and a row in the "Workflow Inventory" table. A workflow nobody ` +
150150
`documented is a check contributors get blocked by without knowing it exists ` +
151151
`(objectui#3212: \`lint.yml\` gated PRs for months while this page never mentioned it).` +

‎scripts/__tests__/workflow-cache-save-bound.test.ts‎

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,21 @@ import { parse as parseYaml } from 'yaml';
6262
* - Four had a distribution to derive from, and carry a ceiling with the
6363
* derivation written beside the key.
6464
* - Two did not, and carry NO ceiling ON PURPOSE, with the reason written
65-
* beside the job. `changelog.yml::changelog` has never run at all;
66-
* `stale.yml::stale` has never succeeded, and every run it has fails in
67-
* `Set up job` before the marketplace action starts, so its seconds measure
68-
* a setup failure rather than the job's work. Pinning an ABSENCE reads
69-
* oddly until you notice the failure mode it catches: someone tidying up
70-
* the inconsistency by copying `20` onto them, which is precisely the
71-
* inherited number objectui#7048 fences and objectui#7956's triage forbade.
65+
* beside the job. Pinning an ABSENCE reads oddly until you notice the
66+
* failure mode it catches: someone tidying up the inconsistency by copying
67+
* `20` onto them, which is precisely the inherited number objectui#7048
68+
* fences and objectui#7956's triage forbade.
69+
*
70+
* ⚠️ That second half is now ONE job, not two. `stale.yml::stale` was the other
71+
* one, and objectui#8548 deleted the whole workflow under enforce-or-remove:
72+
* eight months, 236 runs, zero successes, zero consumers. Its entry left the
73+
* `accepted` table in that same commit, because a pin naming a job that no
74+
* longer exists fails on the lookup and says nothing about ceilings.
75+
* ⛔ Do not read the shrink as the rule weakening — `changelog.yml::changelog`
76+
* still holds it, and the case it catches (copying `20` onto an unmeasured job)
77+
* is unchanged. ⭐ A job leaving this table by being DELETED and a job leaving
78+
* it by acquiring a derived ceiling are opposite events; only the second one
79+
* moves an entry into the `derived` table above.
7280
*
7381
* ## Deliberately NOT asserted
7482
*
@@ -438,22 +446,26 @@ describe('every workflow cache save is bounded and non-fatal (objectui#7048)', (
438446
).toEqual([]);
439447
});
440448

441-
it('keeps the two jobs objectui#7956 could not measure UNBOUNDED, with their reason recorded', () => {
449+
it('keeps the job objectui#7956 could not measure UNBOUNDED, with its reason recorded', () => {
442450
// The other half of objectui#7956, and the half that is easy to undo by
443-
// being helpful. Four of its six jobs got a ceiling; these two did not,
444-
// because neither has a distribution that measures the job doing its work:
451+
// being helpful. Four of its six jobs got a ceiling; this one did not,
452+
// because it has no distribution that measures the job doing its work:
445453
//
446454
// - `changelog.yml::changelog` — `total_count: 0` runs, ever. It is
447455
// dispatch-only and has never been dispatched. (Control on the same
448456
// endpoint: `changeset-release.yml` answers with thousands, so the zero
449457
// is a reading and not a broken query.)
450-
// - `stale.yml::stale` — 234 completed runs, 0 successful, over eight
451-
// months. Ten sampled evenly across that window all fail in `Set up
452-
// job`, before `actions/stale` starts, so their 1-4 seconds measure how
453-
// fast the job fails to begin. Its real cost has never been observed,
454-
// and it is the kind that grows with the repository.
455458
//
456-
// A number invented for either one would look derived and be a guess, and a
459+
// objectui#7956 left TWO jobs here. The second was `stale.yml::stale` — 234
460+
// completed runs, 0 successful, over eight months, every sampled one failing
461+
// in `Set up job` before the marketplace action started, so its 1-4 seconds
462+
// measured how fast the job failed to begin rather than any work it did.
463+
// objectui#8548 deleted that workflow outright (enforce-or-remove: zero
464+
// consumers, zero successes), which is why this table lists one key and not
465+
// two. ⛔ Re-adding `stale.yml::stale` here does not restore a pin — the
466+
// lookup below would fail on a workflow that is gone.
467+
//
468+
// A number invented for it would look derived and be a guess, and a
457469
// ceiling under a job's honest slowest run converts a working job into a
458470
// permanently red one — objectui#7048's fence, and objectstack#16173 is the
459471
// live counter-example (a distribution mis-estimated by ~2.6x killed a test
@@ -464,7 +476,7 @@ describe('every workflow cache save is bounded and non-fatal (objectui#7048)', (
464476
// jobs above onto these two. Adding a real ceiling here is welcome — derive
465477
// it from runs that did the work, write the derivation beside the key, and
466478
// move the entry up into the `derived` table above in the same commit.
467-
const accepted = ['changelog.yml::changelog', 'stale.yml::stale'];
479+
const accepted = ['changelog.yml::changelog'];
468480

469481
const bounded: string[] = [];
470482
const undocumented: string[] = [];

0 commit comments

Comments
 (0)