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,69 @@
1
+ import { DeletePlanValidationStatuses } from "../db/index.js";
2
+ import { deletePackageVersion } from "./_package-version-delete-client.js";
3
+ import { untagRootTags } from "./_untag-client.js";
4
+ export async function executeDeletePlan(plan, options) {
5
+ let deletedPackageVersionCount = 0;
6
+ let detachedTagCount = 0;
7
+ const directTargetTagSet = new Set(plan.directTargetTags);
8
+ const deletedVersionIds = new Set();
9
+ for (const decision of plan.rootDecisions) {
10
+ if (decision.validationStatus !== DeletePlanValidationStatuses.untagOnly) {
11
+ continue;
12
+ }
13
+ if (!options.listRootTags) {
14
+ throw new Error(`execution requires listRootTags support for untag-only root ${decision.digest}`);
15
+ }
16
+ const selectedTags = options
17
+ .listRootTags({
18
+ owner: plan.owner,
19
+ packageName: plan.packageName,
20
+ versionId: decision.versionId,
21
+ digest: decision.digest
22
+ })
23
+ .filter((tag) => directTargetTagSet.has(tag));
24
+ if (selectedTags.length === 0) {
25
+ throw new Error(`no selected tags resolved for untag-only root ${decision.digest}`);
26
+ }
27
+ detachedTagCount += await untagRootTags(plan.owner, plan.packageName, decision.versionId, decision.digest, selectedTags, options);
28
+ }
29
+ const closureMembersByRootDigest = new Map(plan.fullyDeletableRoots.map((root) => [
30
+ root.digest,
31
+ plan.closureManifests
32
+ .filter((manifest) => manifest.sourceDigest === root.digest)
33
+ .sort((left, right) => {
34
+ if (left.hopsFromRoot !== right.hopsFromRoot) {
35
+ return right.hopsFromRoot - left.hopsFromRoot;
36
+ }
37
+ return left.memberVersionId - right.memberVersionId;
38
+ })
39
+ ]));
40
+ for (const root of plan.fullyDeletableRoots) {
41
+ const closureMembers = closureMembersByRootDigest.get(root.digest) ?? [];
42
+ const deleteTargets = closureMembers.length > 0
43
+ ? closureMembers.map((member) => ({
44
+ versionId: member.memberVersionId,
45
+ digest: member.memberDigest
46
+ }))
47
+ : [{ versionId: root.versionId, digest: root.digest }];
48
+ for (const target of deleteTargets) {
49
+ if (deletedVersionIds.has(target.versionId)) {
50
+ continue;
51
+ }
52
+ options.logger.info(`Deleting package version ${target.versionId} for ${plan.owner}/${plan.packageName} (${target.digest})`);
53
+ await deletePackageVersion(plan.owner, plan.packageName, target.versionId, options.token, options.logger, {
54
+ fetchImpl: options.fetchImpl
55
+ });
56
+ deletedVersionIds.add(target.versionId);
57
+ deletedPackageVersionCount += 1;
58
+ }
59
+ }
60
+ return {
61
+ owner: plan.owner,
62
+ packageName: plan.packageName,
63
+ scanCompletedAt: plan.scanCompletedAt,
64
+ plannerInputs: plan.plannerInputs,
65
+ deletedPackageVersionCount,
66
+ detachedTagCount,
67
+ blockedRoots: plan.blockedRoots
68
+ };
69
+ }
@@ -0,0 +1,12 @@
1
+ import type { DeleteExecutionLogger, GitHubPackageFetch } from "./_types.js";
2
+ export interface LoadedRegistryManifest {
3
+ digest: string;
4
+ mediaType: string;
5
+ rawJson: string;
6
+ }
7
+ export declare function loadRegistryManifestByDigest(owner: string, packageName: string, digest: string, registryToken: string, logger: DeleteExecutionLogger, runtime?: {
8
+ fetchImpl?: GitHubPackageFetch;
9
+ }): Promise<LoadedRegistryManifest>;
10
+ export declare function putRegistryManifestForTag(owner: string, packageName: string, tag: string, mediaType: string, manifestJson: string, registryToken: string, logger: DeleteExecutionLogger, runtime?: {
11
+ fetchImpl?: GitHubPackageFetch;
12
+ }): Promise<string>;
@@ -0,0 +1,79 @@
1
+ import { ghcrRegistryBaseUrl } from "../config/index.js";
2
+ import { createHash } from "node:crypto";
3
+ import { buildHttpErrorMessage, buildTransportErrorMessage, isRetryableStatus, resolveFetch, resolveJsonHeaders, runWithRetry } from "./_http.js";
4
+ const _ACCEPTED_MANIFEST_MEDIA_TYPES = [
5
+ "application/vnd.oci.image.index.v1+json",
6
+ "application/vnd.oci.image.manifest.v1+json",
7
+ "application/vnd.docker.distribution.manifest.list.v2+json",
8
+ "application/vnd.docker.distribution.manifest.v2+json",
9
+ "application/vnd.oci.artifact.manifest.v1+json"
10
+ ].join(", ");
11
+ export async function loadRegistryManifestByDigest(owner, packageName, digest, registryToken, logger, runtime) {
12
+ const fetchImpl = resolveFetch(runtime?.fetchImpl);
13
+ const url = new URL(`/v2/${owner}/${packageName}/manifests/${digest}`, ghcrRegistryBaseUrl);
14
+ let response;
15
+ try {
16
+ response = await runWithRetry(`GHCR manifest request for ${digest}`, logger, async () => {
17
+ const manifestResponse = await fetchImpl(url.toString(), {
18
+ headers: {
19
+ Accept: _ACCEPTED_MANIFEST_MEDIA_TYPES,
20
+ Authorization: `Bearer ${registryToken}`,
21
+ "User-Agent": "ghcr-cleanup-manager"
22
+ }
23
+ });
24
+ if (!manifestResponse.ok && isRetryableStatus(manifestResponse.status)) {
25
+ throw new Error(await buildHttpErrorMessage(manifestResponse, `GHCR manifest request for ${digest} failed`));
26
+ }
27
+ return manifestResponse;
28
+ });
29
+ }
30
+ catch (error) {
31
+ throw new Error(buildTransportErrorMessage(error, `GHCR manifest request for ${digest} failed`), {
32
+ cause: error
33
+ });
34
+ }
35
+ if (!response.ok) {
36
+ throw new Error(await buildHttpErrorMessage(response, `GHCR manifest request for ${digest} failed`));
37
+ }
38
+ const document = (await response.json());
39
+ const mediaType = document.mediaType ?? resolveJsonHeaders(response);
40
+ if (!mediaType) {
41
+ throw new Error(`manifest response for ${digest} did not include a media type`);
42
+ }
43
+ return {
44
+ digest,
45
+ mediaType,
46
+ rawJson: JSON.stringify(document)
47
+ };
48
+ }
49
+ export async function putRegistryManifestForTag(owner, packageName, tag, mediaType, manifestJson, registryToken, logger, runtime) {
50
+ const fetchImpl = resolveFetch(runtime?.fetchImpl);
51
+ const url = new URL(`/v2/${owner}/${packageName}/manifests/${encodeURIComponent(tag)}`, ghcrRegistryBaseUrl);
52
+ let response;
53
+ try {
54
+ response = await runWithRetry(`GHCR manifest put request for tag ${tag}`, logger, async () => {
55
+ const putResponse = await fetchImpl(url.toString(), {
56
+ method: "PUT",
57
+ headers: {
58
+ Authorization: `Bearer ${registryToken}`,
59
+ "Content-Type": mediaType,
60
+ "User-Agent": "ghcr-cleanup-manager"
61
+ },
62
+ body: manifestJson
63
+ });
64
+ if (!putResponse.ok && isRetryableStatus(putResponse.status)) {
65
+ throw new Error(await buildHttpErrorMessage(putResponse, `GHCR manifest put request for tag ${tag} failed`));
66
+ }
67
+ return putResponse;
68
+ });
69
+ }
70
+ catch (error) {
71
+ throw new Error(buildTransportErrorMessage(error, `GHCR manifest put request for tag ${tag} failed`), {
72
+ cause: error
73
+ });
74
+ }
75
+ if (!response.ok) {
76
+ throw new Error(await buildHttpErrorMessage(response, `GHCR manifest put request for tag ${tag} failed`));
77
+ }
78
+ return `sha256:${createHash("sha256").update(manifestJson).digest("hex")}`;
79
+ }
@@ -0,0 +1,4 @@
1
+ import type { DeleteExecutionLogger, GitHubPackageFetch } from "./_types.js";
2
+ export declare function loadRegistryPushToken(owner: string, packageName: string, token: string, logger: DeleteExecutionLogger, runtime?: {
3
+ fetchImpl?: GitHubPackageFetch;
4
+ }): Promise<string>;
@@ -0,0 +1,37 @@
1
+ import { ghcrRegistryBaseUrl } from "../config/index.js";
2
+ import { buildHttpErrorMessage, buildTransportErrorMessage, isRetryableStatus, resolveFetch, runWithRetry } from "./_http.js";
3
+ export async function loadRegistryPushToken(owner, packageName, token, logger, runtime) {
4
+ const fetchImpl = resolveFetch(runtime?.fetchImpl);
5
+ const registryUrl = new URL(ghcrRegistryBaseUrl);
6
+ const tokenUrl = new URL("/token", registryUrl);
7
+ tokenUrl.searchParams.set("service", registryUrl.host);
8
+ tokenUrl.searchParams.set("scope", `repository:${owner}/${packageName}:pull,push`);
9
+ let response;
10
+ try {
11
+ response = await runWithRetry("GHCR token request", logger, async () => {
12
+ const tokenResponse = await fetchImpl(tokenUrl.toString(), {
13
+ headers: {
14
+ "User-Agent": "ghcr-cleanup-manager",
15
+ Authorization: `Basic ${Buffer.from(`${owner}:${token}`).toString("base64")}`
16
+ }
17
+ });
18
+ if (!tokenResponse.ok && isRetryableStatus(tokenResponse.status)) {
19
+ throw new Error(await buildHttpErrorMessage(tokenResponse, "GHCR token request failed"));
20
+ }
21
+ return tokenResponse;
22
+ });
23
+ }
24
+ catch (error) {
25
+ throw new Error(buildTransportErrorMessage(error, "GHCR token request failed"), {
26
+ cause: error
27
+ });
28
+ }
29
+ if (!response.ok) {
30
+ throw new Error(await buildHttpErrorMessage(response, "GHCR token request failed"));
31
+ }
32
+ const body = (await response.json());
33
+ if (!body.token) {
34
+ throw new Error("GHCR token response did not include a token");
35
+ }
36
+ return body.token;
37
+ }
@@ -0,0 +1,34 @@
1
+ import type { DeletePlan } from "../db/index.js";
2
+ export interface DeleteExecutionSummary {
3
+ owner: string;
4
+ packageName: string;
5
+ scanCompletedAt: string;
6
+ plannerInputs: DeletePlan["plannerInputs"];
7
+ deletedPackageVersionCount: number;
8
+ detachedTagCount: number;
9
+ blockedRoots: DeletePlan["blockedRoots"];
10
+ }
11
+ export interface DeleteExecutionOptions {
12
+ token: string;
13
+ logger: DeleteExecutionLogger;
14
+ fetchImpl?: GitHubPackageFetch;
15
+ listRootTags?: (root: {
16
+ owner: string;
17
+ packageName: string;
18
+ versionId: number;
19
+ digest: string;
20
+ }) => string[];
21
+ }
22
+ export interface DeleteExecutionLogger {
23
+ debug(message: string): void;
24
+ info(message: string): void;
25
+ warn(message: string): void;
26
+ error(message: string): void;
27
+ }
28
+ export interface GitHubPackageFetchResponse {
29
+ ok: boolean;
30
+ status: number;
31
+ headers: Headers;
32
+ json(): Promise<unknown>;
33
+ }
34
+ export type GitHubPackageFetch = (input: string, init?: RequestInit) => Promise<GitHubPackageFetchResponse>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { DeleteExecutionOptions } from "./_types.js";
2
+ export declare function untagRootTags(owner: string, packageName: string, sourceVersionId: number, sourceDigest: string, tags: string[], options: DeleteExecutionOptions): Promise<number>;
@@ -0,0 +1,30 @@
1
+ import { buildDetachedManifestClone } from "./_manifest-detach.js";
2
+ import { findPackageVersionByDigestAndTag } from "./_package-version-page-client.js";
3
+ import { deletePackageVersion } from "./_package-version-delete-client.js";
4
+ import { loadRegistryManifestByDigest, putRegistryManifestForTag } from "./_registry-manifest-client.js";
5
+ import { loadRegistryPushToken } from "./_registry-token-client.js";
6
+ export async function untagRootTags(owner, packageName, sourceVersionId, sourceDigest, tags, options) {
7
+ const registryToken = await loadRegistryPushToken(owner, packageName, options.token, options.logger, {
8
+ fetchImpl: options.fetchImpl
9
+ });
10
+ const sourceManifest = await loadRegistryManifestByDigest(owner, packageName, sourceDigest, registryToken, options.logger, {
11
+ fetchImpl: options.fetchImpl
12
+ });
13
+ for (const tag of tags) {
14
+ options.logger.info(`Detaching tag ${owner}/${packageName}:${tag} from ${sourceDigest}`);
15
+ const detachedManifestJson = buildDetachedManifestClone(sourceManifest.rawJson, sourceManifest.mediaType, {
16
+ detachedTag: tag,
17
+ sourceDigest
18
+ });
19
+ const detachedDigest = await putRegistryManifestForTag(owner, packageName, tag, sourceManifest.mediaType, detachedManifestJson, registryToken, options.logger, {
20
+ fetchImpl: options.fetchImpl
21
+ });
22
+ const detachedVersionId = await findPackageVersionByDigestAndTag(owner, packageName, detachedDigest, tag, options.token, options.logger, {
23
+ fetchImpl: options.fetchImpl
24
+ });
25
+ await deletePackageVersion(owner, packageName, detachedVersionId, options.token, options.logger, {
26
+ fetchImpl: options.fetchImpl
27
+ });
28
+ }
29
+ return tags.length;
30
+ }
@@ -0,0 +1,5 @@
1
+ export { executeDeletePlan } from "./_plan-executor.js";
2
+ export { listPackageVersionTagSources, listPresentPackageVersionIds } from "./_package-version-tag-source-client.js";
3
+ export { untagRootTags } from "./_untag-client.js";
4
+ export type { GitHubPackageVersionTagSource } from "./_package-version-tag-source-client.js";
5
+ export type { DeleteExecutionSummary } from "./_types.js";
@@ -0,0 +1,3 @@
1
+ export { executeDeletePlan } from "./_plan-executor.js";
2
+ export { listPackageVersionTagSources, listPresentPackageVersionIds } from "./_package-version-tag-source-client.js";
3
+ export { untagRootTags } from "./_untag-client.js";
@@ -0,0 +1,14 @@
1
+ import type { ManifestDescriptorRecord, ManifestEdgeRecord, ManifestRecord } from "../../core/index.js";
2
+ import { type FetchLike, type GitHubScanOptions } from "./_shared.js";
3
+ type _LoadedManifestRecord = Omit<ManifestRecord, "versionId">;
4
+ export declare function loadManifestGraph(fetchImpl: FetchLike, registryBaseUrl: string, digest: string, registryToken: string, options: GitHubScanOptions): Promise<{
5
+ record: _LoadedManifestRecord;
6
+ descriptorRecords: ManifestDescriptorRecord[];
7
+ edgeRecords: ManifestEdgeRecord[];
8
+ rawJson: string;
9
+ }>;
10
+ export declare function buildManifestRelations(digest: string, rawJson: string): {
11
+ descriptorRecords: ManifestDescriptorRecord[];
12
+ edgeRecords: ManifestEdgeRecord[];
13
+ };
14
+ export {};
@@ -0,0 +1,108 @@
1
+ import { classifyManifestKind } from "./_manifest-kind.js";
2
+ import { acceptedManifestMediaTypes, buildFetchTransportErrorMessage, buildHttpErrorMessage, withFetchRetry } from "./_shared.js";
3
+ export async function loadManifestGraph(fetchImpl, registryBaseUrl, digest, registryToken, options) {
4
+ const startTime = Date.now();
5
+ const url = new URL(`/v2/${options.owner}/${options.packageName}/manifests/${digest}`, registryBaseUrl);
6
+ let response;
7
+ try {
8
+ response = await withFetchRetry(async () => {
9
+ const manifestResponse = await fetchImpl(url.toString(), {
10
+ headers: {
11
+ Accept: acceptedManifestMediaTypes,
12
+ Authorization: `Bearer ${registryToken}`,
13
+ "User-Agent": "ghcr-cleanup-manager"
14
+ }
15
+ });
16
+ if (!manifestResponse.ok && _shouldRetryStatus(manifestResponse.status)) {
17
+ throw new Error(await buildHttpErrorMessage(manifestResponse, `GHCR manifest request for ${digest} failed`));
18
+ }
19
+ return manifestResponse;
20
+ }, {
21
+ logger: options.logger,
22
+ label: `GHCR manifest request for ${digest}`,
23
+ shouldRetry: (error) => _shouldRetryError(error)
24
+ });
25
+ }
26
+ catch (error) {
27
+ throw new Error(buildFetchTransportErrorMessage(error, `GHCR manifest request for ${digest} failed`), {
28
+ cause: error
29
+ });
30
+ }
31
+ if (!response.ok) {
32
+ throw new Error(await buildHttpErrorMessage(response, `GHCR manifest request for ${digest} failed`));
33
+ }
34
+ const mediaTypeHeader = response.headers.get("content-type")?.split(";")[0];
35
+ const document = (await response.json());
36
+ const rawJson = JSON.stringify(document);
37
+ const mediaType = document.mediaType ?? mediaTypeHeader;
38
+ if (!mediaType) {
39
+ throw new Error(`manifest response for ${digest} did not include a media type`);
40
+ }
41
+ options.logger.debug(`Loaded GHCR manifest ${digest} in ${Date.now() - startTime}ms (${mediaType})`);
42
+ return {
43
+ rawJson,
44
+ record: {
45
+ digest,
46
+ manifestKind: classifyManifestKind(document),
47
+ mediaType,
48
+ artifactType: document.artifactType,
49
+ configMediaType: document.config?.mediaType,
50
+ subjectDigest: document.subject?.digest,
51
+ annotations: document.annotations
52
+ },
53
+ ...buildManifestRelations(digest, rawJson)
54
+ };
55
+ }
56
+ export function buildManifestRelations(digest, rawJson) {
57
+ const document = JSON.parse(rawJson);
58
+ return {
59
+ descriptorRecords: _buildDescriptorRecords(digest, document),
60
+ edgeRecords: _buildEdges(digest, document)
61
+ };
62
+ }
63
+ function _buildDescriptorRecords(parentDigest, document) {
64
+ const records = [];
65
+ for (const child of document.manifests ?? []) {
66
+ if (!child.digest || !child.mediaType) {
67
+ continue;
68
+ }
69
+ records.push({
70
+ parentDigest,
71
+ childDigest: child.digest,
72
+ mediaType: child.mediaType,
73
+ artifactType: child.artifactType,
74
+ platform: child.platform
75
+ });
76
+ }
77
+ return records;
78
+ }
79
+ function _buildEdges(parentDigest, document) {
80
+ const edges = [];
81
+ for (const child of document.manifests ?? []) {
82
+ if (!child.digest || !child.mediaType) {
83
+ continue;
84
+ }
85
+ edges.push({
86
+ parentDigest,
87
+ childDigest: child.digest,
88
+ edgeKind: "image-child"
89
+ });
90
+ }
91
+ if (document.subject?.digest) {
92
+ edges.push({
93
+ parentDigest: document.subject.digest,
94
+ childDigest: parentDigest,
95
+ edgeKind: "referrer"
96
+ });
97
+ }
98
+ return edges;
99
+ }
100
+ function _shouldRetryStatus(status) {
101
+ return status === 429 || status === 502 || status === 503 || status === 504;
102
+ }
103
+ function _shouldRetryError(error) {
104
+ if (!(error instanceof Error)) {
105
+ return false;
106
+ }
107
+ return /fetch failed|status 429|status 502|status 503|status 504/.test(error.message);
108
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScanWriter, SnapshotRepository } from "../../db/index.js";
2
+ import { type FetchLike, type GitHubScanOptions } from "./_shared.js";
3
+ export declare function ingestManifests(fetchImpl: FetchLike, registryBaseUrl: string, options: GitHubScanOptions, writer: ScanWriter, repository: SnapshotRepository, scanId: number): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { manifestFetchConcurrency, manifestIngestProgressStepRatio } from "../../config/index.js";
2
+ import { buildManifestRelations, loadManifestGraph } from "./_manifest-client.js";
3
+ import { loadRegistryPullToken } from "./_registry-token-client.js";
4
+ export async function ingestManifests(fetchImpl, registryBaseUrl, options, writer, repository, scanId) {
5
+ const manifests = repository.listPackageVersionManifestRefs(scanId);
6
+ const totalDigestCount = manifests.length;
7
+ const progressStep = Math.max(1, Math.ceil(totalDigestCount * manifestIngestProgressStepRatio));
8
+ const registryPullTokenState = {};
9
+ options.logger.info(`Fetching manifests for ${totalDigestCount} package versions`);
10
+ let completed = 0;
11
+ let nextManifestIndex = 0;
12
+ const activeLoads = new Set();
13
+ while (nextManifestIndex < manifests.length || activeLoads.size > 0) {
14
+ while (nextManifestIndex < manifests.length && activeLoads.size < manifestFetchConcurrency) {
15
+ const load = _fetchManifest(manifests[nextManifestIndex], fetchImpl, registryBaseUrl, options, writer, completed, async () => (await _getRegistryPullToken(fetchImpl, registryBaseUrl, options, registryPullTokenState)).token, () => {
16
+ completed += 1;
17
+ if (completed % progressStep === 0 || completed === totalDigestCount) {
18
+ options.logger.info(`Fetched manifests ${completed}/${totalDigestCount}`);
19
+ }
20
+ }).finally(() => {
21
+ activeLoads.delete(load);
22
+ });
23
+ nextManifestIndex += 1;
24
+ activeLoads.add(load);
25
+ }
26
+ if (activeLoads.size > 0) {
27
+ await Promise.race(activeLoads);
28
+ }
29
+ }
30
+ _processManifestPayloads(options, writer, repository, scanId);
31
+ }
32
+ async function _fetchManifest(manifestRef, fetchImpl, registryBaseUrl, options, writer, completed, getRegistryToken, onComplete) {
33
+ const { versionId, digest } = manifestRef;
34
+ options.logger.debug(`Fetching manifest ${completed + 1}: ${digest}`);
35
+ let manifest;
36
+ try {
37
+ manifest = await loadManifestGraph(fetchImpl, registryBaseUrl, digest, await getRegistryToken(), options);
38
+ }
39
+ catch (error) {
40
+ if (_isMissingManifestError(error)) {
41
+ options.logger.warn(`Skipping missing GHCR manifest ${digest}`);
42
+ onComplete();
43
+ return;
44
+ }
45
+ throw error;
46
+ }
47
+ writer.insertManifest({ versionId, ...manifest.record });
48
+ writer.insertManifestPayload(manifest.record.digest, manifest.rawJson);
49
+ onComplete();
50
+ }
51
+ function _processManifestPayloads(options, writer, repository, scanId) {
52
+ const digests = new Set(repository.listManifestDigests(scanId));
53
+ const payloads = repository.listManifestPayloads(scanId);
54
+ options.logger.info(`Starting manifest graph processing for ${payloads.length} manifest payloads`);
55
+ let persistedEdgeCount = 0;
56
+ for (const payload of payloads) {
57
+ const relations = buildManifestRelations(payload.digest, payload.rawJson);
58
+ for (const descriptor of relations.descriptorRecords) {
59
+ writer.insertManifestDescriptor(descriptor);
60
+ }
61
+ for (const edge of relations.edgeRecords) {
62
+ if (digests.has(edge.parentDigest) && digests.has(edge.childDigest)) {
63
+ writer.insertManifestEdge(edge);
64
+ persistedEdgeCount += 1;
65
+ }
66
+ }
67
+ }
68
+ options.logger.info(`Inserted ${persistedEdgeCount} manifest edges; rebuilding reachability`);
69
+ writer.rebuildManifestReachability();
70
+ options.logger.info("Completed manifest graph processing");
71
+ }
72
+ function _isMissingManifestError(error) {
73
+ if (!(error instanceof Error)) {
74
+ return false;
75
+ }
76
+ return /GHCR manifest request .* failed - status 404/.test(error.message);
77
+ }
78
+ async function _getRegistryPullToken(fetchImpl, registryBaseUrl, options, registryPullTokenState) {
79
+ if (registryPullTokenState.token && Date.now() < registryPullTokenState.token.expiresAt - 5000) {
80
+ return registryPullTokenState.token;
81
+ }
82
+ if (!registryPullTokenState.load) {
83
+ registryPullTokenState.load = loadRegistryPullToken(fetchImpl, registryBaseUrl, options).finally(() => {
84
+ registryPullTokenState.load = undefined;
85
+ });
86
+ }
87
+ const registryPullToken = await registryPullTokenState.load;
88
+ registryPullTokenState.token = registryPullToken;
89
+ return registryPullToken;
90
+ }
@@ -0,0 +1,26 @@
1
+ import { type ManifestKind } from "../../core/index.js";
2
+ interface _RegistryLayer {
3
+ mediaType?: string;
4
+ annotations?: Record<string, unknown>;
5
+ }
6
+ interface _RegistryManifestDocument {
7
+ mediaType?: string;
8
+ artifactType?: string;
9
+ annotations?: Record<string, unknown>;
10
+ manifests?: Array<{
11
+ platform?: {
12
+ architecture?: string;
13
+ os?: string;
14
+ };
15
+ }>;
16
+ config?: {
17
+ mediaType?: string;
18
+ artifactType?: string;
19
+ };
20
+ layers?: _RegistryLayer[];
21
+ subject?: {
22
+ digest?: string;
23
+ };
24
+ }
25
+ export declare function classifyManifestKind(document: _RegistryManifestDocument): ManifestKind | undefined;
26
+ export {};
@@ -0,0 +1,65 @@
1
+ import { ManifestKinds } from "../../core/index.js";
2
+ export function classifyManifestKind(document) {
3
+ if (document.mediaType === "application/vnd.oci.image.index.v1+json" ||
4
+ document.mediaType === "application/vnd.docker.distribution.manifest.list.v2+json") {
5
+ return _isMultiArchManifest(document) ? ManifestKinds.multiArchManifest : ManifestKinds.indexManifest;
6
+ }
7
+ if (_isSignatureManifest(document)) {
8
+ return ManifestKinds.signatureManifest;
9
+ }
10
+ if (_isAttestationManifest(document)) {
11
+ return ManifestKinds.attestationManifest;
12
+ }
13
+ if (document.mediaType === "application/vnd.oci.image.manifest.v1+json" ||
14
+ document.mediaType === "application/vnd.docker.distribution.manifest.v2+json") {
15
+ return ManifestKinds.imageManifest;
16
+ }
17
+ if (document.mediaType === "application/vnd.oci.artifact.manifest.v1+json") {
18
+ return ManifestKinds.artifactManifest;
19
+ }
20
+ return undefined;
21
+ }
22
+ function _isMultiArchManifest(document) {
23
+ const realPlatformDescriptorCount = document.manifests?.filter((descriptor) => {
24
+ const architecture = descriptor.platform?.architecture;
25
+ const os = descriptor.platform?.os;
26
+ return (typeof architecture === "string" &&
27
+ typeof os === "string" &&
28
+ architecture.length > 0 &&
29
+ os.length > 0 &&
30
+ architecture !== "unknown" &&
31
+ os !== "unknown");
32
+ }).length ?? 0;
33
+ return realPlatformDescriptorCount > 1;
34
+ }
35
+ function _isSignatureManifest(document) {
36
+ const candidates = [
37
+ document.artifactType,
38
+ document.config?.artifactType,
39
+ document.config?.mediaType,
40
+ ...(document.layers?.map((layer) => layer.mediaType) ?? [])
41
+ ];
42
+ if (candidates.some((value) => typeof value === "string" && value.includes("application/vnd.dev.sigstore"))) {
43
+ return true;
44
+ }
45
+ return (typeof document.annotations?.["dev.sigstore.bundle.predicateType"] === "string" &&
46
+ document.annotations["dev.sigstore.bundle.predicateType"] === "https://sigstore.dev/cosign/sign/v1");
47
+ }
48
+ function _isAttestationManifest(document) {
49
+ const candidates = [
50
+ document.artifactType,
51
+ document.config?.artifactType,
52
+ document.config?.mediaType,
53
+ ...(document.layers?.map((layer) => layer.mediaType) ?? [])
54
+ ];
55
+ if (candidates.some((value) => typeof value === "string" && value.includes("application/vnd.in-toto"))) {
56
+ return true;
57
+ }
58
+ if (typeof document.annotations?.["vnd.docker.reference.type"] === "string") {
59
+ return document.annotations["vnd.docker.reference.type"] === "attestation-manifest";
60
+ }
61
+ if (typeof document.annotations?.["dev.sigstore.bundle.predicateType"] === "string") {
62
+ return document.annotations["dev.sigstore.bundle.predicateType"] !== "https://sigstore.dev/cosign/sign/v1";
63
+ }
64
+ return (document.layers?.some((layer) => typeof layer.annotations?.["in-toto.io/predicate-type"] === "string") ?? false);
65
+ }
@@ -0,0 +1,5 @@
1
+ import { type FetchLike, type GitHubScanOptions } from "./_shared.js";
2
+ export interface GitHubPackageMetadata {
3
+ rawJson: string;
4
+ }
5
+ export declare function loadPackageMetadata(fetchImpl: FetchLike, options: GitHubScanOptions): Promise<GitHubPackageMetadata>;
@@ -0,0 +1,45 @@
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 loadPackageMetadata(fetchImpl, options) {
5
+ const ownerURIComponent = await getOwnerURIComponent(fetchImpl, options.owner, options.token, options.logger);
6
+ const url = new URL(`/${ownerURIComponent}/packages/container/${encodeURIComponent(options.packageName)}`, githubApiBaseUrl).toString();
7
+ let response;
8
+ try {
9
+ response = await withFetchRetry(async () => {
10
+ const packageResponse = 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 (!packageResponse.ok && _shouldRetryStatus(packageResponse.status)) {
19
+ throw new Error(await buildHttpErrorMessage(packageResponse, "GitHub package metadata request failed"));
20
+ }
21
+ return packageResponse;
22
+ }, {
23
+ logger: options.logger,
24
+ label: "GitHub package metadata request",
25
+ shouldRetry: (error) => _shouldRetryError(error)
26
+ });
27
+ }
28
+ catch (error) {
29
+ throw new Error(buildFetchTransportErrorMessage(error, "GitHub package metadata request failed"), { cause: error });
30
+ }
31
+ if (!response.ok) {
32
+ throw new Error(await buildHttpErrorMessage(response, "GitHub package metadata request failed"));
33
+ }
34
+ const payload = (await response.json());
35
+ return { rawJson: JSON.stringify(payload) };
36
+ }
37
+ function _shouldRetryStatus(status) {
38
+ return status === 429 || status === 502 || status === 503 || status === 504;
39
+ }
40
+ function _shouldRetryError(error) {
41
+ if (!(error instanceof Error)) {
42
+ return false;
43
+ }
44
+ return /fetch failed|status 429|status 502|status 503|status 504/.test(error.message);
45
+ }