ghcr-cleanup-manager 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/LICENSE +21 -0
  3. package/README.md +262 -0
  4. package/dist/cleanup-summary/_cleanup-summary-markdown.d.ts +5 -0
  5. package/dist/cleanup-summary/_cleanup-summary-markdown.js +224 -0
  6. package/dist/cleanup-summary/_cleanup-summary.d.ts +56 -0
  7. package/dist/cleanup-summary/_cleanup-summary.js +59 -0
  8. package/dist/cleanup-summary/index.d.ts +2 -0
  9. package/dist/cleanup-summary/index.js +2 -0
  10. package/dist/cli/_args.d.ts +7 -0
  11. package/dist/cli/_args.js +41 -0
  12. package/dist/cli/_cleanup-command.d.ts +1 -0
  13. package/dist/cli/_cleanup-command.js +142 -0
  14. package/dist/cli/_db-merge-command.d.ts +1 -0
  15. package/dist/cli/_db-merge-command.js +41 -0
  16. package/dist/cli/_github-output.d.ts +10 -0
  17. package/dist/cli/_github-output.js +13 -0
  18. package/dist/cli/_json-output.d.ts +1 -0
  19. package/dist/cli/_json-output.js +11 -0
  20. package/dist/cli/_logger.d.ts +10 -0
  21. package/dist/cli/_logger.js +26 -0
  22. package/dist/cli/_older-than.d.ts +5 -0
  23. package/dist/cli/_older-than.js +42 -0
  24. package/dist/cli/_planner-options.d.ts +20 -0
  25. package/dist/cli/_planner-options.js +83 -0
  26. package/dist/cli/_scan-command.d.ts +1 -0
  27. package/dist/cli/_scan-command.js +39 -0
  28. package/dist/cli/_tag-selector-resolver.d.ts +3 -0
  29. package/dist/cli/_tag-selector-resolver.js +132 -0
  30. package/dist/cli/index.d.ts +2 -0
  31. package/dist/cli/index.js +52 -0
  32. package/dist/config/_service-constants.d.ts +3 -0
  33. package/dist/config/_service-constants.js +3 -0
  34. package/dist/config/index.d.ts +9 -0
  35. package/dist/config/index.js +9 -0
  36. package/dist/core/_digest-tag.d.ts +2 -0
  37. package/dist/core/_digest-tag.js +12 -0
  38. package/dist/core/_github-package-owner.d.ts +11 -0
  39. package/dist/core/_github-package-owner.js +45 -0
  40. package/dist/core/_http-error.d.ts +6 -0
  41. package/dist/core/_http-error.js +33 -0
  42. package/dist/core/_types.d.ts +54 -0
  43. package/dist/core/_types.js +8 -0
  44. package/dist/core/index.d.ts +6 -0
  45. package/dist/core/index.js +4 -0
  46. package/dist/db/_cleanup-run-writer.d.ts +10 -0
  47. package/dist/db/_cleanup-run-writer.js +113 -0
  48. package/dist/db/_db-merge-cleanup-copy.d.ts +7 -0
  49. package/dist/db/_db-merge-cleanup-copy.js +172 -0
  50. package/dist/db/_db-merge-history.d.ts +2 -0
  51. package/dist/db/_db-merge-history.js +15 -0
  52. package/dist/db/_db-merge-repository.d.ts +8 -0
  53. package/dist/db/_db-merge-repository.js +95 -0
  54. package/dist/db/_db-merge-scan-copy.d.ts +10 -0
  55. package/dist/db/_db-merge-scan-copy.js +70 -0
  56. package/dist/db/_db-merge-types.d.ts +44 -0
  57. package/dist/db/_db-merge-types.js +1 -0
  58. package/dist/db/_github-actions-run-url.d.ts +1 -0
  59. package/dist/db/_github-actions-run-url.js +9 -0
  60. package/dist/db/_manifest-reachability.d.ts +2 -0
  61. package/dist/db/_manifest-reachability.js +158 -0
  62. package/dist/db/_scan-writer.d.ts +20 -0
  63. package/dist/db/_scan-writer.js +184 -0
  64. package/dist/db/_schema.d.ts +2 -0
  65. package/dist/db/_schema.js +19 -0
  66. package/dist/db/_snapshot-repository.d.ts +24 -0
  67. package/dist/db/_snapshot-repository.js +86 -0
  68. package/dist/db/_sql-placeholders.d.ts +2 -0
  69. package/dist/db/_sql-placeholders.js +16 -0
  70. package/dist/db/index.d.ts +10 -0
  71. package/dist/db/index.js +13 -0
  72. package/dist/db/planner/_planner-direct-target-root-options.d.ts +11 -0
  73. package/dist/db/planner/_planner-direct-target-root-options.js +1 -0
  74. package/dist/db/planner/_planner-direct-target-root-tag-filters.d.ts +7 -0
  75. package/dist/db/planner/_planner-direct-target-root-tag-filters.js +41 -0
  76. package/dist/db/planner/_planner-direct-target-roots-combined-sql.d.ts +7 -0
  77. package/dist/db/planner/_planner-direct-target-roots-combined-sql.js +189 -0
  78. package/dist/db/planner/_planner-direct-target-roots-combined.d.ts +4 -0
  79. package/dist/db/planner/_planner-direct-target-roots-combined.js +10 -0
  80. package/dist/db/planner/_planner-direct-target-roots-tagged.d.ts +4 -0
  81. package/dist/db/planner/_planner-direct-target-roots-tagged.js +117 -0
  82. package/dist/db/planner/_planner-direct-target-roots.d.ts +8 -0
  83. package/dist/db/planner/_planner-direct-target-roots.js +17 -0
  84. package/dist/db/planner/_planner-direct-target-tags.d.ts +6 -0
  85. package/dist/db/planner/_planner-direct-target-tags.js +54 -0
  86. package/dist/db/planner/_planner-latest-scan.d.ts +10 -0
  87. package/dist/db/planner/_planner-latest-scan.js +20 -0
  88. package/dist/db/planner/_planner-output.d.ts +5 -0
  89. package/dist/db/planner/_planner-output.js +111 -0
  90. package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.d.ts +1 -0
  91. package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.js +110 -0
  92. package/dist/db/planner/_planner-plan-artifacts-closure-sql.d.ts +1 -0
  93. package/dist/db/planner/_planner-plan-artifacts-closure-sql.js +188 -0
  94. package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.d.ts +1 -0
  95. package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.js +86 -0
  96. package/dist/db/planner/_planner-plan-artifacts.d.ts +7 -0
  97. package/dist/db/planner/_planner-plan-artifacts.js +109 -0
  98. package/dist/db/planner/_planner-repository.d.ts +44 -0
  99. package/dist/db/planner/_planner-repository.js +105 -0
  100. package/dist/db/planner/_planner-sql.d.ts +12 -0
  101. package/dist/db/planner/_planner-sql.js +46 -0
  102. package/dist/db/planner/_planner-tag-selectors.d.ts +8 -0
  103. package/dist/db/planner/_planner-tag-selectors.js +57 -0
  104. package/dist/db/planner/_planner-types.d.ts +144 -0
  105. package/dist/db/planner/_planner-types.js +49 -0
  106. package/dist/db/planner/index.d.ts +3 -0
  107. package/dist/db/planner/index.js +2 -0
  108. package/dist/execute/_http.d.ts +7 -0
  109. package/dist/execute/_http.js +48 -0
  110. package/dist/execute/_manifest-detach.d.ts +4 -0
  111. package/dist/execute/_manifest-detach.js +31 -0
  112. package/dist/execute/_package-version-delete-client.d.ts +4 -0
  113. package/dist/execute/_package-version-delete-client.js +34 -0
  114. package/dist/execute/_package-version-page-client.d.ts +14 -0
  115. package/dist/execute/_package-version-page-client.js +64 -0
  116. package/dist/execute/_package-version-tag-source-client.d.ts +12 -0
  117. package/dist/execute/_package-version-tag-source-client.js +65 -0
  118. package/dist/execute/_plan-executor.d.ts +3 -0
  119. package/dist/execute/_plan-executor.js +69 -0
  120. package/dist/execute/_registry-manifest-client.d.ts +12 -0
  121. package/dist/execute/_registry-manifest-client.js +79 -0
  122. package/dist/execute/_registry-token-client.d.ts +4 -0
  123. package/dist/execute/_registry-token-client.js +37 -0
  124. package/dist/execute/_types.d.ts +34 -0
  125. package/dist/execute/_types.js +1 -0
  126. package/dist/execute/_untag-client.d.ts +2 -0
  127. package/dist/execute/_untag-client.js +30 -0
  128. package/dist/execute/index.d.ts +5 -0
  129. package/dist/execute/index.js +3 -0
  130. package/dist/ingest/github/_manifest-client.d.ts +14 -0
  131. package/dist/ingest/github/_manifest-client.js +108 -0
  132. package/dist/ingest/github/_manifest-ingest.d.ts +3 -0
  133. package/dist/ingest/github/_manifest-ingest.js +90 -0
  134. package/dist/ingest/github/_manifest-kind.d.ts +26 -0
  135. package/dist/ingest/github/_manifest-kind.js +65 -0
  136. package/dist/ingest/github/_package-metadata-load.d.ts +5 -0
  137. package/dist/ingest/github/_package-metadata-load.js +45 -0
  138. package/dist/ingest/github/_package-version-page-load.d.ts +13 -0
  139. package/dist/ingest/github/_package-version-page-load.js +55 -0
  140. package/dist/ingest/github/_packages-client.d.ts +10 -0
  141. package/dist/ingest/github/_packages-client.js +76 -0
  142. package/dist/ingest/github/_parallel-paginated-ingest.d.ts +12 -0
  143. package/dist/ingest/github/_parallel-paginated-ingest.js +49 -0
  144. package/dist/ingest/github/_registry-token-client.d.ts +6 -0
  145. package/dist/ingest/github/_registry-token-client.js +67 -0
  146. package/dist/ingest/github/_shared.d.ts +28 -0
  147. package/dist/ingest/github/_shared.js +70 -0
  148. package/dist/ingest/github/index.d.ts +11 -0
  149. package/dist/ingest/github/index.js +29 -0
  150. package/package.json +78 -0
  151. package/resources/sql/schema/001_schema.sql +222 -0
  152. package/resources/sql/views/001_v_latest_scan_per_package.sql +27 -0
@@ -0,0 +1,13 @@
1
+ import { type FetchLike, type GitHubScanOptions } from "./_shared.js";
2
+ export interface GitHubPackageVersionPageItem {
3
+ id: number;
4
+ name: string;
5
+ created_at: string;
6
+ updated_at: string;
7
+ metadata?: {
8
+ container?: {
9
+ tags?: string[];
10
+ };
11
+ };
12
+ }
13
+ export declare function loadPackageVersionPage(fetchImpl: FetchLike, options: GitHubScanOptions, page: number): Promise<GitHubPackageVersionPageItem[]>;
@@ -0,0 +1,55 @@
1
+ import { githubApiBaseUrl, githubApiVersion } from "../../config/index.js";
2
+ import { getOwnerURIComponent } from "../../core/index.js";
3
+ import { buildFetchTransportErrorMessage, buildHttpErrorMessage, withFetchRetry } from "./_shared.js";
4
+ export async function loadPackageVersionPage(fetchImpl, options, page) {
5
+ const startTime = Date.now();
6
+ const url = await buildPackageVersionPageUrl(fetchImpl, options, page);
7
+ let response;
8
+ try {
9
+ response = await withFetchRetry(async () => {
10
+ const pageResponse = await fetchImpl(url, {
11
+ headers: {
12
+ Accept: "application/vnd.github+json",
13
+ Authorization: `Bearer ${options.token}`,
14
+ "User-Agent": "ghcr-cleanup-manager",
15
+ "X-GitHub-Api-Version": githubApiVersion
16
+ }
17
+ });
18
+ if (!pageResponse.ok && _shouldRetryStatus(pageResponse.status)) {
19
+ throw new Error(await buildHttpErrorMessage(pageResponse, `GitHub Packages request for page ${page} failed`));
20
+ }
21
+ return pageResponse;
22
+ }, {
23
+ logger: options.logger,
24
+ label: `GitHub Packages request for page ${page}`,
25
+ shouldRetry: (error) => _shouldRetryError(error)
26
+ });
27
+ }
28
+ catch (error) {
29
+ throw new Error(buildFetchTransportErrorMessage(error, `GitHub Packages request for page ${page} failed`), {
30
+ cause: error
31
+ });
32
+ }
33
+ if (!response.ok) {
34
+ throw new Error(await buildHttpErrorMessage(response, "GitHub Packages request failed"));
35
+ }
36
+ const pageItems = (await response.json());
37
+ options.logger.debug(`Loaded GitHub package-version page ${page} in ${Date.now() - startTime}ms (${pageItems.length} items)`);
38
+ return pageItems;
39
+ }
40
+ async function buildPackageVersionPageUrl(fetchImpl, options, page) {
41
+ const ownerURIComponent = await getOwnerURIComponent(fetchImpl, options.owner, options.token, options.logger);
42
+ const url = new URL(`/${ownerURIComponent}/packages/container/${encodeURIComponent(options.packageName)}/versions`, githubApiBaseUrl);
43
+ url.searchParams.set("per_page", "100");
44
+ url.searchParams.set("page", String(page));
45
+ return url.toString();
46
+ }
47
+ function _shouldRetryStatus(status) {
48
+ return status === 429 || status === 502 || status === 503 || status === 504;
49
+ }
50
+ function _shouldRetryError(error) {
51
+ if (!(error instanceof Error)) {
52
+ return false;
53
+ }
54
+ return /fetch failed|status 429|status 502|status 503|status 504/.test(error.message);
55
+ }
@@ -0,0 +1,10 @@
1
+ import type { PackageVersionRecord, TagRecord } from "../../core/index.js";
2
+ import type { ScanWriter } from "../../db/index.js";
3
+ import { type GitHubPackageVersionPageItem } from "./_package-version-page-load.js";
4
+ import { type FetchLike, type GitHubScanOptions } from "./_shared.js";
5
+ export declare function ingestPackageVersions(fetchImpl: FetchLike, options: GitHubScanOptions, writer: ScanWriter): Promise<{
6
+ packageVersions: number;
7
+ tags: number;
8
+ }>;
9
+ export declare function buildTags(packageVersions: PackageVersionRecord[]): TagRecord[];
10
+ export declare function normalizePackageVersions(packageVersions: GitHubPackageVersionPageItem[]): PackageVersionRecord[];
@@ -0,0 +1,76 @@
1
+ import { loadPackageVersionPage } from "./_package-version-page-load.js";
2
+ import { ingestParallelPaginated } from "./_parallel-paginated-ingest.js";
3
+ export async function ingestPackageVersions(fetchImpl, options, writer) {
4
+ let tagCount = 0;
5
+ const firstPageItems = await loadPackageVersionPage(fetchImpl, options, 1);
6
+ const result = await ingestParallelPaginated({
7
+ logger: options.logger,
8
+ firstPageItems,
9
+ loadPage(page) {
10
+ return loadPackageVersionPage(fetchImpl, options, page);
11
+ },
12
+ writePage(pageItems) {
13
+ _writePage(writer, pageItems);
14
+ tagCount += _countTags(pageItems);
15
+ }
16
+ });
17
+ await _assertStableFirstPage(fetchImpl, options, firstPageItems);
18
+ return { packageVersions: result.items, tags: tagCount };
19
+ }
20
+ export function buildTags(packageVersions) {
21
+ const tags = [];
22
+ for (const version of packageVersions) {
23
+ const metadata = version.metadata?.container;
24
+ const tagNames = Array.isArray(metadata?.tags)
25
+ ? metadata.tags.filter((tag) => typeof tag === "string")
26
+ : [];
27
+ for (const tagName of tagNames) {
28
+ tags.push({
29
+ tag: tagName,
30
+ versionId: version.versionId
31
+ });
32
+ }
33
+ }
34
+ return tags.sort((left, right) => left.tag.localeCompare(right.tag));
35
+ }
36
+ export function normalizePackageVersions(packageVersions) {
37
+ return packageVersions
38
+ .map((version) => ({
39
+ versionId: version.id,
40
+ createdAt: version.created_at,
41
+ updatedAt: version.updated_at,
42
+ metadata: version.metadata
43
+ }))
44
+ .sort((left, right) => left.versionId - right.versionId);
45
+ }
46
+ function _writePage(writer, pageItems) {
47
+ const versions = normalizePackageVersions(pageItems);
48
+ const rawItemsByVersionId = new Map(pageItems.map((pageItem) => [pageItem.id, pageItem]));
49
+ for (const version of versions) {
50
+ writer.insertPackageVersion(version);
51
+ writer.insertPackageVersionPayload(version.versionId, JSON.stringify(rawItemsByVersionId.get(version.versionId)));
52
+ }
53
+ const tags = buildTags(versions);
54
+ for (const tag of tags) {
55
+ writer.insertTag(tag);
56
+ }
57
+ }
58
+ function _countTags(pageItems) {
59
+ return buildTags(normalizePackageVersions(pageItems)).length;
60
+ }
61
+ async function _assertStableFirstPage(fetchImpl, options, initialPageItems) {
62
+ const reloadedPageItems = await loadPackageVersionPage(fetchImpl, options, 1);
63
+ if (_buildPageSignature(initialPageItems) === _buildPageSignature(reloadedPageItems)) {
64
+ return;
65
+ }
66
+ throw new Error(`GitHub package-version page 1 changed while scanning ${options.owner}/${options.packageName}; aborting scan`);
67
+ }
68
+ function _buildPageSignature(pageItems) {
69
+ return JSON.stringify(pageItems.map((pageItem) => ({
70
+ id: pageItem.id,
71
+ name: pageItem.name,
72
+ createdAt: pageItem.created_at,
73
+ updatedAt: pageItem.updated_at,
74
+ tags: Array.isArray(pageItem.metadata?.container?.tags) ? [...pageItem.metadata.container.tags] : []
75
+ })));
76
+ }
@@ -0,0 +1,12 @@
1
+ import type { GitHubScanLogger } from "./_shared.js";
2
+ export interface ParallelPaginatedIngestOptions<T> {
3
+ loadPage(page: number): Promise<T[]>;
4
+ writePage(pageItems: T[], page: number): Promise<void> | void;
5
+ logger: GitHubScanLogger;
6
+ firstPageItems?: T[];
7
+ }
8
+ export interface ParallelPaginatedIngestResult {
9
+ pages: number;
10
+ items: number;
11
+ }
12
+ export declare function ingestParallelPaginated<T>(options: ParallelPaginatedIngestOptions<T>): Promise<ParallelPaginatedIngestResult>;
@@ -0,0 +1,49 @@
1
+ import { packageVersionPageFetchConcurrency, paginatedIngestProgressIntervalPages } from "../../config/index.js";
2
+ const _DEFAULT_PAGE_SIZE = 100;
3
+ const _PROGRESS_LABEL = "GitHub package-version pages";
4
+ export async function ingestParallelPaginated(options) {
5
+ const firstPageItems = options.firstPageItems ?? (await options.loadPage(1));
6
+ let pages = 0;
7
+ let items = 0;
8
+ let lastLoggedPage = 0;
9
+ if (firstPageItems.length === 0) {
10
+ return { pages: 0, items: 0 };
11
+ }
12
+ await options.writePage(firstPageItems, 1);
13
+ pages = 1;
14
+ items = firstPageItems.length;
15
+ options.logger.info(`Loaded ${_PROGRESS_LABEL} 1 (${items} items total)`);
16
+ lastLoggedPage = 1;
17
+ if (firstPageItems.length < _DEFAULT_PAGE_SIZE) {
18
+ return { pages, items };
19
+ }
20
+ let nextPage = 2;
21
+ let stopPageExclusive = Number.POSITIVE_INFINITY;
22
+ const workers = Array.from({ length: packageVersionPageFetchConcurrency }, async () => {
23
+ while (nextPage < stopPageExclusive) {
24
+ const page = nextPage;
25
+ nextPage += 1;
26
+ const pageItems = await options.loadPage(page);
27
+ if (pageItems.length === 0) {
28
+ stopPageExclusive = Math.min(stopPageExclusive, page);
29
+ return;
30
+ }
31
+ await options.writePage(pageItems, page);
32
+ pages = Math.max(pages, page);
33
+ items += pageItems.length;
34
+ if (page % paginatedIngestProgressIntervalPages === 0 || pageItems.length < _DEFAULT_PAGE_SIZE) {
35
+ options.logger.info(`Loaded ${_PROGRESS_LABEL} ${page} (${items} items total)`);
36
+ lastLoggedPage = Math.max(lastLoggedPage, page);
37
+ }
38
+ if (pageItems.length < _DEFAULT_PAGE_SIZE) {
39
+ stopPageExclusive = Math.min(stopPageExclusive, page + 1);
40
+ return;
41
+ }
42
+ }
43
+ });
44
+ await Promise.all(workers);
45
+ if (pages > 0 && lastLoggedPage !== pages) {
46
+ options.logger.info(`Loaded ${_PROGRESS_LABEL} ${pages} (${items} items total)`);
47
+ }
48
+ return { pages, items };
49
+ }
@@ -0,0 +1,6 @@
1
+ import { type FetchLike, type GitHubScanOptions } from "./_shared.js";
2
+ export interface RegistryPullToken {
3
+ token: string;
4
+ expiresAt: number;
5
+ }
6
+ export declare function loadRegistryPullToken(fetchImpl: FetchLike, registryBaseUrl: string, options: GitHubScanOptions): Promise<RegistryPullToken>;
@@ -0,0 +1,67 @@
1
+ import { buildFetchTransportErrorMessage, buildHttpErrorMessage, withFetchRetry } from "./_shared.js";
2
+ export async function loadRegistryPullToken(fetchImpl, registryBaseUrl, options) {
3
+ const startTime = Date.now();
4
+ const url = _buildRegistryTokenUrl(registryBaseUrl, options);
5
+ let response;
6
+ try {
7
+ response = await withFetchRetry(async () => {
8
+ const tokenResponse = await fetchImpl(url, {
9
+ headers: _buildTokenHeaders(options)
10
+ });
11
+ if (!tokenResponse.ok && _shouldRetryStatus(tokenResponse.status)) {
12
+ throw new Error(await buildHttpErrorMessage(tokenResponse, "GHCR token request failed"));
13
+ }
14
+ return tokenResponse;
15
+ }, {
16
+ logger: options.logger,
17
+ label: "GHCR token request",
18
+ shouldRetry: (error) => _shouldRetryError(error)
19
+ });
20
+ }
21
+ catch (error) {
22
+ throw new Error(buildFetchTransportErrorMessage(error, "GHCR token request failed"), {
23
+ cause: error
24
+ });
25
+ }
26
+ if (!response.ok) {
27
+ throw new Error(await buildHttpErrorMessage(response, "GHCR token request failed"));
28
+ }
29
+ const body = (await response.json());
30
+ if (!body.token) {
31
+ throw new Error("GHCR token response did not include a token");
32
+ }
33
+ const registryPullToken = {
34
+ token: body.token,
35
+ expiresAt: _getExpiresAt(body.expires_in, body.issued_at)
36
+ };
37
+ options.logger.debug(`Loaded GHCR pull token in ${Date.now() - startTime}ms (expires in ${Math.max(0, Math.round((registryPullToken.expiresAt - Date.now()) / 1000))}s)`);
38
+ return registryPullToken;
39
+ }
40
+ function _buildRegistryTokenUrl(registryBaseUrl, options) {
41
+ const registryUrl = new URL(registryBaseUrl);
42
+ const tokenUrl = new URL("/token", registryUrl);
43
+ tokenUrl.searchParams.set("service", registryUrl.host);
44
+ tokenUrl.searchParams.set("scope", `repository:${options.owner}/${options.packageName}:pull`);
45
+ return tokenUrl.toString();
46
+ }
47
+ function _buildTokenHeaders(options) {
48
+ const basicAuth = Buffer.from(`${options.owner}:${options.token}`).toString("base64");
49
+ return {
50
+ "User-Agent": "ghcr-cleanup-manager",
51
+ Authorization: `Basic ${basicAuth}`
52
+ };
53
+ }
54
+ function _getExpiresAt(expiresIn, issuedAt) {
55
+ const expiresInSeconds = typeof expiresIn === "number" && expiresIn > 0 ? expiresIn : 60;
56
+ const issuedAtMilliseconds = typeof issuedAt === "string" && !Number.isNaN(Date.parse(issuedAt)) ? Date.parse(issuedAt) : Date.now();
57
+ return issuedAtMilliseconds + expiresInSeconds * 1000;
58
+ }
59
+ function _shouldRetryStatus(status) {
60
+ return status === 429 || status === 502 || status === 503 || status === 504;
61
+ }
62
+ function _shouldRetryError(error) {
63
+ if (!(error instanceof Error)) {
64
+ return false;
65
+ }
66
+ return /fetch failed|status 429|status 502|status 503|status 504/.test(error.message);
67
+ }
@@ -0,0 +1,28 @@
1
+ export { buildHttpErrorMessage } from "../../core/index.js";
2
+ export interface GitHubScanOptions {
3
+ owner: string;
4
+ packageName: string;
5
+ token: string;
6
+ logger: GitHubScanLogger;
7
+ }
8
+ export interface GitHubScanLogger {
9
+ debug(message: string): void;
10
+ info(message: string): void;
11
+ warn(message: string): void;
12
+ error(message: string): void;
13
+ }
14
+ export interface FetchLikeResponse {
15
+ ok: boolean;
16
+ status: number;
17
+ headers: Headers;
18
+ json(): Promise<unknown>;
19
+ }
20
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<FetchLikeResponse>;
21
+ export declare const acceptedManifestMediaTypes: string;
22
+ export declare function defaultFetch(input: string, init?: RequestInit): Promise<FetchLikeResponse>;
23
+ export declare function buildFetchTransportErrorMessage(error: unknown, fallback: string): string;
24
+ export declare function withFetchRetry<T>(run: () => Promise<T>, options: {
25
+ logger: GitHubScanLogger;
26
+ label: string;
27
+ shouldRetry?: (error: unknown) => boolean;
28
+ }): Promise<T>;
@@ -0,0 +1,70 @@
1
+ import { ingestRequestRetryCount, ingestRequestRetryDelayMs } from "../../config/index.js";
2
+ export { buildHttpErrorMessage } from "../../core/index.js";
3
+ export const acceptedManifestMediaTypes = [
4
+ "application/vnd.oci.image.index.v1+json",
5
+ "application/vnd.oci.image.manifest.v1+json",
6
+ "application/vnd.docker.distribution.manifest.list.v2+json",
7
+ "application/vnd.docker.distribution.manifest.v2+json",
8
+ "application/vnd.oci.artifact.manifest.v1+json"
9
+ ].join(", ");
10
+ export async function defaultFetch(input, init) {
11
+ return fetch(input, init);
12
+ }
13
+ export function buildFetchTransportErrorMessage(error, fallback) {
14
+ const details = [fallback];
15
+ details.push(..._collectErrorDetails(error));
16
+ return details.join(" - ");
17
+ }
18
+ export async function withFetchRetry(run, options) {
19
+ let attempt = 0;
20
+ for (;;) {
21
+ try {
22
+ return await run();
23
+ }
24
+ catch (error) {
25
+ attempt += 1;
26
+ const shouldRetry = options.shouldRetry ? options.shouldRetry(error) : true;
27
+ if (!shouldRetry || attempt > ingestRequestRetryCount) {
28
+ throw error;
29
+ }
30
+ const errorMessage = error instanceof Error ? error.message : String(error);
31
+ options.logger.warn(`${options.label} failed on attempt ${attempt}/${ingestRequestRetryCount + 1}; retrying in ${ingestRequestRetryDelayMs}ms - ${errorMessage}`);
32
+ await _sleep(ingestRequestRetryDelayMs);
33
+ }
34
+ }
35
+ }
36
+ function _sleep(delayMs) {
37
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
38
+ }
39
+ function _collectErrorDetails(error) {
40
+ if (!(error instanceof Error)) {
41
+ return [String(error)];
42
+ }
43
+ const details = [];
44
+ const seen = new Set();
45
+ let current = error;
46
+ while (current instanceof Error && !seen.has(current)) {
47
+ seen.add(current);
48
+ const message = _formatErrorMessage(current);
49
+ if (message && !details.includes(message)) {
50
+ details.push(message);
51
+ }
52
+ current = current.cause;
53
+ }
54
+ return details.length > 0 ? details : [String(error)];
55
+ }
56
+ function _formatErrorMessage(error) {
57
+ const code = "code" in error && typeof error.code === "string"
58
+ ? error.code
59
+ : undefined;
60
+ if (error.message && code) {
61
+ return `${error.message} (${code})`;
62
+ }
63
+ if (error.message) {
64
+ return error.message;
65
+ }
66
+ if (code) {
67
+ return code;
68
+ }
69
+ return undefined;
70
+ }
@@ -0,0 +1,11 @@
1
+ import { ScanWriter, SnapshotRepository } from "../../db/index.js";
2
+ import { type GitHubPackageMetadata } from "./_package-metadata-load.js";
3
+ import { type FetchLike, type GitHubScanOptions } from "./_shared.js";
4
+ export { type GitHubScanOptions } from "./_shared.js";
5
+ export { loadPackageMetadata, type GitHubPackageMetadata } from "./_package-metadata-load.js";
6
+ export { defaultFetch, type FetchLike, type GitHubScanLogger } from "./_shared.js";
7
+ interface _GitHubScanRuntime {
8
+ fetchImpl?: FetchLike;
9
+ packageMetadata?: GitHubPackageMetadata;
10
+ }
11
+ export declare function importGitHubScan(options: GitHubScanOptions, writer: ScanWriter, repository: SnapshotRepository, runtime?: _GitHubScanRuntime): Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { ghcrRegistryBaseUrl } from "../../config/index.js";
2
+ import { ingestManifests } from "./_manifest-ingest.js";
3
+ import { loadPackageMetadata } from "./_package-metadata-load.js";
4
+ import { ingestPackageVersions } from "./_packages-client.js";
5
+ import { defaultFetch } from "./_shared.js";
6
+ export { loadPackageMetadata } from "./_package-metadata-load.js";
7
+ export { defaultFetch } from "./_shared.js";
8
+ export async function importGitHubScan(options, writer, repository, runtime) {
9
+ const fetchImpl = runtime?.fetchImpl ?? defaultFetch;
10
+ const scanStartedAt = new Date().toISOString();
11
+ const fullPackageName = `${options.owner}/${options.packageName}`;
12
+ const packageMetadata = runtime?.packageMetadata ?? (await loadPackageMetadata(fetchImpl, options));
13
+ writer.startScan(options.owner, options.packageName, scanStartedAt, packageMetadata);
14
+ const scanId = writer.getActiveScanId();
15
+ options.logger.info(`Starting GitHub package scan for ${fullPackageName}`);
16
+ try {
17
+ options.logger.info(`Starting remote data pull for ${fullPackageName}`);
18
+ const counts = await ingestPackageVersions(fetchImpl, options, writer);
19
+ options.logger.info(`Loaded ${counts.packageVersions} package versions and ${counts.tags} tags`);
20
+ await ingestManifests(fetchImpl, ghcrRegistryBaseUrl, options, writer, repository, scanId);
21
+ options.logger.info(`Completed remote data pull for ${fullPackageName}`);
22
+ writer.markScanCompleted(new Date().toISOString());
23
+ options.logger.info(`Completed GitHub package scan for ${fullPackageName}`);
24
+ }
25
+ catch (error) {
26
+ writer.markScanFailed(new Date().toISOString());
27
+ throw error;
28
+ }
29
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "ghcr-cleanup-manager",
3
+ "description": "GHCR Cleanup Manager: GitHub Action and CLI for safe GitHub Container Registry cleanup and inspection.",
4
+ "homepage": "https://github.com/ghcr-manager/ghcr-cleanup-manager#readme",
5
+ "version": "v1.1.0",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "keywords": [
9
+ "ghcr",
10
+ "github-container-registry",
11
+ "github-packages",
12
+ "container-registry",
13
+ "docker",
14
+ "oci",
15
+ "cleanup",
16
+ "github-actions",
17
+ "sqlite"
18
+ ],
19
+ "bugs": {
20
+ "url": "https://github.com/ghcr-manager/ghcr-cleanup-manager/issues"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/ghcr-manager/ghcr-cleanup-manager"
25
+ },
26
+ "workspaces": [
27
+ "visualizer"
28
+ ],
29
+ "engines": {
30
+ "node": ">=24.0.0"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "resources/sql",
35
+ "LICENSE",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "bin": {
40
+ "ghcr-cleanup-manager": "./dist/cli/index.js"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc --project tsconfig.json && npm run build:visualizer",
44
+ "build:visualizer": "npm run build --workspace visualizer",
45
+ "ghcr-cleanup-manager": "tsx src/cli/index.ts",
46
+ "ghcr-cleanup-manager:dist": "node dist/cli/index.js",
47
+ "visualize": "npm run start --workspace visualizer --",
48
+ "format": "prettier --write .",
49
+ "format:check": "prettier --check .",
50
+ "lint": "npm run check:file-names && npm run check:test-mapping && npm run typecheck && npm run typecheck --workspace visualizer && npm run lint:ts && npm run lint:yaml && npm run lint:markdown && npm run lint --workspace visualizer && npm run format:check",
51
+ "check:file-names": "node tools/tests/check-src-file-names.mjs",
52
+ "check:test-mapping": "node tools/tests/check-test-mapping.mjs",
53
+ "lint:ts": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"",
54
+ "lint:yaml": "eslint \".github/**/*.yml\" \"*.yml\"",
55
+ "lint:markdown": "markdownlint-cli2",
56
+ "typecheck": "tsc --noEmit --project tsconfig.json",
57
+ "test": "bash -lc 'shopt -s globstar nullglob; node --import tsx --test tests/**/*.test.ts' && npm run test --workspace visualizer",
58
+ "coverage": "bash -lc 'shopt -s globstar nullglob; c8 --src src --reporter=text --reporter=text-summary node --import tsx --test tests/**/*.test.ts'"
59
+ },
60
+ "dependencies": {
61
+ "better-sqlite3": "^12.9.0"
62
+ },
63
+ "devDependencies": {
64
+ "@eslint/js": "^10.0.1",
65
+ "@types/better-sqlite3": "^7.6.13",
66
+ "@types/node": "^24.12.4",
67
+ "c8": "^11.0.0",
68
+ "eslint": "^10.2.1",
69
+ "eslint-plugin-yml": "^3.4.0",
70
+ "globals": "^17.5.0",
71
+ "markdownlint-cli2": "^0.22.1",
72
+ "prettier": "^3.8.3",
73
+ "tsx": "^4.20.6",
74
+ "typescript": "^6.0.3",
75
+ "typescript-eslint": "^8.46.2",
76
+ "yaml": "^2.9.0"
77
+ }
78
+ }