ghcr-manager 0.9.2 → 0.9.3

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.3] - 2026-05-21
11
+
12
+ ### Changed
13
+
14
+ - Cleanup selector planning now composes tagged and untagged selector families in one SQL-backed planner path.
15
+ - Cleanup CLI help and docs now describe the composed selector model, including tagged selectors combined with
16
+ `delete-untagged`.
17
+
18
+ ### Fixed
19
+
20
+ - `exclude-tag` now works correctly when a tagged selector family is combined with `delete-untagged`.
21
+
10
22
  ## [0.9.2] - 2026-05-21
11
23
 
12
24
  ### Changed
package/README.md CHANGED
@@ -33,7 +33,7 @@ jobs:
33
33
 
34
34
  - name: Preview GHCR cleanup
35
35
  id: ghcr-manager
36
- uses: gh-workflow/ghcr-manager@0.9.2
36
+ uses: gh-workflow/ghcr-manager@0.9.3
37
37
  with:
38
38
  command: cleanup
39
39
  token: ${{ github.token }}
@@ -72,7 +72,7 @@ The action supports three commands:
72
72
  ### Preview cleanup
73
73
 
74
74
  ```yaml
75
- - uses: gh-workflow/ghcr-manager@0.9.2
75
+ - uses: gh-workflow/ghcr-manager@0.9.3
76
76
  with:
77
77
  command: cleanup
78
78
  token: ${{ github.token }}
@@ -93,7 +93,7 @@ The action supports three commands:
93
93
  ### Apply cleanup
94
94
 
95
95
  ```yaml
96
- - uses: gh-workflow/ghcr-manager@0.9.2
96
+ - uses: gh-workflow/ghcr-manager@0.9.3
97
97
  with:
98
98
  command: cleanup
99
99
  token: ${{ github.token }}
@@ -112,7 +112,7 @@ Note: the second scan only runs if cleanup actually makes changes.
112
112
  ### Remove selected tags directly
113
113
 
114
114
  ```yaml
115
- - uses: gh-workflow/ghcr-manager@0.9.2
115
+ - uses: gh-workflow/ghcr-manager@0.9.3
116
116
  with:
117
117
  command: untag
118
118
  token: ${{ github.token }}
@@ -128,7 +128,7 @@ Note: the second scan only runs if cleanup actually makes changes.
128
128
  ### Scan one package
129
129
 
130
130
  ```yaml
131
- - uses: gh-workflow/ghcr-manager@0.9.2
131
+ - uses: gh-workflow/ghcr-manager@0.9.3
132
132
  with:
133
133
  command: scan
134
134
  token: ${{ github.token }}
@@ -169,6 +169,12 @@ Note: the second scan only runs if cleanup actually makes changes.
169
169
 
170
170
  `Cmds`: `s` = `scan`, `c` = `cleanup`, `u` = `untag`
171
171
 
172
+ Cleanup notes:
173
+
174
+ - Tagged selector families may be combined with `delete-untagged`.
175
+ - `exclude-tags` requires at least one tagged selector family.
176
+ - `delete-untagged` and `keep-n-untagged` cannot be combined.
177
+
172
178
  ## Outputs
173
179
 
174
180
  | Output | Description |
@@ -27,14 +27,14 @@ export function resolvePlanCommandInputs(args) {
27
27
  deletePartialImages ||
28
28
  deleteOrphanedImages ||
29
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");
30
+ const hasAnySelector = deleteUntagged || taggedSelectorActive || keepNUntagged !== undefined;
31
+ if (deleteUntagged && keepNUntagged !== undefined) {
32
+ throw new Error("--delete-untagged and --keep-n-untagged cannot be combined");
33
33
  }
34
- if (selectorCount === 0) {
34
+ if (!hasAnySelector) {
35
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
36
  }
37
- if ((deleteUntagged || keepNUntagged !== undefined) && excludeTags.length > 0) {
37
+ if (!taggedSelectorActive && excludeTags.length > 0) {
38
38
  throw new Error("--exclude-tag is only supported with tagged selector families");
39
39
  }
40
40
  if (olderThanRaw.length > 1) {
@@ -60,35 +60,17 @@ export function resolvePlanCommandInputs(args) {
60
60
  };
61
61
  }
62
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, {
63
+ return repository.getCleanupPlanWithCutoff(inputs.owner, inputs.packageName, {
64
+ deleteTags: inputs.deleteTags,
65
+ deleteTagsRequested: inputs.deleteTagsRequested,
86
66
  deleteGhostImages: inputs.deleteGhostImages,
87
67
  deletePartialImages: inputs.deletePartialImages,
88
68
  deleteOrphanedImages: inputs.deleteOrphanedImages,
89
- deleteTagsRequested: inputs.deleteTagsRequested,
90
- keepNTagged: inputs.keepNTagged,
69
+ excludeTags: inputs.excludeTags,
70
+ deleteUntagged: inputs.deleteUntagged,
91
71
  useRegex: inputs.useRegex,
72
+ keepNTagged: inputs.keepNTagged,
73
+ keepNUntagged: inputs.keepNUntagged,
92
74
  olderThan: inputs.olderThan,
93
75
  cutoffTimestamp: inputs.cutoffTimestamp
94
76
  });
package/dist/cli/index.js CHANGED
@@ -26,16 +26,24 @@ export async function main(argv) {
26
26
  }
27
27
  function printUsage() {
28
28
  console.error(`Usage:
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>]
29
+ ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] <cleanup selectors...> [--exclude-tag <tag> ...] [--use-regex] [--older-than <interval>]
36
30
  ghcr-manager db-merge --db <target-path> --source-db <path> [--source-db <path> ...]
37
31
  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> ...]`);
32
+ ghcr-manager untag [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> --token <token> --tag <tag> [--tag <tag> ...]
33
+
34
+ Cleanup selectors:
35
+ --delete-untagged
36
+ --delete-ghost-images
37
+ --delete-partial-images
38
+ --delete-orphaned-images
39
+ --delete-tag <tag> [--delete-tag <tag> ...]
40
+ --keep-n-tagged <count>
41
+ --keep-n-untagged <count>
42
+
43
+ Notes:
44
+ - Tagged selector families may be combined with --delete-untagged.
45
+ - --exclude-tag requires at least one tagged selector family.
46
+ - --delete-untagged and --keep-n-untagged cannot be combined.`);
39
47
  }
40
48
  const _entryPath = process.argv[1];
41
49
  const _isDirectExecution = realpathSync(_entryPath) === realpathSync(fileURLToPath(import.meta.url));
@@ -0,0 +1,17 @@
1
+ import { PlannerSql } from "./_planner-sql.js";
2
+ import { type DeletePlanRoot } from "./_planner-types.js";
3
+ export interface DirectTargetRootOptions {
4
+ deleteTags: string[];
5
+ deleteTagsRequested: boolean;
6
+ excludeTags: string[];
7
+ deleteUntagged: boolean;
8
+ keepNTagged?: number;
9
+ keepNUntagged?: number;
10
+ useRegex?: boolean;
11
+ cutoffTimestamp?: string;
12
+ }
13
+ export declare class PlannerDirectTargetRoots {
14
+ #private;
15
+ constructor(sql: PlannerSql);
16
+ list(scanId: number, options: DirectTargetRootOptions): DeletePlanRoot[];
17
+ }
@@ -0,0 +1,199 @@
1
+ import { buildTagSelectorPredicate } from "./_planner-tag-selectors.js";
2
+ import { mapPlanRootRow } from "./_planner-types.js";
3
+ export class PlannerDirectTargetRoots {
4
+ #sql;
5
+ constructor(sql) {
6
+ this.#sql = sql;
7
+ }
8
+ list(scanId, options) {
9
+ const selectedTagPredicate = options.deleteTags.length > 0
10
+ ? buildTagSelectorPredicate(this.#sql.database, "t.tag", options.deleteTags, options.useRegex ?? false)
11
+ : undefined;
12
+ const excludedTagPredicate = options.excludeTags.length > 0
13
+ ? buildTagSelectorPredicate(this.#sql.database, "xt.tag", options.excludeTags, options.useRegex ?? false)
14
+ : undefined;
15
+ const params = [scanId];
16
+ const cutoffSql = options.cutoffTimestamp ? "AND created_at < ?" : "";
17
+ if (options.cutoffTimestamp) {
18
+ params.push(options.cutoffTimestamp);
19
+ }
20
+ const selectedTagsSql = selectedTagPredicate
21
+ ? `
22
+ SELECT DISTINCT t.version_id, t.tag
23
+ FROM tags t
24
+ WHERE t.scan_id = ?
25
+ AND (${selectedTagPredicate.sql})
26
+ `
27
+ : `
28
+ SELECT NULL AS version_id, NULL AS tag
29
+ WHERE 1 = 0
30
+ `;
31
+ if (selectedTagPredicate) {
32
+ params.push(scanId, ...selectedTagPredicate.params);
33
+ }
34
+ const excludedVersionsSql = excludedTagPredicate
35
+ ? `
36
+ SELECT DISTINCT xt.version_id
37
+ FROM tags xt
38
+ WHERE xt.scan_id = ?
39
+ AND (${excludedTagPredicate.sql})
40
+ `
41
+ : `
42
+ SELECT NULL AS version_id
43
+ WHERE 1 = 0
44
+ `;
45
+ if (excludedTagPredicate) {
46
+ params.push(scanId, ...excludedTagPredicate.params);
47
+ }
48
+ const taggedBranchEnabled = options.deleteTagsRequested || options.keepNTagged !== undefined ? 1 : 0;
49
+ const deleteTagsRequested = options.deleteTagsRequested ? 1 : 0;
50
+ const keepNTaggedActive = options.keepNTagged !== undefined ? 1 : 0;
51
+ const deleteUntagged = options.deleteUntagged ? 1 : 0;
52
+ const keepNUntaggedActive = options.keepNUntagged !== undefined ? 1 : 0;
53
+ const paramsTail = [
54
+ taggedBranchEnabled,
55
+ deleteTagsRequested,
56
+ deleteTagsRequested,
57
+ keepNTaggedActive,
58
+ deleteTagsRequested,
59
+ keepNTaggedActive,
60
+ options.keepNTagged ?? 0,
61
+ deleteUntagged,
62
+ keepNUntaggedActive,
63
+ deleteUntagged,
64
+ deleteUntagged,
65
+ keepNUntaggedActive,
66
+ options.keepNUntagged ?? 0
67
+ ];
68
+ const sql = `
69
+ WITH root_candidates AS (
70
+ SELECT
71
+ root_version_id AS version_id,
72
+ root_digest,
73
+ root_manifest_kind,
74
+ created_at,
75
+ tag_count,
76
+ is_tagged
77
+ FROM v_scan_root_manifests
78
+ WHERE scan_id = ?
79
+ AND has_ancestor = 0
80
+ ${cutoffSql}
81
+ ),
82
+ selected_tags AS (
83
+ ${selectedTagsSql}
84
+ ),
85
+ excluded_versions AS (
86
+ ${excludedVersionsSql}
87
+ ),
88
+ matched_tag_counts AS (
89
+ SELECT
90
+ st.version_id,
91
+ COUNT(DISTINCT st.tag) AS matched_tag_count
92
+ FROM selected_tags st
93
+ GROUP BY st.version_id
94
+ ),
95
+ eligible_tagged_roots AS (
96
+ SELECT
97
+ rc.version_id,
98
+ rc.root_digest,
99
+ rc.root_manifest_kind,
100
+ rc.created_at,
101
+ rc.tag_count AS total_tag_count,
102
+ COALESCE(mtc.matched_tag_count, 0) AS matched_tag_count
103
+ FROM root_candidates rc
104
+ LEFT JOIN matched_tag_counts mtc
105
+ ON mtc.version_id = rc.version_id
106
+ LEFT JOIN excluded_versions ev
107
+ ON ev.version_id = rc.version_id
108
+ WHERE rc.is_tagged = 1
109
+ AND ev.version_id IS NULL
110
+ AND ? = 1
111
+ ),
112
+ ranked_tagged_roots AS (
113
+ SELECT
114
+ version_id,
115
+ root_digest,
116
+ root_manifest_kind,
117
+ total_tag_count,
118
+ matched_tag_count,
119
+ ROW_NUMBER() OVER (
120
+ ORDER BY created_at DESC, version_id DESC, root_digest DESC
121
+ ) AS recency_rank
122
+ FROM eligible_tagged_roots
123
+ WHERE ? = 0
124
+ OR matched_tag_count > 0
125
+ ),
126
+ final_tagged_targets AS (
127
+ SELECT
128
+ version_id,
129
+ root_digest,
130
+ root_manifest_kind,
131
+ CASE
132
+ WHEN ? = 0
133
+ THEN 'keep-n-tagged-overflow'
134
+ WHEN ? = 1 AND total_tag_count = matched_tag_count
135
+ THEN 'keep-n-tagged-overflow'
136
+ WHEN total_tag_count = matched_tag_count
137
+ THEN 'delete-tags-all-tags-selected'
138
+ ELSE 'delete-tags-partial-tag-match'
139
+ END AS direct_target_reason,
140
+ CASE
141
+ WHEN ? = 0
142
+ THEN 'delete-root'
143
+ WHEN total_tag_count = matched_tag_count
144
+ THEN 'delete-root'
145
+ ELSE 'untag-only'
146
+ END AS selection_mode
147
+ FROM ranked_tagged_roots
148
+ WHERE ? = 0
149
+ OR recency_rank > ?
150
+ ),
151
+ ranked_untagged_roots AS (
152
+ SELECT
153
+ rc.version_id,
154
+ rc.root_digest,
155
+ rc.root_manifest_kind,
156
+ ROW_NUMBER() OVER (
157
+ ORDER BY rc.created_at DESC, rc.version_id DESC, rc.root_digest DESC
158
+ ) AS recency_rank
159
+ FROM root_candidates rc
160
+ WHERE rc.is_tagged = 0
161
+ AND (? = 1 OR ? = 1)
162
+ ),
163
+ final_untagged_targets AS (
164
+ SELECT
165
+ version_id,
166
+ root_digest,
167
+ root_manifest_kind,
168
+ CASE
169
+ WHEN ? = 1
170
+ THEN 'delete-untagged'
171
+ ELSE 'keep-n-untagged-overflow'
172
+ END AS direct_target_reason,
173
+ 'delete-root' AS selection_mode
174
+ FROM ranked_untagged_roots
175
+ WHERE ? = 1
176
+ OR (? = 1 AND recency_rank > ?)
177
+ )
178
+ SELECT
179
+ version_id,
180
+ root_digest,
181
+ root_manifest_kind,
182
+ direct_target_reason,
183
+ selection_mode
184
+ FROM final_tagged_targets
185
+
186
+ UNION ALL
187
+
188
+ SELECT
189
+ version_id,
190
+ root_digest,
191
+ root_manifest_kind,
192
+ direct_target_reason,
193
+ selection_mode
194
+ FROM final_untagged_targets
195
+ ORDER BY root_digest
196
+ `;
197
+ return this.#sql.all(sql, [...params, ...paramsTail]).map(mapPlanRootRow);
198
+ }
199
+ }
@@ -0,0 +1,10 @@
1
+ import type { ScanRow } from "./_planner-types.js";
2
+ interface LatestScanSql {
3
+ get<T>(sql: string, params: Array<number | string>): T | undefined;
4
+ }
5
+ export declare class PlannerLatestScan {
6
+ #private;
7
+ constructor(sql: LatestScanSql);
8
+ get(owner: string, packageName: string): ScanRow;
9
+ }
10
+ export {};
@@ -0,0 +1,20 @@
1
+ export class PlannerLatestScan {
2
+ #sql;
3
+ constructor(sql) {
4
+ this.#sql = sql;
5
+ }
6
+ get(owner, packageName) {
7
+ const sql = `
8
+ SELECT scan_id, owner, package_name, scan_completed_at
9
+ FROM v_latest_scan_per_package
10
+ WHERE owner = ?
11
+ AND package_name = ?
12
+ LIMIT 1
13
+ `;
14
+ const row = this.#sql.get(sql, [owner, packageName]);
15
+ if (!row) {
16
+ throw new Error(`database does not contain completed package scan for ${owner}/${packageName}`);
17
+ }
18
+ return row;
19
+ }
20
+ }
@@ -7,7 +7,6 @@ export declare class PlannerRepository {
7
7
  getDeleteUntaggedPlan(owner: string, packageName: string): DeletePlan;
8
8
  getLatestCompletedScanId(owner: string, packageName: string): number;
9
9
  getKeepNUntaggedPlan(owner: string, packageName: string, keepCount: number): DeletePlan;
10
- getKeepNTaggedPlan(owner: string, packageName: string, keepCount: number): DeletePlan;
11
10
  getDeleteUntaggedPlanWithCutoff(owner: string, packageName: string, options?: {
12
11
  olderThan?: string;
13
12
  cutoffTimestamp?: string;
@@ -16,10 +15,6 @@ export declare class PlannerRepository {
16
15
  olderThan?: string;
17
16
  cutoffTimestamp?: string;
18
17
  }): DeletePlan;
19
- getKeepNTaggedPlanWithCutoff(owner: string, packageName: string, keepCount: number, excludeTags: string[], options?: {
20
- olderThan?: string;
21
- cutoffTimestamp?: string;
22
- }): DeletePlan;
23
18
  getDeleteTagsPlan(owner: string, packageName: string, deleteTags: string[], excludeTags: string[]): DeletePlan;
24
19
  getDeleteTagsPlanWithCutoff(owner: string, packageName: string, deleteTags: string[], excludeTags: string[], options?: {
25
20
  deleteTagsRequested?: boolean;
@@ -31,4 +26,18 @@ export declare class PlannerRepository {
31
26
  olderThan?: string;
32
27
  cutoffTimestamp?: string;
33
28
  }): DeletePlan;
29
+ getCleanupPlanWithCutoff(owner: string, packageName: string, options?: {
30
+ deleteUntagged?: boolean;
31
+ deleteGhostImages?: boolean;
32
+ deletePartialImages?: boolean;
33
+ deleteOrphanedImages?: boolean;
34
+ deleteTags?: string[];
35
+ deleteTagsRequested?: boolean;
36
+ excludeTags?: string[];
37
+ keepNTagged?: number;
38
+ keepNUntagged?: number;
39
+ useRegex?: boolean;
40
+ olderThan?: string;
41
+ cutoffTimestamp?: string;
42
+ }): DeletePlan;
34
43
  }
@@ -1,95 +1,73 @@
1
+ import { PlannerDirectTargetRoots } from "./_planner-direct-target-roots.js";
2
+ import { PlannerDirectTargetTags } from "./_planner-direct-target-tags.js";
3
+ import { PlannerLatestScan } from "./_planner-latest-scan.js";
1
4
  import { buildPlanOutputs } from "./_planner-output.js";
2
5
  import { PlannerPlanArtifacts } from "./_planner-plan-artifacts.js";
3
6
  import { PlannerSql } from "./_planner-sql.js";
4
- import { PlannerTaggedTargets } from "./_planner-tagged-targets.js";
5
- import { PlannerUntaggedTargets } from "./_planner-untagged-targets.js";
6
7
  export class PlannerRepository {
7
- #untaggedTargets;
8
- #taggedTargets;
8
+ #latestScan;
9
+ #directTargetTags;
10
+ #directTargetRoots;
9
11
  #planArtifacts;
10
12
  constructor(database, logger) {
11
13
  const sql = new PlannerSql(database, logger);
12
- this.#untaggedTargets = new PlannerUntaggedTargets(sql);
13
- this.#taggedTargets = new PlannerTaggedTargets(sql);
14
+ this.#latestScan = new PlannerLatestScan(sql);
15
+ this.#directTargetTags = new PlannerDirectTargetTags(sql);
16
+ this.#directTargetRoots = new PlannerDirectTargetRoots(sql);
14
17
  this.#planArtifacts = new PlannerPlanArtifacts(sql);
15
18
  }
16
19
  getDeleteUntaggedPlan(owner, packageName) {
17
20
  return this.getDeleteUntaggedPlanWithCutoff(owner, packageName);
18
21
  }
19
22
  getLatestCompletedScanId(owner, packageName) {
20
- return this.#untaggedTargets.getLatestCompletedScan(owner, packageName).scan_id;
23
+ return this.#latestScan.get(owner, packageName).scan_id;
21
24
  }
22
25
  getKeepNUntaggedPlan(owner, packageName, keepCount) {
23
26
  return this.getKeepNUntaggedPlanWithCutoff(owner, packageName, keepCount);
24
27
  }
25
- getKeepNTaggedPlan(owner, packageName, keepCount) {
26
- return this.getKeepNTaggedPlanWithCutoff(owner, packageName, keepCount, []);
27
- }
28
28
  getDeleteUntaggedPlanWithCutoff(owner, packageName, options) {
29
- const scan = this.#untaggedTargets.getLatestCompletedScan(owner, packageName);
30
- const directTargetRoots = this.#untaggedTargets.listDeleteUntaggedDirectTargetRoots(scan.scan_id, options?.cutoffTimestamp);
31
- const planArtifacts = this.#planArtifacts.build(scan.scan_id, directTargetRoots);
32
- return {
33
- owner: scan.owner,
34
- packageName: scan.package_name,
35
- scanCompletedAt: scan.scan_completed_at,
36
- plannerInputs: _buildPlannerInputs({
37
- deleteUntagged: true,
38
- olderThan: options?.olderThan,
39
- cutoffTimestamp: options?.cutoffTimestamp
40
- }),
41
- ...buildPlanOutputs([], directTargetRoots, planArtifacts)
42
- };
29
+ return this.getCleanupPlanWithCutoff(owner, packageName, {
30
+ deleteUntagged: true,
31
+ olderThan: options?.olderThan,
32
+ cutoffTimestamp: options?.cutoffTimestamp
33
+ });
43
34
  }
44
35
  getKeepNUntaggedPlanWithCutoff(owner, packageName, keepCount, options) {
45
- const scan = this.#untaggedTargets.getLatestCompletedScan(owner, packageName);
46
- const directTargetRoots = this.#untaggedTargets.listKeepNUntaggedDirectTargetRoots(scan.scan_id, keepCount, options?.cutoffTimestamp);
47
- const planArtifacts = this.#planArtifacts.build(scan.scan_id, directTargetRoots);
48
- return {
49
- owner: scan.owner,
50
- packageName: scan.package_name,
51
- scanCompletedAt: scan.scan_completed_at,
52
- plannerInputs: _buildPlannerInputs({
53
- keepNUntagged: keepCount,
54
- olderThan: options?.olderThan,
55
- cutoffTimestamp: options?.cutoffTimestamp
56
- }),
57
- ...buildPlanOutputs([], directTargetRoots, planArtifacts)
58
- };
59
- }
60
- getKeepNTaggedPlanWithCutoff(owner, packageName, keepCount, excludeTags, options) {
61
- const scan = this.#untaggedTargets.getLatestCompletedScan(owner, packageName);
62
- const directTargetRoots = this.#taggedTargets.listTaggedDirectTargetRoots(scan.scan_id, {
63
- deleteTags: [],
64
- excludeTags,
65
- keepCount,
36
+ return this.getCleanupPlanWithCutoff(owner, packageName, {
37
+ keepNUntagged: keepCount,
38
+ olderThan: options?.olderThan,
66
39
  cutoffTimestamp: options?.cutoffTimestamp
67
40
  });
68
- const planArtifacts = this.#planArtifacts.build(scan.scan_id, directTargetRoots);
69
- return {
70
- owner: scan.owner,
71
- packageName: scan.package_name,
72
- scanCompletedAt: scan.scan_completed_at,
73
- plannerInputs: _buildPlannerInputs({
74
- excludeTags,
75
- keepNTagged: keepCount,
76
- olderThan: options?.olderThan,
77
- cutoffTimestamp: options?.cutoffTimestamp
78
- }),
79
- ...buildPlanOutputs([], directTargetRoots, planArtifacts)
80
- };
81
41
  }
82
42
  getDeleteTagsPlan(owner, packageName, deleteTags, excludeTags) {
83
43
  return this.getDeleteTagsPlanWithCutoff(owner, packageName, deleteTags, excludeTags);
84
44
  }
85
45
  getDeleteTagsPlanWithCutoff(owner, packageName, deleteTags, excludeTags, options) {
86
- const scan = this.#untaggedTargets.getLatestCompletedScan(owner, packageName);
87
- const directTargetTags = this.#taggedTargets.listDeleteTagDirectTargetTags(scan.scan_id, deleteTags, excludeTags, options?.useRegex ?? false, options?.cutoffTimestamp);
88
- const directTargetRoots = this.#taggedTargets.listTaggedDirectTargetRoots(scan.scan_id, {
46
+ return this.getCleanupPlanWithCutoff(owner, packageName, {
89
47
  deleteTags,
48
+ excludeTags,
49
+ deleteGhostImages: options?.deleteGhostImages,
50
+ deletePartialImages: options?.deletePartialImages,
51
+ deleteOrphanedImages: options?.deleteOrphanedImages,
90
52
  deleteTagsRequested: options?.deleteTagsRequested ?? true,
53
+ keepNTagged: options?.keepNTagged,
54
+ useRegex: options?.useRegex,
55
+ olderThan: options?.olderThan,
56
+ cutoffTimestamp: options?.cutoffTimestamp
57
+ });
58
+ }
59
+ getCleanupPlanWithCutoff(owner, packageName, options) {
60
+ const scan = this.#latestScan.get(owner, packageName);
61
+ const deleteTags = options?.deleteTags ?? [];
62
+ const excludeTags = options?.excludeTags ?? [];
63
+ const directTargetTags = this.#directTargetTags.listDeleteTagDirectTargetTags(scan.scan_id, deleteTags, excludeTags, options?.useRegex ?? false, options?.cutoffTimestamp);
64
+ const directTargetRoots = this.#directTargetRoots.list(scan.scan_id, {
65
+ deleteTags,
66
+ deleteTagsRequested: options?.deleteTagsRequested ?? false,
91
67
  excludeTags,
92
- keepCount: options?.keepNTagged,
68
+ deleteUntagged: options?.deleteUntagged ?? false,
69
+ keepNTagged: options?.keepNTagged,
70
+ keepNUntagged: options?.keepNUntagged,
93
71
  useRegex: options?.useRegex ?? false,
94
72
  cutoffTimestamp: options?.cutoffTimestamp
95
73
  });
@@ -103,8 +81,10 @@ export class PlannerRepository {
103
81
  deletePartialImages: options?.deletePartialImages || undefined,
104
82
  deleteOrphanedImages: options?.deleteOrphanedImages || undefined,
105
83
  deleteTags,
84
+ deleteUntagged: options?.deleteUntagged || undefined,
106
85
  excludeTags,
107
86
  keepNTagged: options?.keepNTagged,
87
+ keepNUntagged: options?.keepNUntagged,
108
88
  useRegex: options?.useRegex || undefined,
109
89
  olderThan: options?.olderThan,
110
90
  cutoffTimestamp: options?.cutoffTimestamp
@@ -118,9 +98,6 @@ function _buildPlannerInputs(inputs) {
118
98
  if (value === undefined) {
119
99
  return false;
120
100
  }
121
- if (value === false) {
122
- return false;
123
- }
124
101
  return !(Array.isArray(value) && value.length === 0);
125
102
  }));
126
103
  }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "type": "git",
10
10
  "url": "https://github.com/gh-workflow/ghcr-manager"
11
11
  },
12
- "version": "0.9.2",
12
+ "version": "0.9.3",
13
13
  "type": "module",
14
14
  "engines": {
15
15
  "node": ">=20.0.0"
@@ -1,7 +0,0 @@
1
- import { PlannerSql } from "./_planner-sql.js";
2
- import { type DeletePlanRoot } from "./_planner-types.js";
3
- export declare class PlannerDeleteTagRootTargets {
4
- #private;
5
- constructor(sql: PlannerSql);
6
- list(scanId: number, deleteTags: string[], excludeTags: string[], useRegex: boolean, keepCount?: number, cutoffTimestamp?: string): DeletePlanRoot[];
7
- }
@@ -1,130 +0,0 @@
1
- import { buildTagSelectorPredicate } from "./_planner-tag-selectors.js";
2
- import { mapPlanRootRow } from "./_planner-types.js";
3
- export class PlannerDeleteTagRootTargets {
4
- #sql;
5
- constructor(sql) {
6
- this.#sql = sql;
7
- }
8
- list(scanId, deleteTags, excludeTags, useRegex, keepCount, cutoffTimestamp) {
9
- const selectedTagPredicate = buildTagSelectorPredicate(this.#sql.database, "st.tag", deleteTags, useRegex);
10
- const excludedTagPredicate = excludeTags.length > 0
11
- ? buildTagSelectorPredicate(this.#sql.database, "xt.tag", excludeTags, useRegex)
12
- : undefined;
13
- const excludedVersionsCte = excludedTagPredicate
14
- ? `
15
- excluded_versions AS (
16
- SELECT DISTINCT xt.version_id
17
- FROM tags xt
18
- WHERE xt.scan_id = ?
19
- AND (${excludedTagPredicate.sql})
20
- ),
21
- `
22
- : "";
23
- const excludedJoinSql = excludedTagPredicate
24
- ? `
25
- LEFT JOIN excluded_versions ev
26
- ON ev.version_id = st.version_id
27
- `
28
- : "";
29
- const excludedWhereSql = excludedTagPredicate ? "AND ev.version_id IS NULL" : "";
30
- const cutoffSql = cutoffTimestamp ? "AND pv.created_at < ?" : "";
31
- const keepSql = keepCount !== undefined ? "WHERE recency_rank > ?" : "";
32
- const params = [scanId, ...selectedTagPredicate.params];
33
- if (excludedTagPredicate) {
34
- params.push(scanId, ...excludedTagPredicate.params);
35
- }
36
- if (cutoffTimestamp) {
37
- params.push(cutoffTimestamp);
38
- }
39
- params.push(scanId);
40
- const tailParams = [keepCount !== undefined ? 1 : 0];
41
- if (keepCount !== undefined) {
42
- tailParams.push(keepCount);
43
- }
44
- const sql = `
45
- WITH selected_tags AS (
46
- SELECT st.scan_id, st.version_id, st.tag
47
- FROM tags st
48
- WHERE st.scan_id = ?
49
- AND (${selectedTagPredicate.sql})
50
- ),
51
- ${excludedVersionsCte}
52
- matched_candidate_roots AS (
53
- SELECT DISTINCT
54
- st.version_id AS version_id,
55
- m.digest AS root_digest,
56
- m.manifest_kind AS root_manifest_kind,
57
- pv.created_at
58
- FROM selected_tags st
59
- JOIN manifests m
60
- ON m.scan_id = st.scan_id
61
- AND m.version_id = st.version_id
62
- JOIN package_versions pv
63
- ON pv.scan_id = st.scan_id
64
- AND pv.version_id = st.version_id
65
- ${excludedJoinSql}
66
- WHERE 1 = 1
67
- ${excludedWhereSql}
68
- ${cutoffSql}
69
- AND NOT EXISTS (
70
- SELECT 1
71
- FROM manifest_reachability mr
72
- WHERE mr.scan_id = st.scan_id
73
- AND mr.descendant_digest = m.digest
74
- AND mr.min_distance > 0
75
- )
76
- ),
77
- matched_roots AS (
78
- SELECT
79
- mcr.version_id,
80
- mcr.root_digest,
81
- mcr.root_manifest_kind,
82
- mcr.created_at,
83
- COUNT(t.tag) AS total_tag_count,
84
- COUNT(st.tag) AS matched_tag_count
85
- FROM matched_candidate_roots mcr
86
- JOIN tags t
87
- ON t.scan_id = ?
88
- AND t.version_id = mcr.version_id
89
- LEFT JOIN selected_tags st
90
- ON st.scan_id = t.scan_id
91
- AND st.version_id = t.version_id
92
- AND st.tag = t.tag
93
- GROUP BY mcr.version_id, mcr.root_digest, mcr.root_manifest_kind, mcr.created_at
94
- HAVING matched_tag_count > 0
95
- ),
96
- ranked_roots AS (
97
- SELECT
98
- version_id,
99
- root_digest,
100
- root_manifest_kind,
101
- total_tag_count,
102
- matched_tag_count,
103
- ROW_NUMBER() OVER (
104
- ORDER BY created_at DESC, version_id DESC, root_digest DESC
105
- ) AS recency_rank
106
- FROM matched_roots
107
- )
108
- SELECT
109
- version_id,
110
- root_digest,
111
- root_manifest_kind,
112
- CASE
113
- WHEN total_tag_count = matched_tag_count AND ? = 1
114
- THEN 'keep-n-tagged-overflow'
115
- WHEN total_tag_count = matched_tag_count
116
- THEN 'delete-tags-all-tags-selected'
117
- ELSE 'delete-tags-partial-tag-match'
118
- END AS direct_target_reason,
119
- CASE
120
- WHEN total_tag_count = matched_tag_count
121
- THEN 'delete-root'
122
- ELSE 'untag-only'
123
- END AS selection_mode
124
- FROM ranked_roots
125
- ${keepSql}
126
- ORDER BY root_digest
127
- `;
128
- return this.#sql.all(sql, [...params, ...tailParams]).map(mapPlanRootRow);
129
- }
130
- }
@@ -1,7 +0,0 @@
1
- import { PlannerSql } from "./_planner-sql.js";
2
- import { type DeletePlanRoot } from "./_planner-types.js";
3
- export declare class PlannerKeepTaggedRootTargets {
4
- #private;
5
- constructor(sql: PlannerSql);
6
- list(scanId: number, excludeTags: string[], useRegex: boolean, keepCount?: number, cutoffTimestamp?: string): DeletePlanRoot[];
7
- }
@@ -1,74 +0,0 @@
1
- import { buildTagSelectorPredicate } from "./_planner-tag-selectors.js";
2
- import { mapPlanRootRow } from "./_planner-types.js";
3
- export class PlannerKeepTaggedRootTargets {
4
- #sql;
5
- constructor(sql) {
6
- this.#sql = sql;
7
- }
8
- list(scanId, excludeTags, useRegex, keepCount, cutoffTimestamp) {
9
- const excludedTagPredicate = excludeTags.length > 0
10
- ? buildTagSelectorPredicate(this.#sql.database, "xt.tag", excludeTags, useRegex)
11
- : undefined;
12
- const excludedRootSql = excludedTagPredicate
13
- ? `
14
- AND NOT EXISTS (
15
- SELECT 1
16
- FROM tags xt
17
- WHERE xt.scan_id = pv.scan_id
18
- AND xt.version_id = pv.version_id
19
- AND (${excludedTagPredicate.sql})
20
- )
21
- `
22
- : "";
23
- const cutoffSql = cutoffTimestamp ? "AND pv.created_at < ?" : "";
24
- const keepSql = keepCount !== undefined ? "WHERE recency_rank > ?" : "";
25
- const params = [scanId, ...(excludedTagPredicate?.params ?? [])];
26
- if (cutoffTimestamp) {
27
- params.push(cutoffTimestamp);
28
- }
29
- if (keepCount !== undefined) {
30
- params.push(keepCount);
31
- }
32
- const sql = `
33
- WITH eligible_tagged_roots AS (
34
- SELECT
35
- pv.version_id AS version_id,
36
- m.digest AS root_digest,
37
- m.manifest_kind AS root_manifest_kind,
38
- ROW_NUMBER() OVER (
39
- ORDER BY pv.created_at DESC, pv.version_id DESC, m.digest DESC
40
- ) AS recency_rank
41
- FROM package_versions pv
42
- JOIN manifests m
43
- ON m.scan_id = pv.scan_id
44
- AND m.version_id = pv.version_id
45
- WHERE pv.scan_id = ?
46
- AND EXISTS (
47
- SELECT 1
48
- FROM tags t
49
- WHERE t.scan_id = pv.scan_id
50
- AND t.version_id = pv.version_id
51
- )
52
- ${excludedRootSql}
53
- AND NOT EXISTS (
54
- SELECT 1
55
- FROM manifest_reachability mr
56
- WHERE mr.scan_id = pv.scan_id
57
- AND mr.descendant_digest = m.digest
58
- AND mr.min_distance > 0
59
- )
60
- ${cutoffSql}
61
- )
62
- SELECT
63
- version_id,
64
- root_digest,
65
- root_manifest_kind,
66
- 'keep-n-tagged-overflow' AS direct_target_reason,
67
- 'delete-root' AS selection_mode
68
- FROM eligible_tagged_roots
69
- ${keepSql}
70
- ORDER BY root_digest
71
- `;
72
- return this.#sql.all(sql, params).map(mapPlanRootRow);
73
- }
74
- }
@@ -1,15 +0,0 @@
1
- import { PlannerSql } from "./_planner-sql.js";
2
- import type { DeletePlanRoot } from "./_planner-types.js";
3
- export interface TaggedRootTargetOptions {
4
- deleteTags: string[];
5
- deleteTagsRequested?: boolean;
6
- excludeTags: string[];
7
- keepCount?: number;
8
- useRegex?: boolean;
9
- cutoffTimestamp?: string;
10
- }
11
- export declare class PlannerTaggedRootTargets {
12
- #private;
13
- constructor(sql: PlannerSql);
14
- listTaggedDirectTargetRoots(scanId: number, options: TaggedRootTargetOptions): DeletePlanRoot[];
15
- }
@@ -1,19 +0,0 @@
1
- import { PlannerDeleteTagRootTargets } from "./_planner-delete-tag-root-targets.js";
2
- import { PlannerKeepTaggedRootTargets } from "./_planner-keep-tagged-root-targets.js";
3
- export class PlannerTaggedRootTargets {
4
- #deleteTagTargets;
5
- #keepTaggedTargets;
6
- constructor(sql) {
7
- this.#deleteTagTargets = new PlannerDeleteTagRootTargets(sql);
8
- this.#keepTaggedTargets = new PlannerKeepTaggedRootTargets(sql);
9
- }
10
- listTaggedDirectTargetRoots(scanId, options) {
11
- if (options.deleteTagsRequested && options.deleteTags.length === 0) {
12
- return [];
13
- }
14
- if (options.deleteTags.length === 0) {
15
- return this.#keepTaggedTargets.list(scanId, options.excludeTags, options.useRegex ?? false, options.keepCount, options.cutoffTimestamp);
16
- }
17
- return this.#deleteTagTargets.list(scanId, options.deleteTags, options.excludeTags, options.useRegex ?? false, options.keepCount, options.cutoffTimestamp);
18
- }
19
- }
@@ -1,8 +0,0 @@
1
- import { type TaggedRootTargetOptions } from "./_planner-tagged-root-targets.js";
2
- import { PlannerSql } from "./_planner-sql.js";
3
- export declare class PlannerTaggedTargets {
4
- #private;
5
- constructor(sql: PlannerSql);
6
- listDeleteTagDirectTargetTags(scanId: number, deleteTags: string[], excludeTags: string[], useRegex: boolean, cutoffTimestamp?: string): string[];
7
- listTaggedDirectTargetRoots(scanId: number, options: TaggedRootTargetOptions): import("./_planner-types.js").DeletePlanRoot[];
8
- }
@@ -1,16 +0,0 @@
1
- import { PlannerDirectTargetTags } from "./_planner-direct-target-tags.js";
2
- import { PlannerTaggedRootTargets } from "./_planner-tagged-root-targets.js";
3
- export class PlannerTaggedTargets {
4
- #directTargetTags;
5
- #rootTargets;
6
- constructor(sql) {
7
- this.#directTargetTags = new PlannerDirectTargetTags(sql);
8
- this.#rootTargets = new PlannerTaggedRootTargets(sql);
9
- }
10
- listDeleteTagDirectTargetTags(scanId, deleteTags, excludeTags, useRegex, cutoffTimestamp) {
11
- return this.#directTargetTags.listDeleteTagDirectTargetTags(scanId, deleteTags, excludeTags, useRegex, cutoffTimestamp);
12
- }
13
- listTaggedDirectTargetRoots(scanId, options) {
14
- return this.#rootTargets.listTaggedDirectTargetRoots(scanId, options);
15
- }
16
- }
@@ -1,9 +0,0 @@
1
- import { PlannerSql } from "./_planner-sql.js";
2
- import { type DeletePlanRoot, type ScanRow } from "./_planner-types.js";
3
- export declare class PlannerUntaggedTargets {
4
- #private;
5
- constructor(sql: PlannerSql);
6
- getLatestCompletedScan(owner: string, packageName: string): ScanRow;
7
- listDeleteUntaggedDirectTargetRoots(scanId: number, cutoffTimestamp?: string): DeletePlanRoot[];
8
- listKeepNUntaggedDirectTargetRoots(scanId: number, keepCount: number, cutoffTimestamp?: string): DeletePlanRoot[];
9
- }
@@ -1,91 +0,0 @@
1
- import { mapPlanRootRow } from "./_planner-types.js";
2
- export class PlannerUntaggedTargets {
3
- #sql;
4
- constructor(sql) {
5
- this.#sql = sql;
6
- }
7
- getLatestCompletedScan(owner, packageName) {
8
- const sql = `
9
- SELECT scan_id, owner, package_name, scan_completed_at
10
- FROM v_latest_scan_per_package
11
- WHERE owner = ?
12
- AND package_name = ?
13
- LIMIT 1
14
- `;
15
- const row = this.#sql.get(sql, [owner, packageName]);
16
- if (!row) {
17
- throw new Error(`database does not contain completed package scan for ${owner}/${packageName}`);
18
- }
19
- return row;
20
- }
21
- listDeleteUntaggedDirectTargetRoots(scanId, cutoffTimestamp) {
22
- const cutoffSql = cutoffTimestamp ? "AND created_at < ?" : "";
23
- const sql = `
24
- SELECT
25
- root_version_id AS version_id,
26
- root_digest,
27
- root_manifest_kind,
28
- 'delete-untagged' AS direct_target_reason,
29
- 'delete-root' AS selection_mode
30
- FROM v_scan_root_manifests
31
- WHERE scan_id = ?
32
- AND is_tagged = 0
33
- AND has_ancestor = 0
34
- ${cutoffSql}
35
- ORDER BY root_digest
36
- `;
37
- const rows = this.#sql.all(sql, [
38
- scanId,
39
- ...(cutoffTimestamp ? [cutoffTimestamp] : [])
40
- ]);
41
- return rows.map(mapPlanRootRow);
42
- }
43
- listKeepNUntaggedDirectTargetRoots(scanId, keepCount, cutoffTimestamp) {
44
- const cutoffSql = cutoffTimestamp ? "AND pv.created_at < ?" : "";
45
- const sql = `
46
- WITH eligible_untagged_roots AS (
47
- SELECT
48
- pv.version_id AS version_id,
49
- m.digest AS root_digest,
50
- m.manifest_kind AS root_manifest_kind,
51
- ROW_NUMBER() OVER (
52
- ORDER BY pv.created_at DESC, pv.version_id DESC, m.digest DESC
53
- ) AS recency_rank
54
- FROM package_versions pv
55
- JOIN manifests m
56
- ON m.scan_id = pv.scan_id
57
- AND m.version_id = pv.version_id
58
- WHERE pv.scan_id = ?
59
- AND NOT EXISTS (
60
- SELECT 1
61
- FROM tags t
62
- WHERE t.scan_id = pv.scan_id
63
- AND t.version_id = pv.version_id
64
- )
65
- AND NOT EXISTS (
66
- SELECT 1
67
- FROM manifest_reachability mr
68
- WHERE mr.scan_id = pv.scan_id
69
- AND mr.descendant_digest = m.digest
70
- AND mr.min_distance > 0
71
- )
72
- ${cutoffSql}
73
- )
74
- SELECT
75
- version_id,
76
- root_digest,
77
- root_manifest_kind,
78
- 'keep-n-untagged-overflow' AS direct_target_reason,
79
- 'delete-root' AS selection_mode
80
- FROM eligible_untagged_roots
81
- WHERE recency_rank > ?
82
- ORDER BY root_digest
83
- `;
84
- const rows = this.#sql.all(sql, [
85
- scanId,
86
- ...(cutoffTimestamp ? [cutoffTimestamp] : []),
87
- keepCount
88
- ]);
89
- return rows.map(mapPlanRootRow);
90
- }
91
- }