ghcr-manager 0.9.7 → 0.9.8

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 (56) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/LICENSE +1 -1
  3. package/README.md +37 -56
  4. package/dist/cleanup-summary/_cleanup-summary-markdown.js +7 -10
  5. package/dist/cleanup-summary/_cleanup-summary.d.ts +3 -4
  6. package/dist/cleanup-summary/_cleanup-summary.js +2 -3
  7. package/dist/cli/_cleanup-command.js +1 -1
  8. package/dist/cli/_tag-selector-resolver.js +29 -9
  9. package/dist/cli/index.js +0 -4
  10. package/dist/core/_types.d.ts +1 -6
  11. package/dist/core/_types.js +1 -1
  12. package/dist/db/_db-merge-scan-copy.js +3 -2
  13. package/dist/db/_manifest-reachability.js +47 -8
  14. package/dist/db/_scan-writer.js +1 -13
  15. package/dist/db/planner/_planner-direct-target-root-options.d.ts +11 -0
  16. package/dist/db/planner/_planner-direct-target-root-options.js +1 -0
  17. package/dist/db/planner/_planner-direct-target-root-tag-filters.d.ts +9 -0
  18. package/dist/db/planner/_planner-direct-target-root-tag-filters.js +42 -0
  19. package/dist/db/planner/_planner-direct-target-roots-combined-sql.d.ts +7 -0
  20. package/dist/db/planner/_planner-direct-target-roots-combined-sql.js +198 -0
  21. package/dist/db/planner/_planner-direct-target-roots-combined.d.ts +4 -0
  22. package/dist/db/planner/_planner-direct-target-roots-combined.js +10 -0
  23. package/dist/db/planner/_planner-direct-target-roots-tagged.d.ts +4 -0
  24. package/dist/db/planner/_planner-direct-target-roots-tagged.js +125 -0
  25. package/dist/db/planner/_planner-direct-target-roots.d.ts +2 -12
  26. package/dist/db/planner/_planner-direct-target-roots.js +8 -203
  27. package/dist/db/planner/_planner-direct-target-tags.js +3 -4
  28. package/dist/db/planner/_planner-output.js +28 -8
  29. package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.d.ts +1 -0
  30. package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.js +65 -0
  31. package/dist/db/planner/_planner-plan-artifacts-closure-sql.d.ts +1 -0
  32. package/dist/db/planner/_planner-plan-artifacts-closure-sql.js +195 -0
  33. package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.d.ts +1 -0
  34. package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.js +86 -0
  35. package/dist/db/planner/_planner-plan-artifacts.js +26 -128
  36. package/dist/db/planner/_planner-sql.js +13 -2
  37. package/dist/db/planner/_planner-types.d.ts +2 -0
  38. package/dist/db/planner/_planner-types.js +1 -0
  39. package/dist/execute/_plan-executor.js +7 -11
  40. package/dist/execute/_types.d.ts +2 -19
  41. package/dist/execute/_untag-client.d.ts +2 -2
  42. package/dist/execute/_untag-client.js +1 -42
  43. package/dist/execute/index.d.ts +1 -1
  44. package/dist/ingest/github/_manifest-kind.d.ts +6 -0
  45. package/dist/ingest/github/_manifest-kind.js +16 -2
  46. package/package.json +16 -10
  47. package/resources/sql/schema/001_schema.sql +14 -4
  48. package/dist/cli/_untag-command.d.ts +0 -1
  49. package/dist/cli/_untag-command.js +0 -58
  50. package/dist/db/_manifest-kind-refinement.d.ts +0 -2
  51. package/dist/db/_manifest-kind-refinement.js +0 -43
  52. package/resources/sql/views/002_v_scan_root_manifests.sql +0 -44
  53. package/resources/sql/views/003_v_digest_tag_relations.sql +0 -50
  54. package/resources/sql/views/004_v_cleanup_root_closure_members.sql +0 -101
  55. package/resources/sql/views/005_v_cleanup_blocking_overlaps.sql +0 -42
  56. package/resources/sql/views/006_v_cleanup_root_decision_readable.sql +0 -67
package/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ 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.8] - 2026-06-03
11
+
12
+ ### Added
13
+
14
+ - Added dedicated graph-matrix GHCR scenarios and workflows to exercise shared-image, multi-arch, cosign, and
15
+ attestation cleanup cases in isolation.
16
+ - Added a local manifest-graph visualizer with browser UI for `ghcr-manager` SQLite databases, including manifest
17
+ details, zoom controls, one-hop expansion, and scan-to-scan compare mode.
18
+ - Added a separately publishable npm package, `ghcr-manager-visualizer`, plus user-facing visualizer documentation.
19
+ - Added repo-local manual visualizer demo scripts for seeding and updating GHCR packages during graph investigation.
20
+
21
+ ### Changed
22
+
23
+ - Cleanup planning was reworked around the current graph model, including direct SQL-backed tagged/untagged root
24
+ selection, graph-scoped closure walking, and refined `untag-only` vs `fully-deletable` decisions for complex
25
+ multi-arch, cosign, and attestation shapes.
26
+ - Large planner SQL bodies were split into smaller internal modules, and direct-target root selection logic was split
27
+ into smaller planner helpers.
28
+ - Orphaned digest-tag resolution now uses a direct latest-scan query instead of relying on older helper views.
29
+ - Manifest platform display now derives from descriptor data in the visualizer instead of relying on manifest-level
30
+ platform fields.
31
+ - Cross-architecture terminology is now consistently named `multi-arch` across runtime, tests, and docs.
32
+ - The root action and public CLI surface are now centered on `scan` and `cleanup` only.
33
+
34
+ ### Fixed
35
+
36
+ - Fixed digest-tag helper-edge direction and root-detection behavior so helper-tagged artifacts no longer interfere with
37
+ normal cleanup root semantics.
38
+ - Fixed shared-graph cleanup handling so selected indexes and helper-linked artifacts are deleted or retained according
39
+ to surviving real tags instead of simplistic descendant-only closure rules.
40
+
41
+ ### Removed
42
+
43
+ - The public `untag` CLI command, root-action mode, and dedicated direct-untag workflow coverage were removed. Internal
44
+ tag detachment for partial-tag cleanup matches remains part of `cleanup`.
45
+ - Several older cleanup helper views were removed after the planner rewrite moved the live logic into direct SQL query
46
+ paths.
47
+
10
48
  ## [0.9.7] - 2026-05-23
11
49
 
12
50
  ### Added
@@ -20,7 +58,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
20
58
 
21
59
  - Cleanup dry-run output and GitHub step summaries were reworked to explain the plan more clearly, including a filters
22
60
  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
61
+ - Informational manifest classification was tuned so only real multi-arch roots are labeled `multi_arch_manifest`, while
24
62
  helper-tagged indexes remain `index_manifest`.
25
63
  - `merge-run-artifacts` now uses a simpler current-run download flow with direct artifact download handling.
26
64
  - Cleanup selected-tag audit and DB-merge metadata handling were tightened alongside the summary/output refactor.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 gh-workflow
3
+ Copyright (c) 2026 Stefan Kuhn
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # ghcr-manager
2
2
 
3
- [![Release](https://img.shields.io/github/v/release/gh-workflow/ghcr-manager?style=flat-square)](https://github.com/gh-workflow/ghcr-manager/releases)
4
- [![Immutable Releases](https://img.shields.io/badge/releases-immutable-blue?labelColor=333)](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases)
5
3
  [![GitHub Marketplace](https://img.shields.io/badge/marketplace-ghcr--manager-blue?logo=github&labelColor=333&style=flat-square)](https://github.com/marketplace/actions/ghcr-manager)
6
- [![Tests](https://img.shields.io/github/actions/workflow/status/gh-workflow/ghcr-manager/.github/workflows/ci_change-validation.yml?branch=main&label=test&style=flat-square)](https://github.com/gh-workflow/ghcr-manager/actions/workflows/change-validation.yml)
4
+ [![Release](https://img.shields.io/github/v/release/ghcr-manager/ghcr-manager?style=flat-square)](https://github.com/ghcr-manager/ghcr-manager/releases)
5
+ [![Immutable Releases](https://img.shields.io/badge/releases-immutable-blue?labelColor=333)](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases)
6
+ [![Tests](https://img.shields.io/github/actions/workflow/status/ghcr-manager/ghcr-manager/.github/workflows/ci_change-validation.yml?branch=main&label=test&style=flat-square)](https://github.com/ghcr-manager/ghcr-manager/actions/workflows/ci_change-validation.yml)
7
7
 
8
8
  Inspect, review, and manage GitHub Container Registry packages.
9
9
 
@@ -12,7 +12,6 @@ Inspect, review, and manage GitHub Container Registry packages.
12
12
  - scanning one GHCR package into a SQLite database artifact
13
13
  - running cleanup with a GitHub step summary and optional DB artifact
14
14
  - previewing cleanup decisions with `dry-run` before making changes
15
- - directly removing selected tags with `untag`
16
15
 
17
16
  ## Quick Start
18
17
 
@@ -33,7 +32,7 @@ jobs:
33
32
 
34
33
  - name: Preview GHCR cleanup
35
34
  id: ghcr-manager
36
- uses: gh-workflow/ghcr-manager@0.9.7
35
+ uses: ghcr-manager/ghcr-manager@0.9.8
37
36
  with:
38
37
  command: cleanup
39
38
  token: ${{ github.token }}
@@ -55,16 +54,14 @@ After the run:
55
54
 
56
55
  ## Commands
57
56
 
58
- The action supports three commands:
57
+ The action supports two commands:
59
58
 
60
59
  - `cleanup`: Cleans using filters; use `dry-run` to preview the result
61
- - `untag`: Removes one or more tags directly
62
60
  - `scan`: Scans one package and uploads the resulting DB artifact
63
61
 
64
62
  ### Purpose of commands
65
63
 
66
64
  - `cleanup`: Normal entry point for registry maintenance
67
- - `untag`: Works directly without a full package scan
68
65
  - `scan`: For investigation and audit
69
66
 
70
67
  ## Common Usage
@@ -72,7 +69,7 @@ The action supports three commands:
72
69
  ### Preview cleanup
73
70
 
74
71
  ```yaml
75
- - uses: gh-workflow/ghcr-manager@0.9.7
72
+ - uses: ghcr-manager/ghcr-manager@0.9.8
76
73
  with:
77
74
  command: cleanup
78
75
  token: ${{ github.token }}
@@ -93,7 +90,7 @@ The action supports three commands:
93
90
  ### Apply cleanup
94
91
 
95
92
  ```yaml
96
- - uses: gh-workflow/ghcr-manager@0.9.7
93
+ - uses: ghcr-manager/ghcr-manager@0.9.8
97
94
  with:
98
95
  command: cleanup
99
96
  token: ${{ github.token }}
@@ -109,26 +106,10 @@ If `scan-after-cleanup` is `true`, `cleanup` performs a second scan so the uploa
109
106
 
110
107
  Note: the second scan only runs if cleanup actually makes changes.
111
108
 
112
- ### Remove selected tags directly
113
-
114
- ```yaml
115
- - uses: gh-workflow/ghcr-manager@0.9.7
116
- with:
117
- command: untag
118
- token: ${{ github.token }}
119
- owner: OWNER
120
- package: PACKAGE
121
- delete-tags: |
122
- old-tag
123
- test-tag
124
- ```
125
-
126
- `untag` does not use a scan DB and does not support DB artifact upload.
127
-
128
109
  ### Scan one package
129
110
 
130
111
  ```yaml
131
- - uses: gh-workflow/ghcr-manager@0.9.7
112
+ - uses: ghcr-manager/ghcr-manager@0.9.8
132
113
  with:
133
114
  command: scan
134
115
  token: ${{ github.token }}
@@ -142,32 +123,32 @@ Note: the second scan only runs if cleanup actually makes changes.
142
123
 
143
124
  <!-- markdownlint-disable MD013 MD060 -->
144
125
 
145
- | Input | Description | Cmds | Required | Default |
146
- | ---------------------------- | ----------------------------------- | ---- | ----------- | ------------------------------ |
147
- | `command` | `scan`, `cleanup`, or `untag` | all | Yes | |
148
- | `token` | GitHub token for API calls | all | Yes | `${{ github.token }}` |
149
- | `owner` | Package owner | all | Yes | |
150
- | `package` | Package name | all | Yes | |
151
- | `db-path` | Local SQLite DB path | s,c | No | |
152
- | `upload-artifacts` | Upload DB and summary artifacts | s,c | No | `false` |
153
- | `scan-after-cleanup` | Run a second scan after cleanup | c | No | `false` |
154
- | `db-artifact-retention-days` | Override artifact retention days | s,c | No | `${{ github.retention_days }}` |
155
- | `delete-tags` | Newline-separated tags to delete | c,u | for `untag` | |
156
- | `exclude-tags` | Newline-separated tags to exclude | c | No | |
157
- | `keep-n-tagged` | Keep newest tagged roots | c | No | |
158
- | `keep-n-untagged` | Keep newest untagged roots | c | No | |
159
- | `delete-untagged` | Delete untagged roots | c | No | `false` |
160
- | `delete-ghost-images` | Delete ghost multi-arch roots | c | No | `false` |
161
- | `delete-partial-images` | Delete partial multi-arch roots | c | No | `false` |
162
- | `delete-orphaned-images` | Delete orphaned digest-derived tags | c | No | `false` |
163
- | `older-than` | Age cutoff for cleanup selectors | c | No | |
164
- | `use-regex` | Use regex for cleanup tag selectors | c | No | `false` |
165
- | `dry-run` | Show changes without mutating GHCR | c,u | No | `false` |
166
- | `log-level` | CLI log level | all | No | `info` |
126
+ | Input | Description | Cmds | Required | Default |
127
+ | ---------------------------- | ----------------------------------- | ---- | -------- | ------------------------------ |
128
+ | `command` | `scan` or `cleanup` | all | Yes | |
129
+ | `token` | GitHub token for API calls | all | Yes | `${{ github.token }}` |
130
+ | `owner` | Package owner | all | Yes | |
131
+ | `package` | Package name | all | Yes | |
132
+ | `db-path` | Local SQLite DB path | s,c | No | |
133
+ | `upload-artifacts` | Upload DB and summary artifacts | s,c | No | `false` |
134
+ | `scan-after-cleanup` | Run a second scan after cleanup | c | No | `false` |
135
+ | `db-artifact-retention-days` | Override artifact retention days | s,c | No | `${{ github.retention_days }}` |
136
+ | `delete-tags` | Newline-separated tags to delete | c | No | |
137
+ | `exclude-tags` | Newline-separated tags to exclude | c | No | |
138
+ | `keep-n-tagged` | Keep newest tagged roots | c | No | |
139
+ | `keep-n-untagged` | Keep newest untagged roots | c | No | |
140
+ | `delete-untagged` | Delete untagged roots | c | No | `false` |
141
+ | `delete-ghost-images` | Delete ghost multi-arch roots | c | No | `false` |
142
+ | `delete-partial-images` | Delete partial multi-arch roots | c | No | `false` |
143
+ | `delete-orphaned-images` | Delete orphaned digest-derived tags | c | No | `false` |
144
+ | `older-than` | Age cutoff for cleanup selectors | c | No | |
145
+ | `use-regex` | Use regex for cleanup tag selectors | c | No | `false` |
146
+ | `dry-run` | Show changes without mutating GHCR | c | No | `false` |
147
+ | `log-level` | CLI log level | all | No | `info` |
167
148
 
168
149
  <!-- markdownlint-enable MD013 MD060 -->
169
150
 
170
- `Cmds`: `s` = `scan`, `c` = `cleanup`, `u` = `untag`
151
+ `Cmds`: `s` = `scan`, `c` = `cleanup`
171
152
 
172
153
  Cleanup command notes:
173
154
 
@@ -180,10 +161,10 @@ Cleanup command notes:
180
161
 
181
162
  ## Outputs
182
163
 
183
- | Output | Description |
184
- | ------------------- | ------------------------------------------------ |
185
- | `db-path` | SQLite DB path on the runner |
186
- | `summary-json-path` | Summary JSON file path for `cleanup` and `untag` |
164
+ | Output | Description |
165
+ | ------------------- | ------------------------------------ |
166
+ | `db-path` | SQLite DB path on the runner |
167
+ | `summary-json-path` | Summary JSON file path for `cleanup` |
187
168
 
188
169
  ## Artifacts
189
170
 
@@ -191,7 +172,6 @@ When artifacts are enabled:
191
172
 
192
173
  - `scan` always uploads one SQLite DB artifact
193
174
  - `cleanup` optionally uploads the DB artifact and a cleanup summary JSON artifact
194
- - `untag` uploads no artifacts
195
175
 
196
176
  Current naming:
197
177
 
@@ -202,7 +182,8 @@ Current naming:
202
182
 
203
183
  ## Documentation Map
204
184
 
205
- - [GitHub Action usage](docs/action-usage.md): action commands, including `cleanup`, `scan`, and `untag`
185
+ - [GitHub Action usage](docs/action-usage.md): action commands, including `cleanup` and `scan`
186
+ - [Visualizer](docs/visualizer.md): local graph inspection and scan-to-scan comparison
206
187
  - [Multi-package workflows](docs/db-merge-workflows.md): cleaning up multiple packages with one combined DB
207
188
  - [SQLite schema guide](docs/schema-description.md): practical explanation of the SQLite schema
208
189
  - [CLI usage](docs/cli-usage.md): companion CLI usage
@@ -16,7 +16,7 @@ export function renderCleanupSummaryMarkdown(summary, options) {
16
16
  `| 🏷️ Selected tags | ${summary.directTargetTags.length} |`,
17
17
  `| 🔖 Deleted tags | ${summary.changes.deletedTags} |`,
18
18
  `| 🖼️ Deleted images | ${summary.changes.deletedImages} |`,
19
- `| 📚 Deleted cross-arch manifests | ${summary.changes.deletedCrossArchManifests} |`,
19
+ `| 📚 Deleted multi-arch manifests | ${summary.changes.deletedMultiArchManifests} |`,
20
20
  `| 🧱 Deleted indexes | ${summary.changes.deletedIndexes} |`,
21
21
  `| 📄 Deleted total | ${summary.changes.deletedTotal} |`,
22
22
  `| 🔗 Tag-only updates | ${summary.untagOnlyRoots.length} |`,
@@ -29,7 +29,7 @@ export function renderCleanupSummaryMarkdown(summary, options) {
29
29
  lines.push(..._renderRootSection("🗑️ Deleted items", summary.fullyDeletableRoots, maxRootsPerSection));
30
30
  lines.push(..._renderRootSection("🔗 Tags removed only", summary.untagOnlyRoots, maxRootsPerSection));
31
31
  lines.push(..._renderRootSection("🛡️ Blocked items", summary.blockedRoots, maxRootsPerSection));
32
- if (!summary.dryRun && (summary.deletedPackageVersions.length > 0 || summary.untaggedTags.length > 0)) {
32
+ if (!summary.dryRun && (summary.deletedPackageVersionCount > 0 || summary.detachedTagCount > 0)) {
33
33
  lines.push(..._renderLiveEffects(summary));
34
34
  }
35
35
  return `${lines.join("\n").trimEnd()}\n`;
@@ -37,7 +37,7 @@ export function renderCleanupSummaryMarkdown(summary, options) {
37
37
  function _renderPlannedDeleteBreakdown(summary) {
38
38
  const rows = [
39
39
  { label: "Images", count: summary.changes.deletedImages },
40
- { label: "Cross-arch manifests", count: summary.changes.deletedCrossArchManifests },
40
+ { label: "Multi-arch manifests", count: summary.changes.deletedMultiArchManifests },
41
41
  { label: "Artifact manifests", count: summary.changes.deletedArtifactManifests },
42
42
  { label: "Signatures", count: summary.changes.deletedSignatures },
43
43
  { label: "Attestations", count: summary.changes.deletedAttestations },
@@ -105,11 +105,8 @@ function _renderRootSection(title, roots, maxRootsPerSection) {
105
105
  }
106
106
  function _renderLiveEffects(summary) {
107
107
  const lines = ["### Applied changes", ""];
108
- lines.push(`- Deleted package versions: ${summary.deletedPackageVersions.length}`);
109
- lines.push(`- Detached tags: ${summary.untaggedTags.length}`);
110
- if (summary.unsupportedUntagRoots.length > 0) {
111
- lines.push(`- Unsupported untag roots: ${summary.unsupportedUntagRoots.length}`);
112
- }
108
+ lines.push(`- Deleted package versions: ${summary.deletedPackageVersionCount}`);
109
+ lines.push(`- Detached tags: ${summary.detachedTagCount}`);
113
110
  lines.push("");
114
111
  return lines;
115
112
  }
@@ -211,8 +208,8 @@ function _describeManifestKind(manifestKind) {
211
208
  switch (manifestKind) {
212
209
  case ManifestKinds.imageManifest:
213
210
  return "image";
214
- case ManifestKinds.crossArchManifest:
215
- return "cross-arch";
211
+ case ManifestKinds.multiArchManifest:
212
+ return "multi-arch";
216
213
  case ManifestKinds.indexManifest:
217
214
  return "index";
218
215
  case ManifestKinds.signatureManifest:
@@ -25,7 +25,7 @@ export interface CleanupSummaryChanges {
25
25
  deletedTags: number;
26
26
  deletedImages: number;
27
27
  deletedIndexes: number;
28
- deletedCrossArchManifests: number;
28
+ deletedMultiArchManifests: number;
29
29
  deletedArtifactManifests: number;
30
30
  deletedAttestations: number;
31
31
  deletedSignatures: number;
@@ -45,9 +45,8 @@ export interface CleanupSummary {
45
45
  blockedRoots: CleanupSummaryRoot[];
46
46
  affectedManifests: CleanupSummaryAffectedManifest[];
47
47
  changes: CleanupSummaryChanges;
48
- deletedPackageVersions: DeleteExecutionSummary["deletedPackageVersions"];
49
- untaggedTags: DeleteExecutionSummary["untaggedTags"];
50
- unsupportedUntagRoots: DeleteExecutionSummary["unsupportedUntagRoots"];
48
+ deletedPackageVersionCount: DeleteExecutionSummary["deletedPackageVersionCount"];
49
+ detachedTagCount: DeleteExecutionSummary["detachedTagCount"];
51
50
  }
52
51
  export declare function buildCleanupSummary(plan: DeletePlan, options: {
53
52
  dryRun: boolean;
@@ -20,9 +20,8 @@ export function buildCleanupSummary(plan, options) {
20
20
  blockedRoots,
21
21
  affectedManifests,
22
22
  changes: options.changes,
23
- deletedPackageVersions: options.executionSummary?.deletedPackageVersions ?? [],
24
- untaggedTags: options.executionSummary?.untaggedTags ?? [],
25
- unsupportedUntagRoots: options.executionSummary?.unsupportedUntagRoots ?? []
23
+ deletedPackageVersionCount: options.executionSummary?.deletedPackageVersionCount ?? 0,
24
+ detachedTagCount: options.executionSummary?.detachedTagCount ?? 0
26
25
  };
27
26
  }
28
27
  function _mapRootDecision(decision, directTargetTagSet, rootTagsByVersionId) {
@@ -133,7 +133,7 @@ function _loadSummaryChanges(database, cleanupRunId) {
133
133
  deletedTags,
134
134
  deletedImages: countsByKind.get(ManifestKinds.imageManifest) ?? 0,
135
135
  deletedIndexes: countsByKind.get(ManifestKinds.indexManifest) ?? 0,
136
- deletedCrossArchManifests: countsByKind.get(ManifestKinds.crossArchManifest) ?? 0,
136
+ deletedMultiArchManifests: countsByKind.get(ManifestKinds.multiArchManifest) ?? 0,
137
137
  deletedArtifactManifests: countsByKind.get(ManifestKinds.artifactManifest) ?? 0,
138
138
  deletedAttestations: countsByKind.get(ManifestKinds.attestationManifest) ?? 0,
139
139
  deletedSignatures: countsByKind.get(ManifestKinds.signatureManifest) ?? 0,
@@ -96,16 +96,36 @@ function _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestam
96
96
  function _listLatestOrphanedTags(database, owner, packageName, cutoffTimestamp) {
97
97
  const rows = database
98
98
  .prepare(`
99
- SELECT DISTINCT dtr.tag
100
- FROM v_digest_tag_relations dtr
101
- INNER JOIN package_versions pv
102
- ON pv.scan_id = dtr.scan_id
103
- AND pv.version_id = dtr.artifact_version_id
104
- WHERE dtr.owner = ?
105
- AND dtr.package_name = ?
106
- AND dtr.parent_exists = 0
99
+ WITH latest_scan AS (
100
+ SELECT scan_id
101
+ FROM v_latest_scan_per_package
102
+ WHERE owner = ?
103
+ AND package_name = ?
104
+ ),
105
+ digest_tag_artifacts AS (
106
+ SELECT
107
+ t.tag,
108
+ t.scan_id,
109
+ t.version_id AS artifact_version_id,
110
+ 'sha256:' || SUBSTR(t.tag, 8, 64) AS parent_digest
111
+ FROM latest_scan ls
112
+ JOIN tags t
113
+ ON t.scan_id = ls.scan_id
114
+ WHERE t.is_digest_tag = 1
115
+ )
116
+ SELECT DISTINCT dta.tag
117
+ FROM digest_tag_artifacts dta
118
+ JOIN package_versions pv
119
+ ON pv.scan_id = dta.scan_id
120
+ AND pv.version_id = dta.artifact_version_id
121
+ WHERE NOT EXISTS (
122
+ SELECT 1
123
+ FROM manifests parent
124
+ WHERE parent.scan_id = dta.scan_id
125
+ AND parent.digest = dta.parent_digest
126
+ )
107
127
  AND (? IS NULL OR pv.created_at < ?)
108
- ORDER BY dtr.tag
128
+ ORDER BY dta.tag
109
129
  `)
110
130
  .all(owner, packageName, cutoffTimestamp ?? null, cutoffTimestamp ?? null);
111
131
  return rows.map((row) => row.tag);
package/dist/cli/index.js CHANGED
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
4
4
  import { handleCleanup } from "./_cleanup-command.js";
5
5
  import { handleDbMerge } from "./_db-merge-command.js";
6
6
  import { handleScan } from "./_scan-command.js";
7
- import { handleUntag } from "./_untag-command.js";
8
7
  export async function main(argv) {
9
8
  const [command, ...rest] = argv;
10
9
  if (!command) {
@@ -18,8 +17,6 @@ export async function main(argv) {
18
17
  return handleDbMerge(rest);
19
18
  case "scan":
20
19
  return handleScan(rest);
21
- case "untag":
22
- return handleUntag(rest);
23
20
  default:
24
21
  throw new Error(`unknown command: ${command}`);
25
22
  }
@@ -29,7 +26,6 @@ function printUsage() {
29
26
  ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] [--summary-json-path <path>] --owner <org> --package <name> [--token <token>] <cleanup selectors...> [--exclude-tag <tag> ...] [--use-regex] [--older-than <interval>]
30
27
  ghcr-manager db-merge --db <target-path> --source-db <path> [--source-db <path> ...]
31
28
  ghcr-manager scan --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--github-output <path>] --owner <org> --package <name> --token <token>
32
- ghcr-manager untag [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] [--summary-json-path <path>] --owner <org> --package <name> --token <token> --tag <tag> [--tag <tag> ...]
33
29
 
34
30
  Cleanup selectors:
35
31
  --delete-untagged
@@ -1,6 +1,6 @@
1
1
  export declare const ManifestKinds: {
2
2
  readonly indexManifest: "index_manifest";
3
- readonly crossArchManifest: "cross_arch_manifest";
3
+ readonly multiArchManifest: "multi_arch_manifest";
4
4
  readonly imageManifest: "image_manifest";
5
5
  readonly artifactManifest: "artifact_manifest";
6
6
  readonly attestationManifest: "attestation_manifest";
@@ -27,11 +27,6 @@ export interface ManifestRecord {
27
27
  subjectDigest?: string;
28
28
  annotations?: Record<string, unknown>;
29
29
  manifestKind?: ManifestKind;
30
- platform?: {
31
- architecture?: string;
32
- os?: string;
33
- variant?: string;
34
- };
35
30
  }
36
31
  export interface ManifestDescriptorRecord {
37
32
  parentDigest: string;
@@ -1,6 +1,6 @@
1
1
  export const ManifestKinds = {
2
2
  indexManifest: "index_manifest",
3
- crossArchManifest: "cross_arch_manifest",
3
+ multiArchManifest: "multi_arch_manifest",
4
4
  imageManifest: "image_manifest",
5
5
  artifactManifest: "artifact_manifest",
6
6
  attestationManifest: "attestation_manifest",
@@ -26,11 +26,12 @@ export class DbMergeScanCopy {
26
26
  "package_versions(scan_id, version_id, created_at, updated_at)",
27
27
  "package_version_payloads(scan_id, version_id, raw_json)",
28
28
  "tags(scan_id, tag, version_id, is_digest_tag)",
29
- "manifests(scan_id, version_id, digest, media_type, artifact_type, config_media_type, subject_digest, annotations_json, platform_os, platform_architecture, platform_variant, manifest_kind)",
29
+ "manifests(scan_id, version_id, digest, media_type, artifact_type, config_media_type, subject_digest, annotations_json, manifest_kind)",
30
30
  "manifest_descriptors(scan_id, parent_digest, child_digest, media_type, artifact_type, platform_os, platform_architecture, platform_variant)",
31
31
  "manifest_payloads(scan_id, digest, raw_json)",
32
32
  "manifest_edges(scan_id, parent_digest, child_digest, edge_kind)",
33
- "manifest_reachability(scan_id, ancestor_digest, descendant_digest, min_distance)"
33
+ "manifest_reachability(scan_id, ancestor_digest, descendant_digest, min_distance)",
34
+ "manifest_graphs(scan_id, digest, graph_id)"
34
35
  ];
35
36
  for (const spec of copySpecs) {
36
37
  const [tableName, columnList] = spec.split("(");
@@ -3,14 +3,19 @@ export function rebuildManifestReachability(database, scanId) {
3
3
  const manifestDigests = _loadManifestDigests(database, scanId);
4
4
  const childDigestsByParent = new Map();
5
5
  const parentDigestsByChild = new Map();
6
+ const neighborDigestsByDigest = new Map();
6
7
  for (const digest of manifestDigests) {
7
8
  childDigestsByParent.set(digest, new Set());
8
9
  parentDigestsByChild.set(digest, new Set());
10
+ neighborDigestsByDigest.set(digest, new Set());
9
11
  }
10
12
  for (const manifestEdge of _loadManifestEdges(database, scanId)) {
11
13
  childDigestsByParent.get(manifestEdge.parent_digest)?.add(manifestEdge.child_digest);
12
14
  parentDigestsByChild.get(manifestEdge.child_digest)?.add(manifestEdge.parent_digest);
15
+ neighborDigestsByDigest.get(manifestEdge.parent_digest)?.add(manifestEdge.child_digest);
16
+ neighborDigestsByDigest.get(manifestEdge.child_digest)?.add(manifestEdge.parent_digest);
13
17
  }
18
+ const graphIdsByDigest = _buildGraphIdsByDigest(manifestDigests, neighborDigestsByDigest);
14
19
  const remainingChildrenCount = new Map();
15
20
  const descendantDistancesByDigest = new Map();
16
21
  const readyDigests = [];
@@ -61,9 +66,19 @@ export function rebuildManifestReachability(database, scanId) {
61
66
  )
62
67
  VALUES(?, ?, ?, ?)
63
68
  `);
69
+ const insertGraphRow = database.prepare(`
70
+ INSERT OR REPLACE INTO manifest_graphs(
71
+ scan_id,
72
+ digest,
73
+ graph_id
74
+ )
75
+ VALUES(?, ?, ?)
76
+ `);
64
77
  const rebuild = database.transaction(() => {
65
78
  database.prepare("DELETE FROM manifest_reachability WHERE scan_id = ?").run(scanId);
79
+ database.prepare("DELETE FROM manifest_graphs WHERE scan_id = ?").run(scanId);
66
80
  for (const digest of manifestDigests) {
81
+ insertGraphRow.run(scanId, digest, graphIdsByDigest.get(digest));
67
82
  for (const [descendantDigest, distance] of descendantDistancesByDigest.get(digest) ?? []) {
68
83
  insertRow.run(scanId, digest, descendantDigest, distance);
69
84
  }
@@ -71,6 +86,32 @@ export function rebuildManifestReachability(database, scanId) {
71
86
  });
72
87
  rebuild();
73
88
  }
89
+ function _buildGraphIdsByDigest(manifestDigests, neighborDigestsByDigest) {
90
+ const graphIdsByDigest = new Map();
91
+ let nextGraphId = 1;
92
+ for (const rootDigest of manifestDigests) {
93
+ if (graphIdsByDigest.has(rootDigest)) {
94
+ continue;
95
+ }
96
+ const pendingDigests = [rootDigest];
97
+ graphIdsByDigest.set(rootDigest, nextGraphId);
98
+ while (pendingDigests.length > 0) {
99
+ const digest = pendingDigests.pop();
100
+ if (!digest) {
101
+ continue;
102
+ }
103
+ for (const neighborDigest of neighborDigestsByDigest.get(digest) ?? []) {
104
+ if (graphIdsByDigest.has(neighborDigest)) {
105
+ continue;
106
+ }
107
+ graphIdsByDigest.set(neighborDigest, nextGraphId);
108
+ pendingDigests.push(neighborDigest);
109
+ }
110
+ }
111
+ nextGraphId += 1;
112
+ }
113
+ return graphIdsByDigest;
114
+ }
74
115
  function _refreshDigestTagEdges(database, scanId) {
75
116
  database.prepare("DELETE FROM manifest_edges WHERE scan_id = ? AND edge_kind = 'digest-tag-referrer'").run(scanId);
76
117
  database
@@ -78,20 +119,18 @@ function _refreshDigestTagEdges(database, scanId) {
78
119
  INSERT OR IGNORE INTO manifest_edges(scan_id, parent_digest, child_digest, edge_kind)
79
120
  SELECT
80
121
  t.scan_id,
81
- 'sha256:' || SUBSTR(t.tag, 8, 64) AS parent_digest,
82
- m.digest AS child_digest,
122
+ m.digest AS parent_digest,
123
+ 'sha256:' || SUBSTR(t.tag, 8, 64) AS child_digest,
83
124
  'digest-tag-referrer' AS edge_kind
84
125
  FROM tags t
85
126
  JOIN manifests m
86
127
  ON m.scan_id = t.scan_id
87
128
  AND m.version_id = t.version_id
88
- JOIN manifests parent_manifest
89
- ON parent_manifest.scan_id = t.scan_id
90
- AND parent_manifest.digest = 'sha256:' || SUBSTR(t.tag, 8, 64)
129
+ JOIN manifests child_manifest
130
+ ON child_manifest.scan_id = t.scan_id
131
+ AND child_manifest.digest = 'sha256:' || SUBSTR(t.tag, 8, 64)
91
132
  WHERE t.scan_id = ?
92
- AND t.tag LIKE 'sha256-%'
93
- AND LENGTH(t.tag) >= 71
94
- AND SUBSTR(t.tag, 8, 64) NOT GLOB '*[^0-9A-Fa-f]*'
133
+ AND t.is_digest_tag = 1
95
134
  `)
96
135
  .run(scanId);
97
136
  }
@@ -1,7 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isDigestTag } from "../core/index.js";
3
3
  import { resolveGitHubActionsRunUrl } from "./_github-actions-run-url.js";
4
- import { refineManifestKinds } from "./_manifest-kind-refinement.js";
5
4
  import { rebuildManifestReachability } from "./_manifest-reachability.js";
6
5
  export class ScanWriter {
7
6
  #database;
@@ -63,9 +62,6 @@ export class ScanWriter {
63
62
  config_media_type,
64
63
  subject_digest,
65
64
  annotations_json,
66
- platform_os,
67
- platform_architecture,
68
- platform_variant,
69
65
  manifest_kind
70
66
  )
71
67
  VALUES(
@@ -77,9 +73,6 @@ export class ScanWriter {
77
73
  @configMediaType,
78
74
  @subjectDigest,
79
75
  @annotationsJson,
80
- @platformOs,
81
- @platformArchitecture,
82
- @platformVariant,
83
76
  @manifestKind
84
77
  )
85
78
  `);
@@ -152,9 +145,6 @@ export class ScanWriter {
152
145
  configMediaType: manifest.configMediaType ?? null,
153
146
  subjectDigest: manifest.subjectDigest ?? null,
154
147
  annotationsJson: manifest.annotations ? JSON.stringify(manifest.annotations) : null,
155
- platformOs: manifest.platform?.os ?? null,
156
- platformArchitecture: manifest.platform?.architecture ?? null,
157
- platformVariant: manifest.platform?.variant ?? null,
158
148
  manifestKind: manifest.manifestKind ?? null
159
149
  });
160
150
  }
@@ -180,9 +170,7 @@ export class ScanWriter {
180
170
  });
181
171
  }
182
172
  rebuildManifestReachability() {
183
- const scanId = this.#requireScanId();
184
- rebuildManifestReachability(this.#database, scanId);
185
- refineManifestKinds(this.#database, scanId);
173
+ rebuildManifestReachability(this.#database, this.#requireScanId());
186
174
  }
187
175
  getActiveScanId() {
188
176
  return this.#requireScanId();
@@ -0,0 +1,11 @@
1
+ export interface DirectTargetRootOptions {
2
+ deleteTags: string[];
3
+ deleteTagsRequested: boolean;
4
+ deleteOrphanedImages?: boolean;
5
+ excludeTags: string[];
6
+ deleteUntagged: boolean;
7
+ keepNTagged?: number;
8
+ keepNUntagged?: number;
9
+ useRegex?: boolean;
10
+ cutoffTimestamp?: string;
11
+ }
@@ -0,0 +1,9 @@
1
+ import type { DirectTargetRootOptions } from "./_planner-direct-target-root-options.js";
2
+ import type { PlannerSql } from "./_planner-sql.js";
3
+ export interface DirectTargetRootTagFilters {
4
+ selectedTagsSql: string;
5
+ selectedParams: Array<number | string>;
6
+ excludedVersionsSql: string;
7
+ excludedParams: Array<number | string>;
8
+ }
9
+ export declare function buildDirectTargetRootTagFilters(sql: PlannerSql, scanId: number, options: DirectTargetRootOptions): DirectTargetRootTagFilters;