Skip to content

Commit 3882e0d

Browse files
authored
feat: namespace cleanup (#1)
### TL;DR Added automatic cleanup of Rivet namespaces when pull requests are closed. ### What changed? - Added support for the `closed` pull request event type in the GitHub workflow - Implemented a new cleanup flow that archives Rivet namespaces when PRs are closed - Added logic to read GitHub event payload to determine PR context and action type - Updated the README to document the new cleanup functionality - Improved error handling in the Rivet Cloud API fetch function - Added helper functions to extract repository and project information ### How to test? 1. Close a pull request that has a Rivet preview namespace 2. Verify that the GitHub action runs and archives the corresponding namespace 3. Check that the PR comment is updated to show the namespace has been archived ### Why make this change? This change helps keep Rivet projects tidy by automatically cleaning up preview namespaces when they're no longer needed. Without this automation, unused namespaces would accumulate over time as PRs are closed, potentially causing resource waste and namespace clutter.
1 parent 1eb025e commit 3882e0d

File tree

2 files changed

+116
-9
lines changed

2 files changed

+116
-9
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Creates Rivet namespaces for preview deployments.
3434

3535
on:
3636
pull_request:
37-
types: [opened, synchronize, reopened]
37+
types: [opened, synchronize, reopened, closed]
3838
push:
3939
branches: [main]
4040

@@ -67,3 +67,5 @@ When a PR is opened or updated:
6767
This redeploy step is necessary because Vercel starts building immediately when a commit is pushed, before the action has a chance to set the required environment variables. The action automatically handles this by triggering a fresh deployment after configuration is complete.
6868

6969
Deployment protection is automatically bypassed by generating a token via the Vercel API.
70+
71+
When a PR is closed, the action archives the corresponding Rivet namespace to keep your project tidy.

src/index.ts

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
1+
import fs from "node:fs";
2+
13
// Environment variables
24
const GITHUB_TOKEN = process.env.GITHUB_TOKEN!;
35
const RIVET_CLOUD_TOKEN = process.env.RIVET_CLOUD_TOKEN!;
46
const RIVET_CLOUD_ENDPOINT = "https://cloud-api.rivet.dev";
57
const RIVET_ENGINE_ENDPOINT = process.env.RIVET_ENGINE_ENDPOINT || "https://api.rivet.dev";
68
const PLATFORM = process.env.PLATFORM;
9+
const GITHUB_EVENT_NAME = process.env.GITHUB_EVENT_NAME || "";
10+
const GITHUB_EVENT_PATH = process.env.GITHUB_EVENT_PATH || "";
11+
12+
function readGitHubEventPayload(): any | null {
13+
if (!GITHUB_EVENT_PATH) return null;
14+
try {
15+
const raw = fs.readFileSync(GITHUB_EVENT_PATH, "utf8");
16+
return JSON.parse(raw);
17+
} catch (error) {
18+
console.log("Failed to read GitHub event payload:", error);
19+
return null;
20+
}
21+
}
22+
23+
const EVENT_PAYLOAD = readGitHubEventPayload();
24+
const PR_NUMBER_FROM_EVENT = EVENT_PAYLOAD?.pull_request?.number
25+
? String(EVENT_PAYLOAD.pull_request.number)
26+
: undefined;
727

828
if (!PLATFORM) {
929
console.error("platform input is required");
1030
process.exit(1);
1131
}
1232
const VERCEL_TOKEN = process.env.VERCEL_TOKEN;
13-
const PR_NUMBER = process.env.PR_NUMBER; // Optional - not set for push to main
33+
const PR_NUMBER = process.env.PR_NUMBER || PR_NUMBER_FROM_EVENT; // Optional - not set for push to main
1434
const BRANCH_NAME = process.env.BRANCH_NAME || "main";
1535
const REPO_FULL_NAME = process.env.REPO_FULL_NAME!;
1636
const RUN_ID = process.env.RUN_ID!;
1737
const MAIN_BRANCH = process.env.MAIN_BRANCH || "main";
1838

39+
const IS_PR_EVENT = GITHUB_EVENT_NAME === "pull_request";
40+
const IS_PR_CLOSED = IS_PR_EVENT && EVENT_PAYLOAD?.action === "closed";
41+
const IS_CLEANUP = IS_PR_CLOSED;
42+
1943
// Runner config overrides (any JSON object, passed directly to API)
2044
const RUNNER_CONFIG: Record<string, any> = (() => {
2145
try {
@@ -33,8 +57,8 @@ if (!SUPPORTED_PLATFORMS.includes(PLATFORM)) {
3357
process.exit(1);
3458
}
3559

36-
// Validate platform-specific requirements
37-
if (PLATFORM === "vercel" && !VERCEL_TOKEN) {
60+
// Validate platform-specific requirements (skip for cleanup)
61+
if (!IS_CLEANUP && PLATFORM === "vercel" && !VERCEL_TOKEN) {
3862
console.error("vercel-token is required when platform is 'vercel'");
3963
process.exit(1);
4064
}
@@ -214,7 +238,11 @@ async function getPlatformProjectInfo(): Promise<void> {
214238
}
215239

216240
// Rivet Cloud API helpers
217-
async function rivetCloudFetch(path: string, options: RequestInit = {}): Promise<any> {
241+
async function rivetCloudFetch(
242+
path: string,
243+
options: RequestInit = {},
244+
config: { expectJson?: boolean } = {}
245+
): Promise<any> {
218246
const url = `${RIVET_CLOUD_ENDPOINT}${path}`;
219247

220248
const response = await fetch(url, {
@@ -226,12 +254,24 @@ async function rivetCloudFetch(path: string, options: RequestInit = {}): Promise
226254
},
227255
});
228256

257+
const text = await response.text();
229258
if (!response.ok) {
230-
const text = await response.text();
231259
throw new Error(`Rivet Cloud API error: ${response.status} ${text}`);
232260
}
233261

234-
return response.json();
262+
if (config.expectJson === false) {
263+
return { ok: true };
264+
}
265+
266+
if (!text) {
267+
return null;
268+
}
269+
270+
try {
271+
return JSON.parse(text);
272+
} catch {
273+
return text;
274+
}
235275
}
236276

237277
// Rivet Engine API helpers
@@ -800,8 +840,63 @@ async function configureRunners(
800840
console.log(` Configured runners for ${datacenterNames.length} datacenters`);
801841
}
802842

803-
// Main flow
804-
async function main() {
843+
function getRepoProjectName(): string {
844+
const parts = REPO_FULL_NAME?.split("/") || [];
845+
return parts[1] || "unknown";
846+
}
847+
848+
async function cleanupFlow(): Promise<void> {
849+
console.log("=== Rivet Preview Namespace Cleanup ===");
850+
console.log(`Event: ${GITHUB_EVENT_NAME}${EVENT_PAYLOAD?.action ? ` (${EVENT_PAYLOAD.action})` : ""}`);
851+
console.log(`Repo: ${REPO_FULL_NAME}`);
852+
console.log(`PR: ${PR_NUMBER || "unknown"}`);
853+
console.log("");
854+
855+
if (!IS_PR) {
856+
console.log("No PR context found, skipping cleanup.");
857+
return;
858+
}
859+
860+
const runLogsUrl = `https://github.com/${REPO_FULL_NAME}/actions/runs/${RUN_ID}`;
861+
const existingComment = await findExistingComment();
862+
let commentId = existingComment?.id ?? null;
863+
864+
const existingRivetData = existingComment?.body ? parseRivetData(existingComment.body) : null;
865+
const namespaceName = existingRivetData?.namespace || (PR_NUMBER ? `pr-${PR_NUMBER}` : null);
866+
const projectName = PROJECT_NAME || getRepoProjectName();
867+
const tableHeader = `| Project | Namespace | Status | Actions |\n|:--------|:----------|:-------|:-------|\n`;
868+
const intro = "Rivet preview namespace cleanup after PR close.\n\n";
869+
870+
if (!namespaceName) {
871+
console.log("No namespace found for cleanup.");
872+
if (commentId) {
873+
await updateComment(commentId, intro + tableHeader + `| \`${projectName}\` | - | Not found | - |`);
874+
}
875+
return;
876+
}
877+
878+
console.log("Inspecting Rivet token...");
879+
const { project, organization } = await rivetCloudFetch("/tokens/api/inspect");
880+
881+
console.log(`Archiving namespace: ${namespaceName}`);
882+
try {
883+
await rivetCloudFetch(
884+
`/projects/${project}/namespaces/${namespaceName}?org=${organization}`,
885+
{ method: "DELETE" },
886+
{ expectJson: false }
887+
);
888+
} catch (error) {
889+
console.log(`Warning: failed to archive namespace ${namespaceName}:`, error);
890+
return;
891+
}
892+
893+
console.log("Namespace archived.");
894+
if (commentId) {
895+
await updateComment(commentId, intro + tableHeader + `| \`${projectName}\` | \`${namespaceName}\` | Archived | - |`);
896+
}
897+
}
898+
899+
async function setupFlow(): Promise<void> {
805900
console.log("=== Rivet Preview Namespace Action ===");
806901
console.log(`Platform: ${PLATFORM}`);
807902
console.log(`Mode: ${IS_PR ? `PR #${PR_NUMBER}` : `Production (${MAIN_BRANCH} branch)`}`);
@@ -1016,4 +1111,14 @@ async function main() {
10161111
}
10171112
}
10181113

1114+
// Main flow
1115+
async function main() {
1116+
if (IS_CLEANUP) {
1117+
await cleanupFlow();
1118+
return;
1119+
}
1120+
1121+
await setupFlow();
1122+
}
1123+
10191124
main();

0 commit comments

Comments
 (0)