ghcr-manager 0.0.4 → 0.9.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 (137) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +166 -57
  3. package/dist/cleanup-summary/_cleanup-summary-markdown.d.ts +6 -0
  4. package/dist/cleanup-summary/_cleanup-summary-markdown.js +113 -0
  5. package/dist/cleanup-summary/_cleanup-summary.d.ts +40 -0
  6. package/dist/cleanup-summary/_cleanup-summary.js +40 -0
  7. package/dist/cleanup-summary/index.d.ts +2 -0
  8. package/dist/cleanup-summary/index.js +2 -0
  9. package/dist/cli/_args.d.ts +1 -0
  10. package/dist/cli/_args.js +3 -0
  11. package/dist/cli/_cleanup-command.d.ts +1 -0
  12. package/dist/cli/_cleanup-command.js +63 -0
  13. package/dist/cli/_db-merge-command.d.ts +1 -0
  14. package/dist/cli/_db-merge-command.js +41 -0
  15. package/dist/cli/_github-output.d.ts +10 -0
  16. package/dist/cli/_github-output.js +13 -0
  17. package/dist/cli/_logger.d.ts +2 -1
  18. package/dist/cli/_logger.js +2 -0
  19. package/dist/cli/_older-than.d.ts +5 -0
  20. package/dist/cli/_older-than.js +42 -0
  21. package/dist/cli/_planner-options.d.ts +20 -0
  22. package/dist/cli/_planner-options.js +101 -0
  23. package/dist/cli/_scan-command.js +11 -4
  24. package/dist/cli/_tag-selector-resolver.d.ts +3 -0
  25. package/dist/cli/_tag-selector-resolver.js +109 -0
  26. package/dist/cli/_untag-command.d.ts +1 -0
  27. package/dist/cli/_untag-command.js +57 -0
  28. package/dist/cli/index.js +19 -1
  29. package/dist/config/_service-constants.d.ts +3 -0
  30. package/dist/config/_service-constants.js +3 -0
  31. package/dist/{tuning → config}/index.d.ts +3 -0
  32. package/dist/{tuning → config}/index.js +3 -0
  33. package/dist/core/_github-package-owner.d.ts +11 -0
  34. package/dist/core/_github-package-owner.js +45 -0
  35. package/dist/core/_http-error.d.ts +6 -0
  36. package/dist/core/_http-error.js +33 -0
  37. package/dist/core/_types.d.ts +3 -2
  38. package/dist/core/index.d.ts +4 -1
  39. package/dist/core/index.js +2 -1
  40. package/dist/db/_cleanup-run-writer.d.ts +10 -0
  41. package/dist/db/_cleanup-run-writer.js +73 -0
  42. package/dist/db/_db-merge-cleanup-copy.d.ts +7 -0
  43. package/dist/db/_db-merge-cleanup-copy.js +122 -0
  44. package/dist/db/_db-merge-history.d.ts +2 -0
  45. package/dist/db/_db-merge-history.js +15 -0
  46. package/dist/db/_db-merge-repository.d.ts +8 -0
  47. package/dist/db/_db-merge-repository.js +95 -0
  48. package/dist/db/_db-merge-scan-copy.d.ts +10 -0
  49. package/dist/db/_db-merge-scan-copy.js +69 -0
  50. package/dist/db/_db-merge-types.d.ts +44 -0
  51. package/dist/db/_db-merge-types.js +1 -0
  52. package/dist/db/_github-actions-run-url.d.ts +1 -0
  53. package/dist/db/_github-actions-run-url.js +9 -0
  54. package/dist/db/_scan-writer.d.ts +3 -1
  55. package/dist/db/_scan-writer.js +28 -13
  56. package/dist/db/_snapshot-repository.d.ts +9 -9
  57. package/dist/db/_snapshot-repository.js +37 -49
  58. package/dist/db/_sql-placeholders.d.ts +2 -0
  59. package/dist/db/_sql-placeholders.js +16 -0
  60. package/dist/db/index.d.ts +5 -0
  61. package/dist/db/index.js +3 -0
  62. package/dist/db/planner/_planner-delete-tag-root-targets.d.ts +7 -0
  63. package/dist/db/planner/_planner-delete-tag-root-targets.js +130 -0
  64. package/dist/db/planner/_planner-direct-target-tags.d.ts +6 -0
  65. package/dist/db/planner/_planner-direct-target-tags.js +47 -0
  66. package/dist/db/planner/_planner-keep-tagged-root-targets.d.ts +7 -0
  67. package/dist/db/planner/_planner-keep-tagged-root-targets.js +74 -0
  68. package/dist/db/planner/_planner-output.d.ts +5 -0
  69. package/dist/db/planner/_planner-output.js +101 -0
  70. package/dist/db/planner/_planner-plan-artifacts.d.ts +7 -0
  71. package/dist/db/planner/_planner-plan-artifacts.js +211 -0
  72. package/dist/db/planner/_planner-repository.d.ts +34 -0
  73. package/dist/db/planner/_planner-repository.js +126 -0
  74. package/dist/db/planner/_planner-sql.d.ts +12 -0
  75. package/dist/db/planner/_planner-sql.js +35 -0
  76. package/dist/db/planner/_planner-tag-selectors.d.ts +8 -0
  77. package/dist/db/planner/_planner-tag-selectors.js +57 -0
  78. package/dist/db/planner/_planner-tagged-root-targets.d.ts +15 -0
  79. package/dist/db/planner/_planner-tagged-root-targets.js +19 -0
  80. package/dist/db/planner/_planner-tagged-targets.d.ts +8 -0
  81. package/dist/db/planner/_planner-tagged-targets.js +16 -0
  82. package/dist/db/planner/_planner-types.d.ts +135 -0
  83. package/dist/db/planner/_planner-types.js +38 -0
  84. package/dist/db/planner/_planner-untagged-targets.d.ts +9 -0
  85. package/dist/db/planner/_planner-untagged-targets.js +91 -0
  86. package/dist/db/planner/index.d.ts +2 -0
  87. package/dist/db/planner/index.js +1 -0
  88. package/dist/execute/_http.d.ts +7 -0
  89. package/dist/execute/_http.js +48 -0
  90. package/dist/execute/_manifest-detach.d.ts +4 -0
  91. package/dist/execute/_manifest-detach.js +31 -0
  92. package/dist/execute/_package-version-delete-client.d.ts +4 -0
  93. package/dist/execute/_package-version-delete-client.js +34 -0
  94. package/dist/execute/_package-version-page-client.d.ts +14 -0
  95. package/dist/execute/_package-version-page-client.js +64 -0
  96. package/dist/execute/_package-version-tag-source-client.d.ts +12 -0
  97. package/dist/execute/_package-version-tag-source-client.js +65 -0
  98. package/dist/execute/_plan-executor.d.ts +3 -0
  99. package/dist/execute/_plan-executor.js +47 -0
  100. package/dist/execute/_registry-manifest-client.d.ts +12 -0
  101. package/dist/execute/_registry-manifest-client.js +79 -0
  102. package/dist/execute/_registry-token-client.d.ts +4 -0
  103. package/dist/execute/_registry-token-client.js +37 -0
  104. package/dist/execute/_types.d.ts +51 -0
  105. package/dist/execute/_types.js +1 -0
  106. package/dist/execute/_untag-client.d.ts +2 -0
  107. package/dist/execute/_untag-client.js +71 -0
  108. package/dist/execute/index.d.ts +5 -0
  109. package/dist/execute/index.js +3 -0
  110. package/dist/ingest/github/_manifest-client.d.ts +7 -1
  111. package/dist/ingest/github/_manifest-client.js +8 -0
  112. package/dist/ingest/github/_manifest-ingest.js +39 -53
  113. package/dist/ingest/github/_manifest-kind.d.ts +20 -0
  114. package/dist/ingest/github/_manifest-kind.js +50 -0
  115. package/dist/ingest/github/_package-metadata-load.d.ts +5 -0
  116. package/dist/ingest/github/_package-metadata-load.js +45 -0
  117. package/dist/ingest/github/_package-version-page-load.d.ts +1 -1
  118. package/dist/ingest/github/_package-version-page-load.js +8 -5
  119. package/dist/ingest/github/_packages-client.d.ts +1 -1
  120. package/dist/ingest/github/_packages-client.js +21 -4
  121. package/dist/ingest/github/_parallel-paginated-ingest.d.ts +1 -0
  122. package/dist/ingest/github/_parallel-paginated-ingest.js +2 -2
  123. package/dist/ingest/github/_shared.d.ts +1 -1
  124. package/dist/ingest/github/_shared.js +2 -34
  125. package/dist/ingest/github/index.d.ts +4 -0
  126. package/dist/ingest/github/index.js +8 -5
  127. package/package.json +7 -5
  128. package/resources/sql/schema/001_schema.sql +82 -8
  129. package/resources/sql/views/001_v_latest_scan_per_package.sql +2 -2
  130. package/resources/sql/views/003_v_scan_root_manifests.sql +43 -0
  131. package/resources/sql/views/004_v_digest_derived_tag_relations.sql +51 -0
  132. package/resources/sql/views/005_v_cleanup_root_closure_members.sql +100 -0
  133. package/resources/sql/views/006_v_cleanup_blocking_overlaps.sql +42 -0
  134. package/dist/ingest/github/_paginated-ingest.d.ts +0 -11
  135. package/dist/ingest/github/_paginated-ingest.js +0 -28
  136. package/resources/sql/views/003_v_missing_digests_related_manifests.sql +0 -78
  137. package/resources/sql/views/004_v_manifests_related_manifests.sql +0 -142
@@ -0,0 +1,41 @@
1
+ import { collectRepeatedOption, requireOption } from "./_args.js";
2
+ import { DbMergeRepository, openDatabase } from "../db/index.js";
3
+ export async function handleDbMerge(args) {
4
+ const databasePath = requireOption(args, "--db");
5
+ const sourceDatabasePaths = collectRepeatedOption(args, "--source-db");
6
+ if (sourceDatabasePaths.length === 0) {
7
+ throw new Error("missing required option: --source-db");
8
+ }
9
+ const targetDatabase = openDatabase(databasePath);
10
+ try {
11
+ const merger = new DbMergeRepository(targetDatabase);
12
+ const summaries = [];
13
+ let importedScanCount = 0;
14
+ let skippedScanCount = 0;
15
+ let importedCleanupRunCount = 0;
16
+ let skippedCleanupRunCount = 0;
17
+ for (const sourceDatabasePath of sourceDatabasePaths) {
18
+ const sourceDatabase = openDatabase(sourceDatabasePath);
19
+ sourceDatabase.close();
20
+ const summary = merger.mergeSourceDatabase(sourceDatabasePath);
21
+ summaries.push(summary);
22
+ importedScanCount += summary.importedScanCount;
23
+ skippedScanCount += summary.skippedScanCount;
24
+ importedCleanupRunCount += summary.importedCleanupRunCount;
25
+ skippedCleanupRunCount += summary.skippedCleanupRunCount;
26
+ }
27
+ console.log(JSON.stringify({
28
+ targetDatabasePath: databasePath,
29
+ sourceDatabaseCount: sourceDatabasePaths.length,
30
+ importedScanCount,
31
+ skippedScanCount,
32
+ importedCleanupRunCount,
33
+ skippedCleanupRunCount,
34
+ sources: summaries
35
+ }, null, 2));
36
+ return 0;
37
+ }
38
+ finally {
39
+ targetDatabase.close();
40
+ }
41
+ }
@@ -0,0 +1,10 @@
1
+ export interface GitHubScanOutputs {
2
+ owner: string;
3
+ packageName: string;
4
+ scanCompletedAt: string;
5
+ packageVersions: number;
6
+ tags: number;
7
+ manifests: number;
8
+ manifestEdges: number;
9
+ }
10
+ export declare function writeGitHubScanOutputs(outputPath: string, outputs: GitHubScanOutputs): void;
@@ -0,0 +1,13 @@
1
+ import { appendFileSync } from "node:fs";
2
+ export function writeGitHubScanOutputs(outputPath, outputs) {
3
+ const lines = [
4
+ `owner=${outputs.owner}`,
5
+ `package_name=${outputs.packageName}`,
6
+ `scan_completed_at=${outputs.scanCompletedAt}`,
7
+ `package_versions=${outputs.packageVersions}`,
8
+ `tags=${outputs.tags}`,
9
+ `manifests=${outputs.manifests}`,
10
+ `manifest_edges=${outputs.manifestEdges}`
11
+ ];
12
+ appendFileSync(outputPath, `${lines.join("\n")}\n`);
13
+ }
@@ -1,5 +1,6 @@
1
- export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
1
+ export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "silent";
2
2
  export interface Logger {
3
+ trace(message: string): void;
3
4
  debug(message: string): void;
4
5
  info(message: string): void;
5
6
  warn(message: string): void;
@@ -1,4 +1,5 @@
1
1
  const _logLevelPriority = {
2
+ trace: 0,
2
3
  debug: 10,
3
4
  info: 20,
4
5
  warn: 30,
@@ -10,6 +11,7 @@ export function isLogLevel(value) {
10
11
  }
11
12
  export function createLogger(level, sink = process.stderr) {
12
13
  return {
14
+ trace: _write.bind(null, "trace", level, sink),
13
15
  debug: _write.bind(null, "debug", level, sink),
14
16
  info: _write.bind(null, "info", level, sink),
15
17
  warn: _write.bind(null, "warn", level, sink),
@@ -0,0 +1,5 @@
1
+ export interface OlderThanResolution {
2
+ olderThan: string;
3
+ cutoffTimestamp: string;
4
+ }
5
+ export declare function resolveOlderThan(rawValue: string, now: Date): OlderThanResolution;
@@ -0,0 +1,42 @@
1
+ export function resolveOlderThan(rawValue, now) {
2
+ const normalized = rawValue.trim().toLowerCase();
3
+ const match = normalized.match(/^(\d+)\s+(minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)$/);
4
+ if (!match) {
5
+ throw new Error(`invalid older-than interval: ${rawValue}`);
6
+ }
7
+ const amount = Number(match[1]);
8
+ const unit = match[2];
9
+ const cutoff = new Date(now.getTime());
10
+ switch (unit) {
11
+ case "minute":
12
+ case "minutes":
13
+ cutoff.setUTCMinutes(cutoff.getUTCMinutes() - amount);
14
+ break;
15
+ case "hour":
16
+ case "hours":
17
+ cutoff.setUTCHours(cutoff.getUTCHours() - amount);
18
+ break;
19
+ case "day":
20
+ case "days":
21
+ cutoff.setUTCDate(cutoff.getUTCDate() - amount);
22
+ break;
23
+ case "week":
24
+ case "weeks":
25
+ cutoff.setUTCDate(cutoff.getUTCDate() - amount * 7);
26
+ break;
27
+ case "month":
28
+ case "months":
29
+ cutoff.setUTCMonth(cutoff.getUTCMonth() - amount);
30
+ break;
31
+ case "year":
32
+ case "years":
33
+ cutoff.setUTCFullYear(cutoff.getUTCFullYear() - amount);
34
+ break;
35
+ default:
36
+ throw new Error(`invalid older-than unit: ${unit}`);
37
+ }
38
+ return {
39
+ olderThan: normalized,
40
+ cutoffTimestamp: cutoff.toISOString()
41
+ };
42
+ }
@@ -0,0 +1,20 @@
1
+ import type { DeletePlan, PlannerRepository } from "../db/index.js";
2
+ export interface PlanCommandInputs {
3
+ databasePath: string;
4
+ owner: string;
5
+ packageName: string;
6
+ deleteTags: string[];
7
+ deleteTagsRequested: boolean;
8
+ deleteGhostImages: boolean;
9
+ deletePartialImages: boolean;
10
+ deleteOrphanedImages: boolean;
11
+ excludeTags: string[];
12
+ deleteUntagged: boolean;
13
+ useRegex: boolean;
14
+ keepNTagged?: number;
15
+ keepNUntagged?: number;
16
+ olderThan?: string;
17
+ cutoffTimestamp?: string;
18
+ }
19
+ export declare function resolvePlanCommandInputs(args: string[]): PlanCommandInputs;
20
+ export declare function loadDeletePlan(repository: PlannerRepository, inputs: PlanCommandInputs): DeletePlan;
@@ -0,0 +1,101 @@
1
+ import { collectRepeatedOption, hasFlag, requireOption } from "./_args.js";
2
+ import { resolveOlderThan } from "./_older-than.js";
3
+ export function resolvePlanCommandInputs(args) {
4
+ const databasePath = requireOption(args, "--db");
5
+ const owner = requireOption(args, "--owner");
6
+ const packageName = requireOption(args, "--package");
7
+ const deleteTags = collectRepeatedOption(args, "--delete-tag");
8
+ const deleteGhostImages = hasFlag(args, "--delete-ghost-images");
9
+ const deletePartialImages = hasFlag(args, "--delete-partial-images");
10
+ const deleteOrphanedImages = hasFlag(args, "--delete-orphaned-images");
11
+ const excludeTags = collectRepeatedOption(args, "--exclude-tag");
12
+ const deleteUntagged = hasFlag(args, "--delete-untagged");
13
+ const useRegex = hasFlag(args, "--use-regex");
14
+ const keepNTaggedRaw = collectRepeatedOption(args, "--keep-n-tagged");
15
+ const keepNUntaggedRaw = collectRepeatedOption(args, "--keep-n-untagged");
16
+ const olderThanRaw = collectRepeatedOption(args, "--older-than");
17
+ if (keepNTaggedRaw.length > 1) {
18
+ throw new Error("--keep-n-tagged may only be provided once");
19
+ }
20
+ if (keepNUntaggedRaw.length > 1) {
21
+ throw new Error("--keep-n-untagged may only be provided once");
22
+ }
23
+ const keepNTagged = keepNTaggedRaw[0] ? resolveKeepCount("--keep-n-tagged", keepNTaggedRaw[0]) : undefined;
24
+ const keepNUntagged = keepNUntaggedRaw[0] ? resolveKeepCount("--keep-n-untagged", keepNUntaggedRaw[0]) : undefined;
25
+ const taggedSelectorActive = deleteTags.length > 0 ||
26
+ deleteGhostImages ||
27
+ deletePartialImages ||
28
+ deleteOrphanedImages ||
29
+ keepNTagged !== undefined;
30
+ const selectorCount = (deleteUntagged ? 1 : 0) + (taggedSelectorActive ? 1 : 0) + (keepNUntagged !== undefined ? 1 : 0);
31
+ if (selectorCount > 1) {
32
+ throw new Error("plan currently supports exactly one selector family: --delete-untagged, --delete-tag, --delete-ghost-images, --delete-partial-images, --delete-orphaned-images, --keep-n-tagged, or --keep-n-untagged");
33
+ }
34
+ if (selectorCount === 0) {
35
+ throw new Error("missing required cleanup selector: --delete-untagged, --delete-tag, --delete-ghost-images, --delete-partial-images, --delete-orphaned-images, --keep-n-tagged, or --keep-n-untagged");
36
+ }
37
+ if ((deleteUntagged || keepNUntagged !== undefined) && excludeTags.length > 0) {
38
+ throw new Error("--exclude-tag is only supported with tagged selector families");
39
+ }
40
+ if (olderThanRaw.length > 1) {
41
+ throw new Error("--older-than may only be provided once");
42
+ }
43
+ const olderThan = olderThanRaw[0] ? resolveOlderThan(olderThanRaw[0], new Date()) : undefined;
44
+ return {
45
+ databasePath,
46
+ owner,
47
+ packageName,
48
+ deleteTags,
49
+ deleteTagsRequested: deleteTags.length > 0 || deleteGhostImages || deletePartialImages || deleteOrphanedImages,
50
+ deleteGhostImages,
51
+ deletePartialImages,
52
+ deleteOrphanedImages,
53
+ excludeTags,
54
+ deleteUntagged,
55
+ useRegex,
56
+ keepNTagged,
57
+ keepNUntagged,
58
+ olderThan: olderThan?.olderThan,
59
+ cutoffTimestamp: olderThan?.cutoffTimestamp
60
+ };
61
+ }
62
+ export function loadDeletePlan(repository, inputs) {
63
+ if (inputs.keepNUntagged !== undefined) {
64
+ return repository.getKeepNUntaggedPlanWithCutoff(inputs.owner, inputs.packageName, inputs.keepNUntagged, {
65
+ olderThan: inputs.olderThan,
66
+ cutoffTimestamp: inputs.cutoffTimestamp
67
+ });
68
+ }
69
+ if (inputs.deleteUntagged) {
70
+ return repository.getDeleteUntaggedPlanWithCutoff(inputs.owner, inputs.packageName, {
71
+ olderThan: inputs.olderThan,
72
+ cutoffTimestamp: inputs.cutoffTimestamp
73
+ });
74
+ }
75
+ if (!inputs.deleteTagsRequested &&
76
+ !inputs.deleteGhostImages &&
77
+ !inputs.deletePartialImages &&
78
+ !inputs.deleteOrphanedImages &&
79
+ inputs.keepNTagged !== undefined) {
80
+ return repository.getKeepNTaggedPlanWithCutoff(inputs.owner, inputs.packageName, inputs.keepNTagged, [], {
81
+ olderThan: inputs.olderThan,
82
+ cutoffTimestamp: inputs.cutoffTimestamp
83
+ });
84
+ }
85
+ return repository.getDeleteTagsPlanWithCutoff(inputs.owner, inputs.packageName, inputs.deleteTags, inputs.excludeTags, {
86
+ deleteGhostImages: inputs.deleteGhostImages,
87
+ deletePartialImages: inputs.deletePartialImages,
88
+ deleteOrphanedImages: inputs.deleteOrphanedImages,
89
+ deleteTagsRequested: inputs.deleteTagsRequested,
90
+ keepNTagged: inputs.keepNTagged,
91
+ useRegex: inputs.useRegex,
92
+ olderThan: inputs.olderThan,
93
+ cutoffTimestamp: inputs.cutoffTimestamp
94
+ });
95
+ }
96
+ function resolveKeepCount(optionName, rawValue) {
97
+ if (!/^\d+$/.test(rawValue)) {
98
+ throw new Error(`${optionName} must be a non-negative integer`);
99
+ }
100
+ return Number.parseInt(rawValue, 10);
101
+ }
@@ -1,11 +1,14 @@
1
1
  import { ScanWriter, SnapshotRepository, openDatabase } from "../db/index.js";
2
2
  import { importGitHubScan } from "../ingest/github/index.js";
3
- import { requireOption, resolveGitHubToken, resolveLogLevel } from "./_args.js";
3
+ import { findOption, requireOption, resolveGitHubToken, resolveLogLevel } from "./_args.js";
4
+ import { writeGitHubScanOutputs } from "./_github-output.js";
4
5
  import { createLogger } from "./_logger.js";
5
6
  export async function handleScan(args) {
6
7
  const databasePath = requireOption(args, "--db");
7
8
  const owner = requireOption(args, "--owner");
8
9
  const packageName = requireOption(args, "--package");
10
+ const githubOutputPath = findOption(args, "--github-output");
11
+ const token = resolveGitHubToken(args);
9
12
  const logger = createLogger(resolveLogLevel(args));
10
13
  const database = openDatabase(databasePath);
11
14
  const repository = new SnapshotRepository(database);
@@ -13,12 +16,12 @@ export async function handleScan(args) {
13
16
  await importGitHubScan({
14
17
  owner,
15
18
  packageName,
16
- token: resolveGitHubToken(args),
19
+ token,
17
20
  logger
18
21
  }, writer, repository);
19
22
  const scanId = writer.getActiveScanId();
20
23
  const metadata = repository.getPackageMetadata(scanId);
21
- console.log(JSON.stringify({
24
+ const summary = {
22
25
  owner: metadata.owner,
23
26
  packageName: metadata.packageName,
24
27
  scanCompletedAt: metadata.scanCompletedAt,
@@ -26,7 +29,11 @@ export async function handleScan(args) {
26
29
  tags: repository.countTags(scanId),
27
30
  manifests: repository.countManifests(scanId),
28
31
  manifestEdges: repository.countManifestEdges(scanId)
29
- }, null, 2));
32
+ };
33
+ if (githubOutputPath) {
34
+ writeGitHubScanOutputs(githubOutputPath, summary);
35
+ }
36
+ console.log(JSON.stringify(summary));
30
37
  database.close();
31
38
  return 0;
32
39
  }
@@ -0,0 +1,3 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { PlanCommandInputs } from "./_planner-options.js";
3
+ export declare function resolveTagSelectors(database: Database.Database, inputs: PlanCommandInputs): PlanCommandInputs;
@@ -0,0 +1,109 @@
1
+ export function resolveTagSelectors(database, inputs) {
2
+ if (!inputs.deleteGhostImages && !inputs.deletePartialImages && !inputs.deleteOrphanedImages) {
3
+ return inputs;
4
+ }
5
+ return {
6
+ ...inputs,
7
+ deleteTags: inputs.deleteGhostImages
8
+ ? _listLatestGhostTags(database, inputs.owner, inputs.packageName, inputs.cutoffTimestamp)
9
+ : inputs.deletePartialImages
10
+ ? _listLatestPartialTags(database, inputs.owner, inputs.packageName, inputs.cutoffTimestamp)
11
+ : inputs.deleteOrphanedImages
12
+ ? _listLatestOrphanedTags(database, inputs.owner, inputs.packageName, inputs.cutoffTimestamp)
13
+ : inputs.deleteTags
14
+ };
15
+ }
16
+ function _listLatestGhostTags(database, owner, packageName, cutoffTimestamp) {
17
+ return _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, "all-missing");
18
+ }
19
+ function _listLatestPartialTags(database, owner, packageName, cutoffTimestamp) {
20
+ return _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, "some-missing");
21
+ }
22
+ function _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, mode) {
23
+ const havingClause = mode === "all-missing"
24
+ ? "COUNT(*) > 0 AND COUNT(child.digest) = 0"
25
+ : "COUNT(child.digest) > 0 AND COUNT(child.digest) < COUNT(*)";
26
+ const rows = database
27
+ .prepare(`
28
+ WITH latest_scan AS (
29
+ SELECT scan_id
30
+ FROM v_latest_scan_per_package
31
+ WHERE owner = ?
32
+ AND package_name = ?
33
+ LIMIT 1
34
+ ),
35
+ ghost_roots AS (
36
+ SELECT
37
+ m.scan_id,
38
+ m.version_id
39
+ FROM latest_scan ls
40
+ JOIN manifests m
41
+ ON m.scan_id = ls.scan_id
42
+ JOIN package_versions pv
43
+ ON pv.scan_id = m.scan_id
44
+ AND pv.version_id = m.version_id
45
+ JOIN tags root_tags
46
+ ON root_tags.scan_id = m.scan_id
47
+ AND root_tags.version_id = m.version_id
48
+ JOIN manifest_descriptors md
49
+ ON md.scan_id = m.scan_id
50
+ AND md.parent_digest = m.digest
51
+ LEFT JOIN manifests child
52
+ ON child.scan_id = md.scan_id
53
+ AND child.digest = md.child_digest
54
+ WHERE m.media_type IN (
55
+ 'application/vnd.oci.image.index.v1+json',
56
+ 'application/vnd.docker.distribution.manifest.list.v2+json'
57
+ )
58
+ AND NOT EXISTS (
59
+ SELECT 1
60
+ FROM manifest_reachability mr
61
+ WHERE mr.scan_id = m.scan_id
62
+ AND mr.descendant_digest = m.digest
63
+ AND mr.min_distance > 0
64
+ )
65
+ AND (? IS NULL OR pv.created_at < ?)
66
+ GROUP BY m.scan_id, m.version_id
67
+ HAVING ${havingClause}
68
+ )
69
+ SELECT DISTINCT t.tag
70
+ FROM ghost_roots gr
71
+ JOIN tags t
72
+ ON t.scan_id = gr.scan_id
73
+ AND t.version_id = gr.version_id
74
+ ORDER BY t.tag
75
+ `)
76
+ .all(owner, packageName, cutoffTimestamp ?? null, cutoffTimestamp ?? null);
77
+ return rows.map((row) => row.tag);
78
+ }
79
+ // Some OCI tooling publishes companion artifacts such as signatures or attestations under
80
+ // digest-derived tags in the same repository, for example `sha256-<digest>.sig`, while the
81
+ // actual relationship is the artifact's subject/referrer link to the parent digest.
82
+ //
83
+ // Public references:
84
+ // - Sigstore Cosign example pushing `sha256-<digest>.sig`:
85
+ // https://docs.sigstore.dev/cosign/signing/other_types/
86
+ // - OCI referrers / subject model:
87
+ // https://github.com/opencontainers/distribution-spec/blob/main/spec.md
88
+ //
89
+ // This resolver intentionally mirrors the `delete-orphaned-images` behavior from
90
+ // `dataaxiom/ghcr-cleanup-action`, but keeps the check narrow and local to the current package
91
+ // scan: derive the parent digest from the tag, then treat the tag as orphaned only when that
92
+ // digest is absent from the scanned manifests for the same package.
93
+ function _listLatestOrphanedTags(database, owner, packageName, cutoffTimestamp) {
94
+ const rows = database
95
+ .prepare(`
96
+ SELECT DISTINCT dtr.tag
97
+ FROM v_digest_derived_tag_relations dtr
98
+ INNER JOIN package_versions pv
99
+ ON pv.scan_id = dtr.scan_id
100
+ AND pv.version_id = dtr.artifact_version_id
101
+ WHERE dtr.owner = ?
102
+ AND dtr.package_name = ?
103
+ AND dtr.parent_exists = 0
104
+ AND (? IS NULL OR pv.created_at < ?)
105
+ ORDER BY dtr.tag
106
+ `)
107
+ .all(owner, packageName, cutoffTimestamp ?? null, cutoffTimestamp ?? null);
108
+ return rows.map((row) => row.tag);
109
+ }
@@ -0,0 +1 @@
1
+ export declare function handleUntag(args: string[]): Promise<number>;
@@ -0,0 +1,57 @@
1
+ import { listPackageVersionTagSources, untagRootTags } from "../execute/index.js";
2
+ import { collectRepeatedOption, hasFlag, requireOption, resolveGitHubToken, resolveLogLevel } from "./_args.js";
3
+ import { createLogger } from "./_logger.js";
4
+ export async function handleUntag(args) {
5
+ const owner = requireOption(args, "--owner");
6
+ const packageName = requireOption(args, "--package");
7
+ const requestedTags = [...new Set(collectRepeatedOption(args, "--tag"))];
8
+ if (requestedTags.length === 0) {
9
+ throw new Error("missing required option: --tag");
10
+ }
11
+ const token = resolveGitHubToken(args);
12
+ const dryRun = hasFlag(args, "--dry-run");
13
+ const logger = createLogger(resolveLogLevel(args));
14
+ const tagSources = await listPackageVersionTagSources(owner, packageName, requestedTags, token, logger);
15
+ const matchedTags = new Set(tagSources.map((tagSource) => tagSource.tag));
16
+ const missingTags = requestedTags.filter((tag) => !matchedTags.has(tag));
17
+ if (missingTags.length > 0) {
18
+ throw new Error(`could not resolve tag(s): ${missingTags.join(", ")}`);
19
+ }
20
+ const roots = _groupTagSources(tagSources);
21
+ const untaggedTags = [];
22
+ if (!dryRun) {
23
+ for (const root of roots) {
24
+ untaggedTags.push(...(await untagRootTags(owner, packageName, root.versionId, root.digest, root.tags, {
25
+ token,
26
+ logger
27
+ })));
28
+ }
29
+ }
30
+ const summary = {
31
+ owner,
32
+ packageName,
33
+ requestedTags,
34
+ dryRun,
35
+ roots,
36
+ untaggedTags
37
+ };
38
+ console.log(JSON.stringify(summary));
39
+ return 0;
40
+ }
41
+ function _groupTagSources(tagSources) {
42
+ const groups = new Map();
43
+ for (const tagSource of tagSources) {
44
+ const key = `${tagSource.sourceVersionId}:${tagSource.sourceDigest}`;
45
+ const existing = groups.get(key);
46
+ if (existing) {
47
+ existing.tags.push(tagSource.tag);
48
+ continue;
49
+ }
50
+ groups.set(key, {
51
+ versionId: tagSource.sourceVersionId,
52
+ digest: tagSource.sourceDigest,
53
+ tags: [tagSource.tag]
54
+ });
55
+ }
56
+ return [...groups.values()];
57
+ }
package/dist/cli/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { handleCleanup } from "./_cleanup-command.js";
5
+ import { handleDbMerge } from "./_db-merge-command.js";
4
6
  import { handleScan } from "./_scan-command.js";
7
+ import { handleUntag } from "./_untag-command.js";
5
8
  export async function main(argv) {
6
9
  const [command, ...rest] = argv;
7
10
  if (!command) {
@@ -9,15 +12,30 @@ export async function main(argv) {
9
12
  return 1;
10
13
  }
11
14
  switch (command) {
15
+ case "cleanup":
16
+ return handleCleanup(rest);
17
+ case "db-merge":
18
+ return handleDbMerge(rest);
12
19
  case "scan":
13
20
  return handleScan(rest);
21
+ case "untag":
22
+ return handleUntag(rest);
14
23
  default:
15
24
  throw new Error(`unknown command: ${command}`);
16
25
  }
17
26
  }
18
27
  function printUsage() {
19
28
  console.error(`Usage:
20
- ghcr-manager scan --db <path> [--log-level <debug|info|warn|error|silent>] --owner <org> --package <name> --token <token>`);
29
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] --delete-untagged [--older-than <interval>]
30
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] --delete-ghost-images [--exclude-tag <tag> ...] [--use-regex] [--keep-n-tagged <count>] [--older-than <interval>]
31
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] --delete-partial-images [--exclude-tag <tag> ...] [--use-regex] [--keep-n-tagged <count>] [--older-than <interval>]
32
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] --delete-orphaned-images [--exclude-tag <tag> ...] [--use-regex] [--keep-n-tagged <count>] [--older-than <interval>]
33
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] --keep-n-tagged <count> [--older-than <interval>]
34
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] --keep-n-untagged <count> [--older-than <interval>]
35
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] --delete-tag <tag> [--delete-tag <tag> ...] [--exclude-tag <tag> ...] [--use-regex] [--older-than <interval>]
36
+ ghcr-manager db-merge --db <target-path> --source-db <path> [--source-db <path> ...]
37
+ ghcr-manager scan --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--github-output <path>] --owner <org> --package <name> --token <token>
38
+ ghcr-manager untag [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> --token <token> --tag <tag> [--tag <tag> ...]`);
21
39
  }
22
40
  const _entryPath = process.argv[1];
23
41
  const _isDirectExecution = realpathSync(_entryPath) === realpathSync(fileURLToPath(import.meta.url));
@@ -0,0 +1,3 @@
1
+ export declare const githubApiBaseUrl = "https://api.github.com";
2
+ export declare const githubApiVersion = "2022-11-28";
3
+ export declare const ghcrRegistryBaseUrl = "https://ghcr.io";
@@ -0,0 +1,3 @@
1
+ export const githubApiBaseUrl = "https://api.github.com";
2
+ export const githubApiVersion = "2022-11-28";
3
+ export const ghcrRegistryBaseUrl = "https://ghcr.io";
@@ -2,5 +2,8 @@ export declare const packageVersionPageFetchConcurrency = 4;
2
2
  export declare const manifestFetchConcurrency = 16;
3
3
  export declare const ingestRequestRetryCount = 3;
4
4
  export declare const ingestRequestRetryDelayMs = 1000;
5
+ export declare const executeRequestRetryCount = 3;
6
+ export declare const executeRequestRetryDelayMs = 1000;
5
7
  export declare const paginatedIngestProgressIntervalPages = 10;
6
8
  export declare const manifestIngestProgressStepRatio = 0.05;
9
+ export { ghcrRegistryBaseUrl, githubApiBaseUrl, githubApiVersion } from "./_service-constants.js";
@@ -2,5 +2,8 @@ export const packageVersionPageFetchConcurrency = 4;
2
2
  export const manifestFetchConcurrency = 16;
3
3
  export const ingestRequestRetryCount = 3;
4
4
  export const ingestRequestRetryDelayMs = 1000;
5
+ export const executeRequestRetryCount = 3;
6
+ export const executeRequestRetryDelayMs = 1000;
5
7
  export const paginatedIngestProgressIntervalPages = 10;
6
8
  export const manifestIngestProgressStepRatio = 0.05;
9
+ export { ghcrRegistryBaseUrl, githubApiBaseUrl, githubApiVersion } from "./_service-constants.js";
@@ -0,0 +1,11 @@
1
+ interface _FetchLikeResponse {
2
+ ok: boolean;
3
+ status: number;
4
+ headers: Headers;
5
+ json(): Promise<unknown>;
6
+ }
7
+ interface _Logger {
8
+ warn(message: string): void;
9
+ }
10
+ export declare function getOwnerURIComponent(fetchImpl: (input: string, init?: RequestInit) => Promise<_FetchLikeResponse>, owner: string, token: string, logger: _Logger): Promise<string>;
11
+ export {};
@@ -0,0 +1,45 @@
1
+ import { githubApiBaseUrl, githubApiVersion, ingestRequestRetryCount, ingestRequestRetryDelayMs } from "../config/index.js";
2
+ import { buildHttpErrorMessage } from "./_http-error.js";
3
+ const _ownerUriComponentByOwner = new Map();
4
+ export async function getOwnerURIComponent(fetchImpl, owner, token, logger) {
5
+ const cachedOwnerURIComponent = _ownerUriComponentByOwner.get(owner);
6
+ if (cachedOwnerURIComponent) {
7
+ return cachedOwnerURIComponent;
8
+ }
9
+ const url = new URL(`/users/${encodeURIComponent(owner)}`, githubApiBaseUrl).toString();
10
+ for (let attempt = 1;; attempt += 1) {
11
+ const response = await fetchImpl(url, {
12
+ headers: {
13
+ Accept: "application/vnd.github+json",
14
+ Authorization: `Bearer ${token}`,
15
+ "User-Agent": "ghcr-manager",
16
+ "X-GitHub-Api-Version": githubApiVersion
17
+ }
18
+ });
19
+ if (response.ok) {
20
+ const payload = (await response.json());
21
+ if (payload.type === "Organization") {
22
+ const ownerURIComponent = `orgs/${encodeURIComponent(owner)}`;
23
+ _ownerUriComponentByOwner.set(owner, ownerURIComponent);
24
+ return ownerURIComponent;
25
+ }
26
+ if (payload.type === "User") {
27
+ const ownerURIComponent = `users/${encodeURIComponent(owner)}`;
28
+ _ownerUriComponentByOwner.set(owner, ownerURIComponent);
29
+ return ownerURIComponent;
30
+ }
31
+ throw new Error(`GitHub owner lookup did not include a supported type`);
32
+ }
33
+ if (!_isRetryableStatus(response.status) || attempt > ingestRequestRetryCount) {
34
+ throw new Error(await buildHttpErrorMessage(response, "GitHub owner lookup failed"));
35
+ }
36
+ logger.warn(`GitHub owner lookup failed on attempt ${attempt}/${ingestRequestRetryCount + 1}; retrying in ${ingestRequestRetryDelayMs}ms - ${await buildHttpErrorMessage(response, "GitHub owner lookup failed")}`);
37
+ await _sleep(ingestRequestRetryDelayMs);
38
+ }
39
+ }
40
+ function _isRetryableStatus(status) {
41
+ return status === 429 || status === 502 || status === 503 || status === 504;
42
+ }
43
+ function _sleep(delayMs) {
44
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
45
+ }
@@ -0,0 +1,6 @@
1
+ export interface HttpErrorResponse {
2
+ status: number;
3
+ headers: Headers;
4
+ json(): Promise<unknown>;
5
+ }
6
+ export declare function buildHttpErrorMessage(response: HttpErrorResponse, fallback: string): Promise<string>;
@@ -0,0 +1,33 @@
1
+ export async function buildHttpErrorMessage(response, fallback) {
2
+ const details = [fallback, `status ${response.status}`];
3
+ const body = await _readJsonErrorBody(response);
4
+ const message = typeof body?.message === "string" ? body.message : undefined;
5
+ const documentationUrl = typeof body?.documentation_url === "string" ? body.documentation_url : undefined;
6
+ const authenticateHeader = response.headers.get("www-authenticate") ?? undefined;
7
+ if (message) {
8
+ details.push(message);
9
+ }
10
+ if (documentationUrl) {
11
+ details.push(documentationUrl);
12
+ }
13
+ if (authenticateHeader) {
14
+ details.push(`www-authenticate: ${authenticateHeader}`);
15
+ }
16
+ return details.join(" - ");
17
+ }
18
+ async function _readJsonErrorBody(response) {
19
+ const contentType = response.headers.get("content-type")?.split(";")[0];
20
+ if (contentType && contentType !== "application/json" && !contentType.endsWith("+json")) {
21
+ return undefined;
22
+ }
23
+ try {
24
+ const body = await response.json();
25
+ if (body && typeof body === "object") {
26
+ return body;
27
+ }
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ return undefined;
33
+ }