Skip to content

Commit 0f5d679

Browse files
fix(ci): sweep only the previews that still hold a slot
The nightly reconcile listed every environment that had ever taken a preview deployment, with no filter on what became of it. A preview torn down months ago stayed a candidate for ever, so each night it was re-assessed, handed to Coolify as a close event for a stack that no longer exists, and re-retired. The bound made that worse rather than containing it. Candidates were sorted ascending and truncated to the first hundred, so as soon as more than a hundred pull requests had ever been previewed, the sweep would spend its whole budget on the oldest — the ones already dealt with — and never reach a preview that had actually leaked. The safety net would have stopped catching anything, quietly. Candidates now come from the same reckoning admission uses: the environments still holding a slot. Nothing dealt with is swept, and the bound is reached only if teardown is genuinely failing, which is when a bound is worth having. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VKWqbmrPJFv8aKZBp36uD
1 parent 8c6942b commit 0f5d679

4 files changed

Lines changed: 69 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
---
3+
4+
Corrects the nightly preview reconcile before pull request previews reach a release; nothing an
5+
operator has deployed changes.

.github/workflows/reconcile-previews.yml

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,26 @@ jobs:
2626
outputs:
2727
previews: ${{ steps.candidates.outputs.previews }}
2828
steps:
29-
- name: Find the preview environments GitHub still records
30-
id: candidates
31-
env:
32-
GH_TOKEN: ${{ github.token }}
33-
run: |
34-
set -euo pipefail
35-
candidates="${RUNNER_TEMP}/preview-candidates.txt"
36-
gh api --paginate "repos/${GITHUB_REPOSITORY}/deployments?task=deploy%3Apreview&per_page=100" \
37-
--jq '.[] | .environment | select(test("^preview/pr-[1-9][0-9]*$")) | sub("^preview/pr-"; "")' \
38-
| sort -nu > "${candidates}"
29+
- name: Load the trusted preview controller
30+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
31+
with:
32+
ref: ${{ github.sha }}
33+
persist-credentials: false
34+
sparse-checkout: |
35+
.github/actions/setup-bun
36+
package.json
37+
scripts
3938
40-
count=$(grep -c . "${candidates}" || true)
41-
# Truncate rather than abort: a backlog past the bound is exactly when this job is needed,
42-
# so refusing to run would make the next night's backlog larger still.
43-
if [ "${count}" -gt 100 ]; then
44-
echo "::warning::Found ${count} preview candidates; reconciling the oldest 100 this run."
45-
head -100 "${candidates}" > "${candidates}.capped"
46-
mv "${candidates}.capped" "${candidates}"
47-
fi
48-
previews=$(jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)' "${candidates}")
49-
echo "previews=${previews}" >> "${GITHUB_OUTPUT}"
50-
echo "Found ${count} preview candidates."
39+
- name: Set up the repository's Bun version
40+
uses: ./.github/actions/setup-bun
41+
42+
- name: Find the previews that still hold a slot
43+
id: candidates
44+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
45+
with:
46+
script: |
47+
const controller = await import(`${process.env.GITHUB_WORKSPACE}/scripts/preview-controller.ts`);
48+
await controller.inventory({ github, context, core });
5149
5250
cleanup:
5351
name: "Preview / Reconcile PR ${{ matrix.pr }}"

scripts/preview-controller.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, it } from "node:test";
33

44
import {
55
assess,
6+
inventory,
67
TEARDOWN_REQUESTED_DESCRIPTION,
78
create,
89
finalize,
@@ -290,6 +291,24 @@ void describe("preview host capacity", () => {
290291
assert.equal(core.outputs.get("eligible"), "true");
291292
});
292293

294+
void it("sweeps only the previews that still hold a slot", async () => {
295+
const core = makeCore();
296+
await inventory({
297+
github: makeGitHub({
298+
deployments: occupants(3),
299+
// pr-102's teardown was recorded, so re-sending its close event would ask Coolify to
300+
// remove a stack that is already gone — and would spend the sweep's bound doing it.
301+
statuses: {
302+
102: [{ description: TEARDOWN_REQUESTED_DESCRIPTION, state: "inactive" }],
303+
},
304+
}),
305+
context: makeContext(),
306+
core,
307+
});
308+
309+
assert.equal(core.outputs.get("previews"), JSON.stringify([100, 101]));
310+
});
311+
293312
void it("still counts a preview whose cleanup was never verified", async () => {
294313
const core = makeCore();
295314
await resolve({

scripts/preview-controller.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ const TRUSTED_ASSOCIATIONS = new Set(["COLLABORATOR", "MEMBER", "OWNER"]);
7878
// GitHub's comparison endpoint reports at most this many files and gives no truncation flag.
7979
const COMPARE_FILE_LIMIT = 300;
8080
const DEFAULT_MAX_ACTIVE = 3;
81+
82+
/** One night's sweep. Reached only if teardown has been failing, which is when a bound matters. */
83+
const RECONCILE_LIMIT = 100;
8184
const LIVE_STATES = new Set(["in_progress", "pending", "queued", "success"]);
8285
const TEARDOWN_REQUESTED_DESCRIPTION =
8386
"Preview teardown requested; awaiting Coolify reconciliation.";
@@ -415,6 +418,28 @@ const assess = async ({ github, context, core }: ControllerInput): Promise<void>
415418
core.setOutput("base_ref", context.payload.repository.default_branch);
416419
};
417420

421+
/**
422+
* Candidates for the nightly sweep: the environments admission still believes are occupied. A
423+
* preview whose teardown was already recorded holds nothing, so re-sending its close event would
424+
* ask Coolify to remove a stack that is gone — every night, for every preview ever deployed. Worse,
425+
* the sweep is bounded, and a list that only grows would fill that bound with previews already dealt
426+
* with, leaving a genuinely leaked one unreached.
427+
*/
428+
const inventory = async ({ github, context, core }: ControllerInput): Promise<void> => {
429+
const { owner, repo } = context.repo;
430+
const occupied = await occupiedEnvironments(github, owner, repo);
431+
const numbers = occupied
432+
.map((environment) => Number(environment.slice("preview/pr-".length)))
433+
.filter((number) => Number.isSafeInteger(number) && number > 0)
434+
.toSorted((left, right) => left - right);
435+
core.setOutput("previews", JSON.stringify(numbers.slice(0, RECONCILE_LIMIT)));
436+
if (numbers.length > RECONCILE_LIMIT) {
437+
core.notice(
438+
`Found ${numbers.length} previews still holding a slot; reconciling the oldest ${RECONCILE_LIMIT}.`,
439+
);
440+
}
441+
};
442+
418443
const retire = async ({ github, context }: ControllerInput): Promise<void> => {
419444
const { owner, repo } = context.repo;
420445
await retireDeployments(github, owner, repo, requiredEnv(process.env, "ENVIRONMENT"));
@@ -425,6 +450,7 @@ export {
425450
PREVIEW_LABEL,
426451
assess,
427452
create,
453+
inventory,
428454
finalize,
429455
inactivate,
430456
recheck,

0 commit comments

Comments
 (0)