diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 5504e5c6f0..0000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Stale Issues & PRs - -on: - schedule: - # Run daily at 00:00 UTC - - cron: '0 0 * * *' - workflow_dispatch: - -permissions: - issues: write - pull-requests: write - -jobs: - stale: - runs-on: ubuntu-latest - - # ── NO job ceiling, and that is the DECISION, not its absence (objectui#7956) ── - # This job runs under GitHub's 360-minute default. objectui#7956 asked for a - # ceiling DERIVED from this job's own measured run-time distribution, in the - # shape objectui#7270 left beside `lint.yml::lint`. It is recorded here - # instead, because every run this job has to offer measures the wrong thing. - # - # - Population: every completed run of this workflow, no status filter, - # window 2026-01-16T01:14Z .. 2026-09-06T00:19Z, n=234, all `schedule`. - # Measured 2026-09-06T17:24Z. - # - `?status=success` returns a `total_count` of 0 over that same window. - # This job has never once succeeded in the whole retained history. - # - Wall clock, from the Actions jobs endpoint: min 1s / median 2s / - # p95 4s / max 4s. - # - ⚠️ Those seconds are NOT this job's work. Ten runs sampled evenly - # across the window all report the same shape: a single step, `Set up - # job`, with conclusion `failure` — the `actions/stale` step below never - # starts. So the distribution above measures how fast this job fails to - # begin, and says nothing whatever about how long it takes to sweep this - # repository's issues and pull requests. - # - # ⛔ Do NOT derive 20-ish minutes from a 4-second maximum that belongs to a - # setup failure. That would be a ceiling with the look of provenance and - # none of the substance, sitting over a job whose real cost has never been - # observed — and `actions/stale` pages the issue and pull-request lists, - # which is the kind of work that grows with the repository. A ceiling set - # under a job's honest slowest run converts a working job into a permanently - # red one (objectui#7048); objectstack#16173 is the live counter-example, - # where a distribution mis-estimated by ~2.6x killed a test shard while the - # rollup read green. Here the mis-estimate would not be 2.6x, it would be - # unbounded, because the numerator has never been measured at all. - # - # ⚠️ Two separate things are true and only one of them is objectui#7956's: - # the exposure (no ceiling) is that card's, and the FAILURE (234 runs, 234 - # failures, never a success) is objectui#8126's — filed separately, because - # bounding a job that cannot start would fix nothing and would hide the - # louder defect behind a tidy-looking diff. - # - # What would change this: fix the setup failure, let a few real sweeps run, - # then derive the number from THOSE the way `lint.yml::lint` documents, - # write the derivation here, and move this job out of the accept-360 table - # and into the `derived` table in - # `scripts/__tests__/workflow-cache-save-bound.test.ts` in the same commit — - # that pin holds this job's UNBOUNDED state deliberately, so adding a - # ceiling has to be an edit someone makes on purpose rather than a silent - # one. - steps: - - uses: actions/stale@e00e804f6792d3fedb5bd3a27df2761c5f86c981 # v9.0.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - # Issues configuration - stale-issue-message: | - This issue has been automatically marked as stale because it has not had recent activity. - It will be closed in 7 days if no further activity occurs. - - If you believe this issue is still relevant, please: - - Comment on this issue to keep it open - - Add more details or context - - Update to the latest version and confirm the issue still exists - - Thank you for your contributions! - close-issue-message: | - This issue has been automatically closed due to inactivity. - - If you believe this issue should remain open, please: - - Reopen the issue with updated information - - Create a new issue with a detailed description and reproduction steps - - Thank you for your understanding! - stale-issue-label: 'stale' - exempt-issue-labels: 'pinned,security,critical,bug,enhancement' - days-before-issue-stale: 60 - days-before-issue-close: 7 - - # Pull requests configuration - stale-pr-message: | - This pull request has been automatically marked as stale because it has not had recent activity. - It will be closed in 14 days if no further activity occurs. - - If you're still working on this PR: - - Comment on this PR to keep it open - - Rebase on the latest main branch - - Address any review comments - - Thank you for your contributions! - close-pr-message: | - This pull request has been automatically closed due to inactivity. - - If you'd like to continue working on this: - - Reopen the PR and rebase on the latest main branch - - Create a new PR with updated changes - - Thank you for your understanding! - stale-pr-label: 'stale' - exempt-pr-labels: 'pinned,security,in-progress,blocked' - days-before-pr-stale: 45 - days-before-pr-close: 14 - - # General configuration - operations-per-run: 100 - remove-stale-when-updated: true - ascending: true diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 78f3bdd8fa..514f090eba 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -50,7 +50,6 @@ one has its own section below. | `cross-repo-issue-closer.yml` | Cross-repo Issue Closer | PR `closed` (acts only when merged) | No — runs after merge | | `changeset-release.yml` | Changeset Release | Push to `main` (publish half); 6-hourly cron `0 */6 * * *`; manual (version-PR refresh half) | n/a | | `changelog.yml` | Auto Changelog | Manual dispatch only — nothing triggers it automatically | n/a | -| `stale.yml` | Stale Issues & PRs | Daily cron `0 0 * * *`; manual | n/a | | `shadcn-check.yml` | Check Shadcn Components | Weekly cron `0 9 * * 1`; manual | n/a | | `check-links.yml` | Check Links | Weekly cron `17 4 * * 0`; manual | n/a — reports, never gates | | `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. [objectui#8465](https://github.com/objectstack-ai/objectui/issues/8465). There were 13 distinct action references in this directory. Exactly **one** was spelled differently from the other twelve — a commit SHA on `actions/stale` — and it was the only reference in the repository that had **never -resolved**: 236 scheduled runs of `stale.yml` since 2026-01-16, **0 successes**, every one failing -in `Set up job`, unnoticed for eight months because nothing downstream consumes that job. The broken -reference itself is [objectui#8126](https://github.com/objectstack-ai/objectui/issues/8126). +resolved**: 236 scheduled runs of the stale-issues workflow since 2026-01-16, **0 successes**, every +one failing in `Set up job`, unnoticed for eight months because nothing downstream consumes that +job. The broken reference itself was +[objectui#8126](https://github.com/objectstack-ai/objectui/issues/8126). + +That workflow no longer exists. [objectui#8548](https://github.com/objectstack-ai/objectui/issues/8548) +retired it under enforce-or-remove — a declared automation with zero successes, zero consumers and no +external authors on the open board is removed rather than repaired — and #8126 closed with it. The +`DECLARED_EXCEPTIONS` entry that had covered its SHA pin was deleted in the same commit: an entry +matching nothing is red under the second rule below, so a deletion that left it behind would have +reddened this gate on `main` for every pull request. This is **not** an argument that SHA pinning is wrong — it is normally the *more* secure spelling and 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 the job never checks out the head ref and never executes anything from the PR — it reads the body and calls the issues API. -### Stale Issues (`stale.yml`) - -**Trigger:** Daily at 00:00 UTC (cron), or manual dispatch. - -| Resource | Stale after | Close after | Exempt labels | -|----------|-------------|-------------|---------------| -| Issues | 60 days | 7 days | `pinned`, `security`, `critical`, `bug`, `enhancement` | -| Pull Requests | 45 days | 14 days | `pinned`, `security`, `in-progress`, `blocked` | - -The two exemption lists are set separately (`exempt-issue-labels` and `exempt-pr-labels`) and -neither is a subset of the other: `critical`, `bug` and `enhancement` exempt issues only, -`in-progress` and `blocked` exempt pull requests only. This page used to state one merged list -— `pinned`, `security`, `critical`, `in-progress` — which was wrong in both directions for -both resources ([#3724](https://github.com/objectstack-ai/objectui/issues/3724)). - ### Half-State Patrol (`half-state-patrol.yml`) **Trigger:** Four times a day at `:37` past the hour (cron `37 1,7,13,19 * * *`), manual dispatch, diff --git a/scripts/__tests__/check-action-ref-convention.test.ts b/scripts/__tests__/check-action-ref-convention.test.ts index fa52b0f626..3e748d929c 100644 --- a/scripts/__tests__/check-action-ref-convention.test.ts +++ b/scripts/__tests__/check-action-ref-convention.test.ts @@ -154,16 +154,47 @@ describe('the gate can fail (non-vacuity)', () => { }); it('goes red on a stale exception — an entry that matches nothing', () => { - const result = scanMutated( - (files) => { - // The stale.yml SHA becomes a tag: exactly what objectui#8126 landing - // one way would do. The entry must then be deleted, loudly. - const name = 'stale.yml'; - files.set(name, files.get(name)!.replace(/actions\/stale@[0-9a-f]{40}/, 'actions/stale@v9')); + // This case used to mutate the real `stale.yml`, whose SHA pin was the only + // entry DECLARED_EXCEPTIONS ever held. objectui#8548 deleted that workflow + // and the entry with it, so the table is empty and the shape has to be + // reproduced over a synthetic one — which is stricter, not weaker: the + // assertion no longer depends on one particular workflow surviving. + // + // Both halves are pinned, because only the pair says the entry is doing + // work. An entry that never silences anything would satisfy the second half + // on its own. + const declared = [ + { + workflow: 'control-bytes.yml', + action: 'actions/checkout', + issue: 'objectui#8465', + reason: 'synthetic entry for the non-vacuity proof below — not a real exception', }, - ); - expect(result.offenders).toEqual([]); - expect(result.stale.map((e: { workflow: string }) => e.workflow)).toEqual(['stale.yml']); + ]; + const shaPin = (files: Map) => { + const name = 'control-bytes.yml'; + files.set( + name, + files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'a'.repeat(40)), + ); + }; + + // Matching: the ref is off-convention and the entry names it, so nothing is + // reported from either direction. + const matching = scanMutated(shaPin, declared); + expect(matching.offenders).toEqual([]); + expect(matching.stale).toEqual([]); + + // Orphaned: the workflow the entry names is deleted — literally what + // objectui#8548 did to the real table. The entry now matches nothing and + // must be reported, loudly, so that deleting a workflow cannot leave a + // permanently red `main` behind it. + const orphaned = scanMutated((files) => { + shaPin(files); + files.delete('control-bytes.yml'); + }, declared); + expect(orphaned.offenders).toEqual([]); + expect(orphaned.stale.map((e: { workflow: string }) => e.workflow)).toEqual(['control-bytes.yml']); }); it('goes red when the census collapses, instead of reporting a clean tree', () => { @@ -180,18 +211,31 @@ describe('the gate can fail (non-vacuity)', () => { const fake = [ { workflow: 'control-bytes.yml', action: 'actions/checkout', issue: 'objectui#1', reason: 'x'.repeat(50) }, ]; + // Two off-convention refs, in two different workflows, and `fake` names only + // the first. The second is the whole point of the case: until objectui#8548 + // it was `stale.yml`'s real SHA pin, which the tree no longer carries, so it + // is injected here instead. ⛔ Do not drop it and keep only the silenced + // half — a gate that reported nothing at all would pass that alone. const result = scanMutated((files) => { - const name = 'control-bytes.yml'; - files.set(name, files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main')); + files.set( + 'control-bytes.yml', + files.get('control-bytes.yml')!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main'), + ); + files.set( + 'shadcn-check.yml', + files + .get('shadcn-check.yml')! + .replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'd'.repeat(40)), + ); }, fake); // The ref `fake` covers is silenced... expect(result.offenders.map((o: { ref: string }) => o.ref)).not.toContain('actions/checkout@main'); expect(result.stale).toEqual([]); - // ...and ONLY that one: `fake` replaced the real table, so stale.yml's SHA - // is now undeclared and reported. Proof that what silences a reference is an - // entry naming it, not the gate being lax about off-convention spellings. + // ...and ONLY that one: the second workflow's SHA is undeclared and is + // reported. Proof that what silences a reference is an entry naming it, not + // the gate being lax about off-convention spellings. expect(result.offenders.map((o: { file: string; kind: string }) => `${o.file} ${o.kind}`)).toEqual([ - 'stale.yml sha', + 'shadcn-check.yml sha', ]); }); }); diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts index a6f3fd6a01..45a7d82d23 100644 --- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts +++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts @@ -145,7 +145,7 @@ describe('ci-cd-pipeline.md — workflow inventory', () => { `content/docs/guide/ci-cd-pipeline.md names them:\n` + undocumented.map((f) => ` - ${f}`).join('\n') + `\n\nAdd a section to that page — a heading that contains the file name ` + - `(e.g. "### Stale Issues (\`stale.yml\`)"), what triggers it, and whether it can ` + + `(e.g. "### Hook Self-Tests (\`hook-selftests.yml\`)"), what triggers it, and whether it can ` + `block a merge — and a row in the "Workflow Inventory" table. A workflow nobody ` + `documented is a check contributors get blocked by without knowing it exists ` + `(objectui#3212: \`lint.yml\` gated PRs for months while this page never mentioned it).` + diff --git a/scripts/__tests__/workflow-cache-save-bound.test.ts b/scripts/__tests__/workflow-cache-save-bound.test.ts index 0457986973..b7c7e42cff 100644 --- a/scripts/__tests__/workflow-cache-save-bound.test.ts +++ b/scripts/__tests__/workflow-cache-save-bound.test.ts @@ -62,13 +62,21 @@ import { parse as parseYaml } from 'yaml'; * - Four had a distribution to derive from, and carry a ceiling with the * derivation written beside the key. * - Two did not, and carry NO ceiling ON PURPOSE, with the reason written - * beside the job. `changelog.yml::changelog` has never run at all; - * `stale.yml::stale` has never succeeded, and every run it has fails in - * `Set up job` before the marketplace action starts, so its seconds measure - * a setup failure rather than the job's work. Pinning an ABSENCE reads - * oddly until you notice the failure mode it catches: someone tidying up - * the inconsistency by copying `20` onto them, which is precisely the - * inherited number objectui#7048 fences and objectui#7956's triage forbade. + * beside the job. Pinning an ABSENCE reads oddly until you notice the + * failure mode it catches: someone tidying up the inconsistency by copying + * `20` onto them, which is precisely the inherited number objectui#7048 + * fences and objectui#7956's triage forbade. + * + * ⚠️ That second half is now ONE job, not two. `stale.yml::stale` was the other + * one, and objectui#8548 deleted the whole workflow under enforce-or-remove: + * eight months, 236 runs, zero successes, zero consumers. Its entry left the + * `accepted` table in that same commit, because a pin naming a job that no + * longer exists fails on the lookup and says nothing about ceilings. + * ⛔ Do not read the shrink as the rule weakening — `changelog.yml::changelog` + * still holds it, and the case it catches (copying `20` onto an unmeasured job) + * is unchanged. ⭐ A job leaving this table by being DELETED and a job leaving + * it by acquiring a derived ceiling are opposite events; only the second one + * moves an entry into the `derived` table above. * * ## Deliberately NOT asserted * @@ -438,22 +446,26 @@ describe('every workflow cache save is bounded and non-fatal (objectui#7048)', ( ).toEqual([]); }); - it('keeps the two jobs objectui#7956 could not measure UNBOUNDED, with their reason recorded', () => { + it('keeps the job objectui#7956 could not measure UNBOUNDED, with its reason recorded', () => { // The other half of objectui#7956, and the half that is easy to undo by - // being helpful. Four of its six jobs got a ceiling; these two did not, - // because neither has a distribution that measures the job doing its work: + // being helpful. Four of its six jobs got a ceiling; this one did not, + // because it has no distribution that measures the job doing its work: // // - `changelog.yml::changelog` — `total_count: 0` runs, ever. It is // dispatch-only and has never been dispatched. (Control on the same // endpoint: `changeset-release.yml` answers with thousands, so the zero // is a reading and not a broken query.) - // - `stale.yml::stale` — 234 completed runs, 0 successful, over eight - // months. Ten sampled evenly across that window all fail in `Set up - // job`, before `actions/stale` starts, so their 1-4 seconds measure how - // fast the job fails to begin. Its real cost has never been observed, - // and it is the kind that grows with the repository. // - // A number invented for either one would look derived and be a guess, and a + // objectui#7956 left TWO jobs here. The second was `stale.yml::stale` — 234 + // completed runs, 0 successful, over eight months, every sampled one failing + // in `Set up job` before the marketplace action started, so its 1-4 seconds + // measured how fast the job failed to begin rather than any work it did. + // objectui#8548 deleted that workflow outright (enforce-or-remove: zero + // consumers, zero successes), which is why this table lists one key and not + // two. ⛔ Re-adding `stale.yml::stale` here does not restore a pin — the + // lookup below would fail on a workflow that is gone. + // + // A number invented for it would look derived and be a guess, and a // ceiling under a job's honest slowest run converts a working job into a // permanently red one — objectui#7048's fence, and objectstack#16173 is the // 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)', ( // jobs above onto these two. Adding a real ceiling here is welcome — derive // it from runs that did the work, write the derivation beside the key, and // move the entry up into the `derived` table above in the same commit. - const accepted = ['changelog.yml::changelog', 'stale.yml::stale']; + const accepted = ['changelog.yml::changelog']; const bounded: string[] = []; const undocumented: string[] = []; diff --git a/scripts/check-action-ref-convention.mjs b/scripts/check-action-ref-convention.mjs index 50f860d6ce..82f677e629 100644 --- a/scripts/check-action-ref-convention.mjs +++ b/scripts/check-action-ref-convention.mjs @@ -23,12 +23,20 @@ * ## WHY THE EXCEPTION MECHANISM IS THE POINT, NOT THE DEFAULT * * objectui#8465. On `origin/main` there were 13 distinct action references. - * Exactly ONE was spelled differently from the other twelve — a commit SHA, - * `actions/stale@e00e804f...` — and it was the only reference in the repository - * that had never resolved. 236 scheduled runs of `stale.yml` since 2026-01-16, - * 0 successes, every one of them failing in `Set up job`. Eight months, silent, - * because nothing downstream consumes that job (the broken ref itself is - * objectui#8126). + * Exactly ONE was spelled differently from the other twelve — a commit SHA on + * `actions/stale` — and it was the only reference in the repository that had + * never resolved. 236 scheduled runs of the stale-issues workflow since + * 2026-01-16, 0 successes, every one of them failing in `Set up job`. Eight + * months, silent, because nothing downstream consumes that job (the broken ref + * itself was objectui#8126). + * + * That workflow is GONE — objectui#8548 retired it under enforce-or-remove + * rather than repairing it (a declared automation with zero consumers and zero + * successes), and objectui#8126 closed with it. Its entry left this table in + * the same commit, because rule 2 below would otherwise have turned the + * deletion into a red `main`. ⛔ The convention did NOT leave with it: it was + * never about that one reference, and the next off-convention ref is what it + * exists to make loud. * * ⛔ The finding is NOT "SHA pinning is wrong". It is normally the MORE secure * spelling and supply-chain guidance recommends it; whether this repository @@ -98,39 +106,41 @@ export const DEFAULT_SPELLING_LABEL = 'a floating major tag (@vN)'; /** * `workflow file + action path -> why this ref is spelled differently`. + * **Deliberately empty — every `uses:` in the tree follows the convention.** * * ⛔ Adding an entry here is the deliberate, reviewable act that objectui#8465 * found missing. Every entry needs a real reason and the issue that owns it — * "it was like that already" is not a reason, it is the defect. * * The test below rejects an entry that no longer matches an off-convention ref, - * so this table cannot rot into a permanent skip-list. + * so this table cannot rot into a permanent skip-list. That is not theory: the + * one entry this table ever held named `stale.yml :: actions/stale`, and when + * objectui#8548 deleted that workflow the entry stopped matching anything. + * Leaving it would have made every pull request red on a `main` nobody broke, + * so it went in the deletion's own commit — which is exactly the rule working. + * + * ⚠️ An empty table does NOT mean this mechanism is dormant. It means the tree + * currently has nothing to excuse, which is the state the convention is for. + * The gate's red branches are pinned over synthetic tables in + * `scripts/__tests__/check-action-ref-convention.test.ts`, so emptiness here + * costs no coverage. */ -export const DECLARED_EXCEPTIONS = [ - { - workflow: 'stale.yml', - action: 'actions/stale', - issue: 'objectui#8126', - reason: - 'SHA-pinned while objectui#8126 owns this line. The pinned SHA resolves to no commit ' + - 'in actions/stale: 236 scheduled runs since 2026-01-16, 0 successes, every one failing ' + - 'in `Set up job`. This entry exists so that this gate can be green on a tree it did not ' + - 'break, WITHOUT pre-empting what objectui#8126 decides to write there — the reason had ' + - 'to survive in the tree either way (objectui#8465 triage). When #8126 lands: if the ref ' + - 'becomes a tag, DELETE this entry (rule 2 below will say so); if it stays a SHA, replace ' + - 'this reason with the one that justifies keeping it.', - }, -]; +export const DECLARED_EXCEPTIONS = []; /** * Non-vacuity floors. A census that collapses reports an empty offender list, * and an empty offender list renders exactly like a healthy repository. * - * Sized well under today's readings (34 workflow files, 13 distinct refs) so + * Sized well under today's readings — 35 workflow files, 110 refs, 12 distinct, + * measured on this commit, one workflow and one distinct ref below the reading + * these floors were written against (objectui#8548 retired `stale.yml`) — so * ordinary churn never trips them, but far enough above zero that a parser - * regression cannot pass. `CONTROL_ACTION` is the positive control: a term - * known to be present, asserted in the same run as the counts, because a zero - * without one is not a reading. + * regression cannot pass. ⚠️ The floors themselves are NOT lowered for that + * deletion: they still sit far under the reading, and moving a floor down every + * time the population shrinks by one is how a floor stops being able to fail. + * `CONTROL_ACTION` is the positive control: a term known to be present, + * asserted in the same run as the counts, because a zero without one is not a + * reading. */ export const FLOORS = { workflowFiles: 20, distinctRefs: 8, totalRefs: 40 }; export const CONTROL_ACTION = 'actions/checkout';