ghcr-manager 0.9.5 → 0.9.7

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 (53) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +14 -13
  3. package/dist/cleanup-summary/_cleanup-summary-markdown.d.ts +0 -1
  4. package/dist/cleanup-summary/_cleanup-summary-markdown.js +146 -32
  5. package/dist/cleanup-summary/_cleanup-summary.d.ts +26 -9
  6. package/dist/cleanup-summary/_cleanup-summary.js +27 -7
  7. package/dist/cleanup-summary/index.d.ts +1 -1
  8. package/dist/cli/_cleanup-command.js +84 -5
  9. package/dist/cli/_json-output.d.ts +1 -0
  10. package/dist/cli/_json-output.js +11 -0
  11. package/dist/cli/_tag-selector-resolver.js +9 -6
  12. package/dist/cli/_untag-command.js +2 -1
  13. package/dist/cli/index.js +2 -2
  14. package/dist/core/_digest-tag.d.ts +2 -0
  15. package/dist/core/_digest-tag.js +12 -0
  16. package/dist/core/_types.d.ts +11 -2
  17. package/dist/core/_types.js +8 -1
  18. package/dist/core/index.d.ts +3 -1
  19. package/dist/core/index.js +2 -0
  20. package/dist/db/_cleanup-run-writer.d.ts +1 -1
  21. package/dist/db/_cleanup-run-writer.js +90 -50
  22. package/dist/db/_db-merge-cleanup-copy.js +130 -80
  23. package/dist/db/_db-merge-scan-copy.js +1 -1
  24. package/dist/db/_manifest-kind-refinement.d.ts +2 -0
  25. package/dist/db/_manifest-kind-refinement.js +43 -0
  26. package/dist/db/_manifest-reachability.js +25 -0
  27. package/dist/db/_scan-writer.js +122 -117
  28. package/dist/db/index.d.ts +2 -1
  29. package/dist/db/index.js +1 -0
  30. package/dist/db/planner/_planner-direct-target-roots.d.ts +1 -0
  31. package/dist/db/planner/_planner-direct-target-roots.js +24 -11
  32. package/dist/db/planner/_planner-direct-target-tags.d.ts +1 -1
  33. package/dist/db/planner/_planner-direct-target-tags.js +8 -1
  34. package/dist/db/planner/_planner-output.d.ts +1 -1
  35. package/dist/db/planner/_planner-output.js +7 -17
  36. package/dist/db/planner/_planner-repository.d.ts +2 -1
  37. package/dist/db/planner/_planner-repository.js +3 -1
  38. package/dist/db/planner/_planner-types.d.ts +33 -26
  39. package/dist/db/planner/_planner-types.js +13 -3
  40. package/dist/db/planner/index.d.ts +2 -1
  41. package/dist/db/planner/index.js +1 -0
  42. package/dist/execute/_plan-executor.d.ts +1 -1
  43. package/dist/execute/_plan-executor.js +35 -9
  44. package/dist/ingest/github/_manifest-kind.d.ts +1 -1
  45. package/dist/ingest/github/_manifest-kind.js +6 -5
  46. package/package.json +1 -1
  47. package/resources/sql/schema/001_schema.sql +24 -1
  48. package/resources/sql/views/{003_v_scan_root_manifests.sql → 002_v_scan_root_manifests.sql} +1 -0
  49. package/resources/sql/views/{004_v_digest_derived_tag_relations.sql → 003_v_digest_tag_relations.sql} +7 -8
  50. package/resources/sql/views/{005_v_cleanup_root_closure_members.sql → 004_v_cleanup_root_closure_members.sql} +2 -1
  51. package/resources/sql/views/006_v_cleanup_root_decision_readable.sql +67 -0
  52. package/resources/sql/views/002_v_missing_digests.sql +0 -32
  53. /package/resources/sql/views/{006_v_cleanup_blocking_overlaps.sql → 005_v_cleanup_blocking_overlaps.sql} +0 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ 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.7] - 2026-05-23
11
+
12
+ ### Added
13
+
14
+ - The root action now prepares `cleanup` and `untag` CLI arguments through `tools/prepare-action-args.mjs`, keeping
15
+ printed and executed argument lists aligned.
16
+ - Cleanup planning now traverses recursively beyond `sha256-*` helper-tag manifest links as well, if deeper helper
17
+ chains ever occur.
18
+
19
+ ### Changed
20
+
21
+ - Cleanup dry-run output and GitHub step summaries were reworked to explain the plan more clearly, including a filters
22
+ table and clearer counts for tags, images, and cross-arch manifests.
23
+ - Informational manifest classification was tuned so only real multi-arch roots are labeled `cross_arch_manifest`, while
24
+ helper-tagged indexes remain `index_manifest`.
25
+ - `merge-run-artifacts` now uses a simpler current-run download flow with direct artifact download handling.
26
+ - Cleanup selected-tag audit and DB-merge metadata handling were tightened alongside the summary/output refactor.
27
+
28
+ ### Fixed
29
+
30
+ - `delete-orphaned-images` now carries orphaned `sha256-*` digest-tag targets through planner selection instead of
31
+ dropping them at the normal non-digest tag boundary.
32
+ - Fully deletable cleanup execution now deletes the planned closure package versions instead of deleting only the root
33
+ package version.
34
+
35
+ ## [0.9.6] - 2026-05-21
36
+
37
+ ### Added
38
+
39
+ - Cleanup audit now persists concrete selected tags in `cleanup_selected_tags`.
40
+ - Cleanup schema docs now include a readable cleanup-decision view plus example SQL queries for audit inspection.
41
+ - GHCR digest-tag helper relations are now modeled explicitly in scan data and manifest reachability.
42
+
43
+ ### Changed
44
+
45
+ - Cleanup summary JSON now exposes derived affected manifests for fully deletable roots.
46
+ - Cleanup Markdown now reads displayed counts from the summary arrays instead of carrying duplicate count fields.
47
+ - Cleanup decision audit fields are now constrained more tightly in SQLite and TypeScript, including `selection_mode`,
48
+ `selection_reason`, and related block reason codes.
49
+ - Digest-tag helper artifacts are now classified on `tags.is_digest_tag` and excluded from normal user-facing tag
50
+ selection and output.
51
+ - Digest-tag helper terminology was simplified across code and SQL surfaces.
52
+ - Schema docs now include a table of contents and collapsible example query blocks for easier GitHub browsing.
53
+
54
+ ### Fixed
55
+
56
+ - Fixed remote action path handling for artifact upload and merge helper actions.
57
+ - Cleanup reachability now follows digest-tag helper edges recursively, matching helper-artifact cascades more closely.
58
+
10
59
  ## [0.9.5] - 2026-05-21
11
60
 
12
61
  ### 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.5
36
+ uses: gh-workflow/ghcr-manager@0.9.7
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.5
75
+ - uses: gh-workflow/ghcr-manager@0.9.7
76
76
  with:
77
77
  command: cleanup
78
78
  token: ${{ github.token }}
@@ -82,7 +82,7 @@ The action supports three commands:
82
82
  delete-tags: |
83
83
  pr-.*
84
84
  use-regex: true
85
- older-than: 30d
85
+ older-than: 30 days
86
86
  keep-n-tagged: "5"
87
87
  exclude-tags: |
88
88
  latest
@@ -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.5
96
+ - uses: gh-workflow/ghcr-manager@0.9.7
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.5
115
+ - uses: gh-workflow/ghcr-manager@0.9.7
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.5
131
+ - uses: gh-workflow/ghcr-manager@0.9.7
132
132
  with:
133
133
  command: scan
134
134
  token: ${{ github.token }}
@@ -169,11 +169,14 @@ 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:
172
+ Cleanup command notes:
173
173
 
174
174
  - Tagged selector families may be combined with `delete-untagged`.
175
175
  - `exclude-tags` requires at least one tagged selector family.
176
176
  - `delete-untagged` and `keep-n-untagged` cannot be combined.
177
+ - `older-than` takes one integer plus one unit.
178
+ - Supported `older-than` units: `minutes`, `hours`, `days`, `weeks`, `months`, `years`.
179
+ - Example values: `30 days`, `2 hours`, `1 month`.
177
180
 
178
181
  ## Outputs
179
182
 
@@ -199,12 +202,10 @@ Current naming:
199
202
 
200
203
  ## Documentation Map
201
204
 
202
- - [docs/action-usage.md](docs/action-usage.md): action commands, including `cleanup`, `scan`, and `untag`
203
- - [docs/db-merge-workflows.md](docs/db-merge-workflows.md): cleaning up multiple packages with one combined DB
204
- - [docs/schema-description.md](docs/schema-description.md): practical explanation of the SQLite schema
205
- - [docs/queries/missing-manifests-queries.md](docs/queries/missing-manifests-queries.md): SQL recipes for missing
206
- manifest references
207
- - [docs/cli-usage.md](docs/cli-usage.md): companion CLI usage
205
+ - [GitHub Action usage](docs/action-usage.md): action commands, including `cleanup`, `scan`, and `untag`
206
+ - [Multi-package workflows](docs/db-merge-workflows.md): cleaning up multiple packages with one combined DB
207
+ - [SQLite schema guide](docs/schema-description.md): practical explanation of the SQLite schema
208
+ - [CLI usage](docs/cli-usage.md): companion CLI usage
208
209
 
209
210
  ## Acknowledgment
210
211
 
@@ -2,5 +2,4 @@ import type { CleanupSummary } from "./_cleanup-summary.js";
2
2
  export declare function renderCleanupSummaryMarkdown(summary: CleanupSummary, options: {
3
3
  maxDirectTargetTags?: number;
4
4
  maxRootsPerSection?: number;
5
- maxTagsPerRoot?: number;
6
5
  }): string;
@@ -1,10 +1,11 @@
1
+ import { ManifestKinds } from "../core/index.js";
2
+ import { DeletePlanValidationStatuses } from "../db/index.js";
1
3
  const _DEFAULT_MAX_DIRECT_TARGET_TAGS = 100;
2
4
  const _DEFAULT_MAX_ROOTS_PER_SECTION = 100;
3
- const _DEFAULT_MAX_TAGS_PER_ROOT = 4;
5
+ const _DEFAULT_MAX_TAG_TEXT_LENGTH = 40;
4
6
  export function renderCleanupSummaryMarkdown(summary, options) {
5
7
  const maxDirectTargetTags = options.maxDirectTargetTags ?? _DEFAULT_MAX_DIRECT_TARGET_TAGS;
6
8
  const maxRootsPerSection = options.maxRootsPerSection ?? _DEFAULT_MAX_ROOTS_PER_SECTION;
7
- const maxTagsPerRoot = options.maxTagsPerRoot ?? _DEFAULT_MAX_TAGS_PER_ROOT;
8
9
  const lines = [
9
10
  "## Cleanup Summary",
10
11
  "",
@@ -12,30 +13,62 @@ export function renderCleanupSummaryMarkdown(summary, options) {
12
13
  "| --- | --- |",
13
14
  `| 📦 Package | \`${_escapeInlineCode(`${summary.owner}/${summary.packageName}`)}\` |`,
14
15
  `| ⚙️ Mode | ${summary.dryRun ? "Cleanup dry-run" : "Cleanup"} |`,
15
- `| 🏷️ Matched tags | ${summary.validationSummary.directTargetTagCount} |`,
16
- `| 🗑️ Fully deletable roots | ${summary.validationSummary.fullyDeletableRootCount} |`,
17
- `| 🔗 Untag-only roots | ${summary.validationSummary.untagOnlyRootCount} |`,
18
- `| 🛡️ Blocked roots | ${summary.validationSummary.blockedDeleteRootCount} |`,
16
+ `| 🏷️ Selected tags | ${summary.directTargetTags.length} |`,
17
+ `| 🔖 Deleted tags | ${summary.changes.deletedTags} |`,
18
+ `| 🖼️ Deleted images | ${summary.changes.deletedImages} |`,
19
+ `| 📚 Deleted cross-arch manifests | ${summary.changes.deletedCrossArchManifests} |`,
20
+ `| 🧱 Deleted indexes | ${summary.changes.deletedIndexes} |`,
21
+ `| 📄 Deleted total | ${summary.changes.deletedTotal} |`,
22
+ `| 🔗 Tag-only updates | ${summary.untagOnlyRoots.length} |`,
23
+ `| 🛡️ Blocked items | ${summary.blockedRoots.length} |`,
19
24
  ""
20
25
  ];
21
- lines.push(..._renderJsonDetails("⚙️ Cleanup filter", summary.plannerInputs));
26
+ lines.push(..._renderPlannedDeleteBreakdown(summary));
27
+ lines.push(..._renderPlannerInputs(summary.plannerInputs));
22
28
  lines.push(..._renderDirectTargetTags(summary.directTargetTags, maxDirectTargetTags));
23
- lines.push(..._renderRootSection("🗑️ Fully deletable roots", summary.fullyDeletableRoots, maxRootsPerSection, maxTagsPerRoot));
24
- lines.push(..._renderRootSection("🔗 Untag-only roots", summary.untagOnlyRoots, maxRootsPerSection, maxTagsPerRoot));
25
- lines.push(..._renderRootSection("🛡️ Blocked roots", summary.blockedRoots, maxRootsPerSection, maxTagsPerRoot));
29
+ lines.push(..._renderRootSection("🗑️ Deleted items", summary.fullyDeletableRoots, maxRootsPerSection));
30
+ lines.push(..._renderRootSection("🔗 Tags removed only", summary.untagOnlyRoots, maxRootsPerSection));
31
+ lines.push(..._renderRootSection("🛡️ Blocked items", summary.blockedRoots, maxRootsPerSection));
26
32
  if (!summary.dryRun && (summary.deletedPackageVersions.length > 0 || summary.untaggedTags.length > 0)) {
27
33
  lines.push(..._renderLiveEffects(summary));
28
34
  }
29
35
  return `${lines.join("\n").trimEnd()}\n`;
30
36
  }
31
- function _renderJsonDetails(title, value) {
37
+ function _renderPlannedDeleteBreakdown(summary) {
38
+ const rows = [
39
+ { label: "Images", count: summary.changes.deletedImages },
40
+ { label: "Cross-arch manifests", count: summary.changes.deletedCrossArchManifests },
41
+ { label: "Artifact manifests", count: summary.changes.deletedArtifactManifests },
42
+ { label: "Signatures", count: summary.changes.deletedSignatures },
43
+ { label: "Attestations", count: summary.changes.deletedAttestations },
44
+ { label: "Generic indexes", count: summary.changes.deletedIndexes }
45
+ ].filter((row) => row.count > 0);
46
+ if (rows.length === 0) {
47
+ return [];
48
+ }
49
+ return [
50
+ "<details>",
51
+ "<summary>📦 Deleted item breakdown</summary>",
52
+ "",
53
+ "| Type | Count |",
54
+ "| --- | --- |",
55
+ ...rows.map((row) => `| ${row.label} | ${row.count} |`),
56
+ "",
57
+ "</details>",
58
+ ""
59
+ ];
60
+ }
61
+ function _renderPlannerInputs(plannerInputs) {
62
+ const rows = _getPlannerInputRows(plannerInputs);
63
+ const patternLines = _getPlannerPatternLines(plannerInputs);
32
64
  return [
33
- `<details>`,
34
- `<summary>${title}</summary>`,
65
+ "<details>",
66
+ "<summary>⚙️ Cleanup filter</summary>",
35
67
  "",
36
- "```json",
37
- JSON.stringify(value, null, 2),
38
- "```",
68
+ "| Filter | Value |",
69
+ "| --- | --- |",
70
+ ...(rows.length > 0 ? rows : ["| (none) | No cleanup filters recorded |"]),
71
+ ...(patternLines.length > 0 ? ["", ...patternLines] : []),
39
72
  "",
40
73
  "</details>",
41
74
  ""
@@ -46,23 +79,24 @@ function _renderDirectTargetTags(tags, maxDirectTargetTags) {
46
79
  return [];
47
80
  }
48
81
  const visibleTags = tags.slice(0, maxDirectTargetTags).map((tag) => `- \`${_escapeInlineCode(tag)}\``);
49
- const lines = ["<details>", "<summary>🏷️ Matched tags</summary>", "", ...visibleTags];
82
+ const lines = ["<details>", "<summary>🏷️ Selected tags</summary>", "", ...visibleTags];
50
83
  if (tags.length > maxDirectTargetTags) {
51
- lines.push("", `_Showing first ${maxDirectTargetTags} of ${tags.length} matched tags._`);
84
+ lines.push("", `_Showing first ${maxDirectTargetTags} of ${tags.length} selected tags._`);
52
85
  }
53
86
  lines.push("", "</details>", "");
54
87
  return lines;
55
88
  }
56
- function _renderRootSection(title, roots, maxRootsPerSection, maxTagsPerRoot) {
89
+ function _renderRootSection(title, roots, maxRootsPerSection) {
57
90
  if (roots.length === 0) {
58
91
  return [];
59
92
  }
60
93
  const lines = ["<details>", `<summary>${title}</summary>`, ""];
61
- lines.push("| Version | Digest | Tags | Reason |");
62
- lines.push("| --- | --- | --- | --- |");
94
+ lines.push("| Version | Type | Digest | Tags | Outcome |");
95
+ lines.push("| --- | --- | --- | --- | --- |");
63
96
  for (const root of roots.slice(0, maxRootsPerSection)) {
64
- lines.push(`| ${root.versionId} | \`${_escapeInlineCode(_shortDigest(root.digest))}\` | ${_escapeMarkdown(_formatTags(root, maxTagsPerRoot))} | ${_escapeMarkdown(_formatReason(root))} |`);
97
+ lines.push(`| ${root.versionId} | ${_escapeMarkdown(_describeManifestKind(root.manifestKind))} | \`${_escapeInlineCode(_shortDigest(root.digest))}\` | ${_escapeMarkdown(_formatTags(root))} | ${_escapeMarkdown(_formatReason(root))} |`);
65
98
  }
99
+ lines.push("", "_Tag lists may be truncated for table width._");
66
100
  if (roots.length > maxRootsPerSection) {
67
101
  lines.push("", `_Showing first ${maxRootsPerSection} of ${roots.length} ${title.toLowerCase()}._`);
68
102
  }
@@ -79,25 +113,27 @@ function _renderLiveEffects(summary) {
79
113
  lines.push("");
80
114
  return lines;
81
115
  }
82
- function _formatTags(root, maxTagsPerRoot) {
116
+ function _formatTags(root) {
83
117
  const tags = root.rootTags.length > 0 ? root.rootTags : root.matchedTags;
84
118
  if (tags.length === 0) {
85
119
  return "(untagged)";
86
120
  }
87
- const visible = tags.slice(0, maxTagsPerRoot);
88
- const suffix = tags.length > maxTagsPerRoot ? `, +${tags.length - maxTagsPerRoot} more` : "";
89
- return visible.join(", ") + suffix;
121
+ const joinedTags = tags.join(", ");
122
+ if (joinedTags.length <= _DEFAULT_MAX_TAG_TEXT_LENGTH) {
123
+ return joinedTags;
124
+ }
125
+ return `${joinedTags.slice(0, _DEFAULT_MAX_TAG_TEXT_LENGTH - 3)}...`;
90
126
  }
91
127
  function _formatReason(root) {
92
- if (root.validationStatus === "blocked") {
93
- const blocking = root.blockingDigest ? _shortDigest(root.blockingDigest) : "another root";
128
+ if (root.validationStatus === DeletePlanValidationStatuses.blocked) {
129
+ const blocking = root.blockingDigest ? _shortDigest(root.blockingDigest) : "another item";
94
130
  const overlap = root.overlapDigest ? ` via ${_shortDigest(root.overlapDigest)}` : "";
95
- return `Blocked by ${blocking}${overlap}`;
131
+ return `Blocked by retained item ${blocking}${overlap}`;
96
132
  }
97
- if (root.validationStatus === "untag-only") {
98
- return "Selected tags detach; root remains";
133
+ if (root.validationStatus === DeletePlanValidationStatuses.untagOnly) {
134
+ return "Remove selected tags, keep item";
99
135
  }
100
- return "Root and closure can be deleted";
136
+ return "Delete item and descendants";
101
137
  }
102
138
  function _shortDigest(value) {
103
139
  if (!value.startsWith("sha256:") || value.length <= 20) {
@@ -111,3 +147,81 @@ function _escapeInlineCode(value) {
111
147
  function _escapeMarkdown(value) {
112
148
  return value.replaceAll("|", "\\|").replaceAll("\n", " ");
113
149
  }
150
+ function _getPlannerInputRows(plannerInputs) {
151
+ const rows = [];
152
+ for (const [key, value] of Object.entries(plannerInputs)) {
153
+ rows.push(`| ${_escapeMarkdown(_plannerInputLabel(key))} | ${_escapeMarkdown(_formatPlannerInputValue(value))} |`);
154
+ }
155
+ return rows;
156
+ }
157
+ function _plannerInputLabel(key) {
158
+ switch (key) {
159
+ case "deleteTags":
160
+ return "Delete tags";
161
+ case "excludeTags":
162
+ return "Exclude tags";
163
+ case "useRegex":
164
+ return "Use regex";
165
+ case "deleteUntagged":
166
+ return "Delete untagged";
167
+ case "keepNTagged":
168
+ return "Keep newest tagged";
169
+ case "keepNUntagged":
170
+ return "Keep newest untagged";
171
+ case "olderThan":
172
+ return "Older than";
173
+ case "cutoffTimestamp":
174
+ return "Cutoff timestamp";
175
+ case "deleteGhostImages":
176
+ return "Delete ghost images";
177
+ case "deletePartialImages":
178
+ return "Delete partial images";
179
+ case "deleteOrphanedImages":
180
+ return "Delete orphaned images";
181
+ default:
182
+ return key;
183
+ }
184
+ }
185
+ function _formatPlannerInputValue(value) {
186
+ if (Array.isArray(value)) {
187
+ if (value.length === 0) {
188
+ return "(none)";
189
+ }
190
+ return value.length === 1 ? "1 pattern" : `${value.length} patterns`;
191
+ }
192
+ if (typeof value === "boolean") {
193
+ return value ? "yes" : "no";
194
+ }
195
+ return String(value);
196
+ }
197
+ function _getPlannerPatternLines(plannerInputs) {
198
+ const lines = [];
199
+ for (const [key, value] of Object.entries(plannerInputs)) {
200
+ if (!Array.isArray(value) || value.length === 0) {
201
+ continue;
202
+ }
203
+ lines.push(`- ${_plannerInputLabel(key)}:`);
204
+ for (const item of value) {
205
+ lines.push(` - \`${_escapeInlineCode(String(item))}\``);
206
+ }
207
+ }
208
+ return lines;
209
+ }
210
+ function _describeManifestKind(manifestKind) {
211
+ switch (manifestKind) {
212
+ case ManifestKinds.imageManifest:
213
+ return "image";
214
+ case ManifestKinds.crossArchManifest:
215
+ return "cross-arch";
216
+ case ManifestKinds.indexManifest:
217
+ return "index";
218
+ case ManifestKinds.signatureManifest:
219
+ return "signature";
220
+ case ManifestKinds.attestationManifest:
221
+ return "attestation";
222
+ case ManifestKinds.artifactManifest:
223
+ return "artifact";
224
+ default:
225
+ return "item";
226
+ }
227
+ }
@@ -1,20 +1,35 @@
1
- import type { DeletePlan } from "../db/index.js";
1
+ import type { ManifestKind } from "../core/index.js";
2
+ import type { DeletePlan, DeletePlanSelectionMode, DeletePlanSelectionReason, DeletePlanValidationReasonCode, DeletePlanValidationStatus } from "../db/index.js";
2
3
  import type { DeleteExecutionSummary } from "../execute/index.js";
3
4
  export interface CleanupSummaryRoot {
4
5
  versionId: number;
5
6
  digest: string;
6
- manifestKind?: string;
7
+ manifestKind?: ManifestKind;
7
8
  rootTags: string[];
8
9
  matchedTags: string[];
9
- selectionMode: string;
10
- selectionReason: string;
11
- validationStatus: "fully-deletable" | "blocked" | "untag-only";
12
- validationReasonCode: "untag-only-partial-tag-match" | "fully-deletable-no-retained-overlap" | "blocked-overlap-with-retained-root";
10
+ selectionMode: DeletePlanSelectionMode;
11
+ selectionReason: DeletePlanSelectionReason;
12
+ validationStatus: DeletePlanValidationStatus;
13
+ validationReasonCode: DeletePlanValidationReasonCode;
13
14
  validationReason: string;
14
15
  blockingVersionId?: number;
15
16
  blockingDigest?: string;
16
17
  overlapDigest?: string;
17
- overlapManifestKind?: string;
18
+ overlapManifestKind?: ManifestKind;
19
+ }
20
+ export interface CleanupSummaryAffectedManifest {
21
+ digest: string;
22
+ manifestKind?: ManifestKind;
23
+ }
24
+ export interface CleanupSummaryChanges {
25
+ deletedTags: number;
26
+ deletedImages: number;
27
+ deletedIndexes: number;
28
+ deletedCrossArchManifests: number;
29
+ deletedArtifactManifests: number;
30
+ deletedAttestations: number;
31
+ deletedSignatures: number;
32
+ deletedTotal: number;
18
33
  }
19
34
  export interface CleanupSummary {
20
35
  command: "cleanup";
@@ -23,18 +38,20 @@ export interface CleanupSummary {
23
38
  scanCompletedAt: string;
24
39
  dryRun: boolean;
25
40
  plannerInputs: DeletePlan["plannerInputs"];
26
- validationSummary: DeletePlan["validationSummary"];
27
41
  directTargetTags: string[];
28
42
  collateralTags: string[];
29
43
  fullyDeletableRoots: CleanupSummaryRoot[];
30
44
  untagOnlyRoots: CleanupSummaryRoot[];
31
45
  blockedRoots: CleanupSummaryRoot[];
46
+ affectedManifests: CleanupSummaryAffectedManifest[];
47
+ changes: CleanupSummaryChanges;
32
48
  deletedPackageVersions: DeleteExecutionSummary["deletedPackageVersions"];
33
49
  untaggedTags: DeleteExecutionSummary["untaggedTags"];
34
50
  unsupportedUntagRoots: DeleteExecutionSummary["unsupportedUntagRoots"];
35
51
  }
36
52
  export declare function buildCleanupSummary(plan: DeletePlan, options: {
37
53
  dryRun: boolean;
38
- listRootTags: (versionId: number) => string[];
54
+ rootTagsByVersionId: ReadonlyMap<number, string[]>;
55
+ changes: CleanupSummaryChanges;
39
56
  executionSummary?: DeleteExecutionSummary;
40
57
  }): CleanupSummary;
@@ -1,6 +1,11 @@
1
+ import { DeletePlanValidationStatuses } from "../db/index.js";
1
2
  export function buildCleanupSummary(plan, options) {
2
3
  const directTargetTagSet = new Set(plan.directTargetTags);
3
- const roots = plan.rootDecisions.map((decision) => _mapRootDecision(decision, directTargetTagSet, options.listRootTags));
4
+ const roots = plan.rootDecisions.map((decision) => _mapRootDecision(decision, directTargetTagSet, options.rootTagsByVersionId));
5
+ const fullyDeletableRoots = roots.filter((root) => root.validationStatus === DeletePlanValidationStatuses.fullyDeletable);
6
+ const blockedRoots = roots.filter((root) => root.validationStatus === DeletePlanValidationStatuses.blocked);
7
+ const untagOnlyRoots = roots.filter((root) => root.validationStatus === DeletePlanValidationStatuses.untagOnly);
8
+ const affectedManifests = _listAffectedManifests(plan, fullyDeletableRoots.map((root) => root.digest));
4
9
  return {
5
10
  command: "cleanup",
6
11
  owner: plan.owner,
@@ -8,19 +13,20 @@ export function buildCleanupSummary(plan, options) {
8
13
  scanCompletedAt: plan.scanCompletedAt,
9
14
  dryRun: options.dryRun,
10
15
  plannerInputs: plan.plannerInputs,
11
- validationSummary: plan.validationSummary,
12
16
  directTargetTags: plan.directTargetTags,
13
17
  collateralTags: plan.collateralTags,
14
- fullyDeletableRoots: roots.filter((root) => root.validationStatus === "fully-deletable"),
15
- untagOnlyRoots: roots.filter((root) => root.validationStatus === "untag-only"),
16
- blockedRoots: roots.filter((root) => root.validationStatus === "blocked"),
18
+ fullyDeletableRoots,
19
+ untagOnlyRoots,
20
+ blockedRoots,
21
+ affectedManifests,
22
+ changes: options.changes,
17
23
  deletedPackageVersions: options.executionSummary?.deletedPackageVersions ?? [],
18
24
  untaggedTags: options.executionSummary?.untaggedTags ?? [],
19
25
  unsupportedUntagRoots: options.executionSummary?.unsupportedUntagRoots ?? []
20
26
  };
21
27
  }
22
- function _mapRootDecision(decision, directTargetTagSet, listRootTags) {
23
- const rootTags = listRootTags(decision.versionId);
28
+ function _mapRootDecision(decision, directTargetTagSet, rootTagsByVersionId) {
29
+ const rootTags = rootTagsByVersionId.get(decision.versionId) ?? [];
24
30
  return {
25
31
  versionId: decision.versionId,
26
32
  digest: decision.digest,
@@ -38,3 +44,17 @@ function _mapRootDecision(decision, directTargetTagSet, listRootTags) {
38
44
  overlapManifestKind: decision.overlapManifestKind
39
45
  };
40
46
  }
47
+ function _listAffectedManifests(plan, fullyDeletableRootDigests) {
48
+ const fullyDeletableRootDigestSet = new Set(fullyDeletableRootDigests);
49
+ const manifestsByDigest = new Map();
50
+ for (const manifest of plan.closureManifests) {
51
+ if (!fullyDeletableRootDigestSet.has(manifest.sourceDigest)) {
52
+ continue;
53
+ }
54
+ manifestsByDigest.set(manifest.memberDigest, {
55
+ digest: manifest.memberDigest,
56
+ manifestKind: manifest.memberManifestKind
57
+ });
58
+ }
59
+ return [...manifestsByDigest.values()].sort((left, right) => left.digest.localeCompare(right.digest));
60
+ }
@@ -1,2 +1,2 @@
1
- export { buildCleanupSummary, type CleanupSummary, type CleanupSummaryRoot } from "./_cleanup-summary.js";
1
+ export { buildCleanupSummary, type CleanupSummary, type CleanupSummaryAffectedManifest, type CleanupSummaryChanges, type CleanupSummaryRoot } from "./_cleanup-summary.js";
2
2
  export { renderCleanupSummaryMarkdown } from "./_cleanup-summary-markdown.js";
@@ -1,7 +1,9 @@
1
+ import { ManifestKinds } from "../core/index.js";
1
2
  import { buildCleanupSummary } from "../cleanup-summary/index.js";
2
3
  import { CleanupRunWriter, openDatabase, PlannerRepository } from "../db/index.js";
3
4
  import { executeDeletePlan } from "../execute/index.js";
4
5
  import { hasFlag, resolveLogLevel, resolveToken } from "./_args.js";
6
+ import { writeJsonOutput } from "./_json-output.js";
5
7
  import { createLogger } from "./_logger.js";
6
8
  import { loadDeletePlan, resolvePlanCommandInputs } from "./_planner-options.js";
7
9
  import { resolveTagSelectors } from "./_tag-selector-resolver.js";
@@ -14,19 +16,22 @@ export async function handleCleanup(args) {
14
16
  try {
15
17
  const repository = new PlannerRepository(database, logger);
16
18
  const cleanupRunWriter = new CleanupRunWriter(database);
19
+ const scanId = repository.getLatestCompletedScanId(inputs.owner, inputs.packageName);
17
20
  logger.debug(`Starting cleanup for ${inputs.owner}/${inputs.packageName}`);
18
21
  const plan = loadDeletePlan(repository, resolveTagSelectors(database, inputs));
19
- cleanupRunWriter.persistCleanupRun(repository.getLatestCompletedScanId(inputs.owner, inputs.packageName), plan, {
22
+ const rootTagsByVersionId = _loadRootTagsByVersionId(database, inputs.owner, inputs.packageName, plan.rootDecisions.map((decision) => decision.versionId));
23
+ const cleanupRunId = cleanupRunWriter.persistCleanupRun(scanId, plan, {
20
24
  dryRun,
21
25
  cleanupStartedAt: new Date().toISOString()
22
26
  });
23
27
  if (dryRun) {
24
28
  const summary = buildCleanupSummary(plan, {
25
29
  dryRun: true,
26
- listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId)
30
+ rootTagsByVersionId,
31
+ changes: _loadSummaryChanges(database, cleanupRunId)
27
32
  });
28
33
  logger.debug(`Completed dry-run cleanup for ${inputs.owner}/${inputs.packageName}`);
29
- console.log(JSON.stringify(summary));
34
+ writeJsonOutput(args, "--summary-json-path", summary);
30
35
  return 0;
31
36
  }
32
37
  const executionSummary = await executeDeletePlan(plan, {
@@ -36,11 +41,12 @@ export async function handleCleanup(args) {
36
41
  });
37
42
  const summary = buildCleanupSummary(plan, {
38
43
  dryRun: false,
39
- listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId),
44
+ rootTagsByVersionId,
45
+ changes: _loadSummaryChanges(database, cleanupRunId),
40
46
  executionSummary
41
47
  });
42
48
  logger.debug(`Completed cleanup for ${inputs.owner}/${inputs.packageName}`);
43
- console.log(JSON.stringify(summary));
49
+ writeJsonOutput(args, "--summary-json-path", summary);
44
50
  return 0;
45
51
  }
46
52
  finally {
@@ -56,8 +62,81 @@ function _listRootTags(database, owner, packageName, versionId) {
56
62
  WHERE latest_scan.owner = ?
57
63
  AND latest_scan.package_name = ?
58
64
  AND tags.version_id = ?
65
+ AND tags.is_digest_tag = 0
59
66
  ORDER BY tags.tag
60
67
  `)
61
68
  .all(owner, packageName, versionId);
62
69
  return rows.map((row) => row.tag);
63
70
  }
71
+ function _loadRootTagsByVersionId(database, owner, packageName, versionIds) {
72
+ const requestedVersionIds = new Set(versionIds);
73
+ const tagsByVersionId = new Map();
74
+ for (const versionId of requestedVersionIds) {
75
+ tagsByVersionId.set(versionId, []);
76
+ }
77
+ if (requestedVersionIds.size === 0) {
78
+ return tagsByVersionId;
79
+ }
80
+ const rows = database
81
+ .prepare(`
82
+ SELECT tags.version_id, tags.tag
83
+ FROM tags
84
+ INNER JOIN v_latest_scan_per_package latest_scan ON latest_scan.scan_id = tags.scan_id
85
+ WHERE latest_scan.owner = ?
86
+ AND latest_scan.package_name = ?
87
+ AND tags.is_digest_tag = 0
88
+ ORDER BY tags.version_id, tags.tag
89
+ `)
90
+ .all(owner, packageName);
91
+ for (const row of rows) {
92
+ if (!requestedVersionIds.has(row.version_id)) {
93
+ continue;
94
+ }
95
+ tagsByVersionId.get(row.version_id)?.push(row.tag);
96
+ }
97
+ return tagsByVersionId;
98
+ }
99
+ function _loadSummaryChanges(database, cleanupRunId) {
100
+ const deletedTags = database
101
+ .prepare(`
102
+ SELECT COUNT(*) AS count
103
+ FROM cleanup_selected_tags
104
+ WHERE cleanup_run_id = ?
105
+ AND is_deleted = 1
106
+ `)
107
+ .get(cleanupRunId).count;
108
+ const manifestCounts = database
109
+ .prepare(`
110
+ WITH fully_deletable_manifests AS (
111
+ SELECT DISTINCT
112
+ reachable.descendant_digest AS digest,
113
+ manifest.manifest_kind
114
+ FROM cleanup_root_decisions decision
115
+ JOIN manifest_reachability reachable
116
+ ON reachable.scan_id = decision.scan_id
117
+ AND reachable.ancestor_digest = decision.digest
118
+ JOIN manifests manifest
119
+ ON manifest.scan_id = reachable.scan_id
120
+ AND manifest.digest = reachable.descendant_digest
121
+ WHERE decision.cleanup_run_id = ?
122
+ AND decision.validation_status = 'fully-deletable'
123
+ )
124
+ SELECT
125
+ manifest_kind,
126
+ COUNT(*) AS count
127
+ FROM fully_deletable_manifests
128
+ GROUP BY manifest_kind
129
+ `)
130
+ .all(cleanupRunId);
131
+ const countsByKind = new Map(manifestCounts.map((row) => [row.manifest_kind ?? "", row.count]));
132
+ return {
133
+ deletedTags,
134
+ deletedImages: countsByKind.get(ManifestKinds.imageManifest) ?? 0,
135
+ deletedIndexes: countsByKind.get(ManifestKinds.indexManifest) ?? 0,
136
+ deletedCrossArchManifests: countsByKind.get(ManifestKinds.crossArchManifest) ?? 0,
137
+ deletedArtifactManifests: countsByKind.get(ManifestKinds.artifactManifest) ?? 0,
138
+ deletedAttestations: countsByKind.get(ManifestKinds.attestationManifest) ?? 0,
139
+ deletedSignatures: countsByKind.get(ManifestKinds.signatureManifest) ?? 0,
140
+ deletedTotal: manifestCounts.reduce((total, row) => total + row.count, 0)
141
+ };
142
+ }
@@ -0,0 +1 @@
1
+ export declare function writeJsonOutput(args: string[], optionName: string, payload: unknown): void;
@@ -0,0 +1,11 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { findOption } from "./_args.js";
3
+ export function writeJsonOutput(args, optionName, payload) {
4
+ const json = JSON.stringify(payload);
5
+ const outputPath = findOption(args, optionName);
6
+ if (outputPath) {
7
+ writeFileSync(outputPath, `${json}\n`, "utf8");
8
+ return;
9
+ }
10
+ console.log(json);
11
+ }