ghcr-manager 0.0.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +166 -57
  3. package/dist/cleanup-summary/_cleanup-summary-markdown.d.ts +6 -0
  4. package/dist/cleanup-summary/_cleanup-summary-markdown.js +113 -0
  5. package/dist/cleanup-summary/_cleanup-summary.d.ts +40 -0
  6. package/dist/cleanup-summary/_cleanup-summary.js +40 -0
  7. package/dist/cleanup-summary/index.d.ts +2 -0
  8. package/dist/cleanup-summary/index.js +2 -0
  9. package/dist/cli/_args.d.ts +1 -0
  10. package/dist/cli/_args.js +3 -0
  11. package/dist/cli/_cleanup-command.d.ts +1 -0
  12. package/dist/cli/_cleanup-command.js +63 -0
  13. package/dist/cli/_db-merge-command.d.ts +1 -0
  14. package/dist/cli/_db-merge-command.js +41 -0
  15. package/dist/cli/_github-output.d.ts +10 -0
  16. package/dist/cli/_github-output.js +13 -0
  17. package/dist/cli/_logger.d.ts +2 -1
  18. package/dist/cli/_logger.js +2 -0
  19. package/dist/cli/_older-than.d.ts +5 -0
  20. package/dist/cli/_older-than.js +42 -0
  21. package/dist/cli/_planner-options.d.ts +20 -0
  22. package/dist/cli/_planner-options.js +101 -0
  23. package/dist/cli/_scan-command.js +11 -4
  24. package/dist/cli/_tag-selector-resolver.d.ts +3 -0
  25. package/dist/cli/_tag-selector-resolver.js +109 -0
  26. package/dist/cli/_untag-command.d.ts +1 -0
  27. package/dist/cli/_untag-command.js +57 -0
  28. package/dist/cli/index.js +19 -1
  29. package/dist/config/_service-constants.d.ts +3 -0
  30. package/dist/config/_service-constants.js +3 -0
  31. package/dist/{tuning → config}/index.d.ts +3 -0
  32. package/dist/{tuning → config}/index.js +3 -0
  33. package/dist/core/_github-package-owner.d.ts +11 -0
  34. package/dist/core/_github-package-owner.js +45 -0
  35. package/dist/core/_http-error.d.ts +6 -0
  36. package/dist/core/_http-error.js +33 -0
  37. package/dist/core/_types.d.ts +3 -2
  38. package/dist/core/index.d.ts +4 -1
  39. package/dist/core/index.js +2 -1
  40. package/dist/db/_cleanup-run-writer.d.ts +10 -0
  41. package/dist/db/_cleanup-run-writer.js +73 -0
  42. package/dist/db/_db-merge-cleanup-copy.d.ts +7 -0
  43. package/dist/db/_db-merge-cleanup-copy.js +122 -0
  44. package/dist/db/_db-merge-history.d.ts +2 -0
  45. package/dist/db/_db-merge-history.js +15 -0
  46. package/dist/db/_db-merge-repository.d.ts +8 -0
  47. package/dist/db/_db-merge-repository.js +95 -0
  48. package/dist/db/_db-merge-scan-copy.d.ts +10 -0
  49. package/dist/db/_db-merge-scan-copy.js +69 -0
  50. package/dist/db/_db-merge-types.d.ts +44 -0
  51. package/dist/db/_db-merge-types.js +1 -0
  52. package/dist/db/_github-actions-run-url.d.ts +1 -0
  53. package/dist/db/_github-actions-run-url.js +9 -0
  54. package/dist/db/_scan-writer.d.ts +3 -1
  55. package/dist/db/_scan-writer.js +28 -13
  56. package/dist/db/_snapshot-repository.d.ts +9 -9
  57. package/dist/db/_snapshot-repository.js +37 -49
  58. package/dist/db/_sql-placeholders.d.ts +2 -0
  59. package/dist/db/_sql-placeholders.js +16 -0
  60. package/dist/db/index.d.ts +5 -0
  61. package/dist/db/index.js +3 -0
  62. package/dist/db/planner/_planner-delete-tag-root-targets.d.ts +7 -0
  63. package/dist/db/planner/_planner-delete-tag-root-targets.js +130 -0
  64. package/dist/db/planner/_planner-direct-target-tags.d.ts +6 -0
  65. package/dist/db/planner/_planner-direct-target-tags.js +47 -0
  66. package/dist/db/planner/_planner-keep-tagged-root-targets.d.ts +7 -0
  67. package/dist/db/planner/_planner-keep-tagged-root-targets.js +74 -0
  68. package/dist/db/planner/_planner-output.d.ts +5 -0
  69. package/dist/db/planner/_planner-output.js +101 -0
  70. package/dist/db/planner/_planner-plan-artifacts.d.ts +7 -0
  71. package/dist/db/planner/_planner-plan-artifacts.js +211 -0
  72. package/dist/db/planner/_planner-repository.d.ts +34 -0
  73. package/dist/db/planner/_planner-repository.js +126 -0
  74. package/dist/db/planner/_planner-sql.d.ts +12 -0
  75. package/dist/db/planner/_planner-sql.js +35 -0
  76. package/dist/db/planner/_planner-tag-selectors.d.ts +8 -0
  77. package/dist/db/planner/_planner-tag-selectors.js +57 -0
  78. package/dist/db/planner/_planner-tagged-root-targets.d.ts +15 -0
  79. package/dist/db/planner/_planner-tagged-root-targets.js +19 -0
  80. package/dist/db/planner/_planner-tagged-targets.d.ts +8 -0
  81. package/dist/db/planner/_planner-tagged-targets.js +16 -0
  82. package/dist/db/planner/_planner-types.d.ts +135 -0
  83. package/dist/db/planner/_planner-types.js +38 -0
  84. package/dist/db/planner/_planner-untagged-targets.d.ts +9 -0
  85. package/dist/db/planner/_planner-untagged-targets.js +91 -0
  86. package/dist/db/planner/index.d.ts +2 -0
  87. package/dist/db/planner/index.js +1 -0
  88. package/dist/execute/_http.d.ts +7 -0
  89. package/dist/execute/_http.js +48 -0
  90. package/dist/execute/_manifest-detach.d.ts +4 -0
  91. package/dist/execute/_manifest-detach.js +31 -0
  92. package/dist/execute/_package-version-delete-client.d.ts +4 -0
  93. package/dist/execute/_package-version-delete-client.js +34 -0
  94. package/dist/execute/_package-version-page-client.d.ts +14 -0
  95. package/dist/execute/_package-version-page-client.js +64 -0
  96. package/dist/execute/_package-version-tag-source-client.d.ts +12 -0
  97. package/dist/execute/_package-version-tag-source-client.js +65 -0
  98. package/dist/execute/_plan-executor.d.ts +3 -0
  99. package/dist/execute/_plan-executor.js +47 -0
  100. package/dist/execute/_registry-manifest-client.d.ts +12 -0
  101. package/dist/execute/_registry-manifest-client.js +79 -0
  102. package/dist/execute/_registry-token-client.d.ts +4 -0
  103. package/dist/execute/_registry-token-client.js +37 -0
  104. package/dist/execute/_types.d.ts +51 -0
  105. package/dist/execute/_types.js +1 -0
  106. package/dist/execute/_untag-client.d.ts +2 -0
  107. package/dist/execute/_untag-client.js +71 -0
  108. package/dist/execute/index.d.ts +5 -0
  109. package/dist/execute/index.js +3 -0
  110. package/dist/ingest/github/_manifest-client.d.ts +7 -1
  111. package/dist/ingest/github/_manifest-client.js +8 -0
  112. package/dist/ingest/github/_manifest-ingest.js +39 -53
  113. package/dist/ingest/github/_manifest-kind.d.ts +20 -0
  114. package/dist/ingest/github/_manifest-kind.js +50 -0
  115. package/dist/ingest/github/_package-metadata-load.d.ts +5 -0
  116. package/dist/ingest/github/_package-metadata-load.js +45 -0
  117. package/dist/ingest/github/_package-version-page-load.d.ts +1 -1
  118. package/dist/ingest/github/_package-version-page-load.js +8 -5
  119. package/dist/ingest/github/_packages-client.d.ts +1 -1
  120. package/dist/ingest/github/_packages-client.js +21 -4
  121. package/dist/ingest/github/_parallel-paginated-ingest.d.ts +1 -0
  122. package/dist/ingest/github/_parallel-paginated-ingest.js +2 -2
  123. package/dist/ingest/github/_shared.d.ts +1 -1
  124. package/dist/ingest/github/_shared.js +2 -34
  125. package/dist/ingest/github/index.d.ts +4 -0
  126. package/dist/ingest/github/index.js +8 -5
  127. package/package.json +7 -5
  128. package/resources/sql/schema/001_schema.sql +82 -8
  129. package/resources/sql/views/001_v_latest_scan_per_package.sql +2 -2
  130. package/resources/sql/views/003_v_scan_root_manifests.sql +43 -0
  131. package/resources/sql/views/004_v_digest_derived_tag_relations.sql +51 -0
  132. package/resources/sql/views/005_v_cleanup_root_closure_members.sql +100 -0
  133. package/resources/sql/views/006_v_cleanup_blocking_overlaps.sql +42 -0
  134. package/dist/ingest/github/_paginated-ingest.d.ts +0 -11
  135. package/dist/ingest/github/_paginated-ingest.js +0 -28
  136. package/resources/sql/views/003_v_missing_digests_related_manifests.sql +0 -78
  137. package/resources/sql/views/004_v_manifests_related_manifests.sql +0 -142
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.0] - 2026-05-21
11
+
12
+ `0.9.0` is the first stable pre-`1.0` release of `ghcr-manager`.
13
+
14
+ ### Added
15
+
16
+ - `cleanup` as the main GHCR maintenance flow for both the GitHub Action and the companion CLI.
17
+ - `untag` as a direct tag-removal mode that works without a scan database.
18
+ - `db-merge` and `merge-run-artifacts` support for combining scan databases across packages and workflow runs.
19
+ - Support for both organization-owned and user-owned GitHub Container Registry packages.
20
+ - Cleanup summary JSON output plus GitHub step summary rendering for action runs.
21
+ - Broad live and scenario-based workflow coverage for cleanup, untag, and cross-owner behavior.
22
+ - User-facing documentation for action usage, CLI usage, DB merge workflows, schema orientation, and SQL recipes.
23
+
24
+ ### Changed
25
+
26
+ - The GitHub Action now builds and runs the repo-local CLI directly instead of installing `ghcr-manager` from npm at
27
+ runtime.
28
+ - The primary maintenance surface is now `cleanup` with `dry-run` semantics, with `scan` and `untag` as supporting
29
+ command modes.
30
+ - The action input and artifact flow were refined around scan databases, cleanup summaries, and optional post-cleanup
31
+ rescan behavior.
32
+ - Documentation was reorganized around action-first usage, with deeper companion docs for CLI and database workflows.
33
+ - Release validation and workflow gating were tightened around exact version references, changelog readiness, and live
34
+ scenario checks.
35
+
36
+ ### Removed
37
+
38
+ - Built-in database artifact encryption and decryption support.
39
+
40
+ ### Fixed
41
+
42
+ - Digest-selector scenario handling and related workflow wiring for `ghcr-manager`.
43
+ - Latest-scan based verification for cleanup and untag test flows.
44
+ - User-owner cleanup workflow behavior and related test setup details.
45
+ - Numerous workflow, artifact-handling, and planner-audit edge cases discovered during pre-release hardening.
46
+
47
+ ## [0.0.6] - 2026-04-30
48
+
49
+ ### Changed
50
+
51
+ - Internal workflow/debug wiring.
52
+
53
+ ## [0.0.5] - 2026-04-30
54
+
55
+ ### Changed
56
+
57
+ - Publish to npmjs by trusted publisher
58
+
10
59
  ## [0.0.4] - 2026-04-30
11
60
 
12
61
  ### Changed
@@ -32,7 +81,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
32
81
  - Immutable per-scan UUID (`package_scans.scan_uuid`) for robust duplicate detection across merged databases.
33
82
  - Optional action artifact upload for scan DB export (`upload-db-artifact`, optional retention override).
34
83
  - Manual workflow for interactive scan runs (`.github/workflows/manual-run.yml`).
35
- - Missing-manifest investigation SQL recipes (`docs/missing-manifests-queries.md`) and schema/terminology docs.
84
+ - Missing-manifest investigation SQL recipes (`docs/queries/missing-manifests-queries.md`) and schema/terminology docs.
36
85
 
37
86
  ### Changed
38
87
 
package/README.md CHANGED
@@ -3,95 +3,204 @@
3
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
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
5
  [![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/change-validation.yml?branch=main&label=test&style=flat-square)](https://github.com/gh-workflow/ghcr-manager/actions/workflows/change-validation.yml)
7
- [![Usage](https://img.shields.io/badge/image-GHCR-2496ED?logo=docker&logoColor=white&style=flat-square)](#usage)
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)
8
7
 
9
- Inspect, analyze, and manage GitHub Container Registry packages.
8
+ Inspect, review, and manage GitHub Container Registry packages.
10
9
 
11
- `ghcr-manager` is a public GitHub Action and companion CLI for safe GHCR cleanup and inspection, with a focus on large
12
- packages and correct handling of multi-arch images, referrers, and attestations.
10
+ `ghcr-manager` is a GitHub Action for:
13
11
 
14
- ## Scope
12
+ - scanning one GHCR package into a SQLite database artifact
13
+ - running cleanup with a GitHub step summary and optional DB artifact
14
+ - previewing cleanup decisions with `dry-run` before making changes
15
+ - directly removing selected tags with `untag`
15
16
 
16
- - :white_check_mark: Full package and manifest scan per run for correctness
17
- - :white_check_mark: Export database of Container Registry from runs for local analysis
18
- - :construction: Safe cleanup of GHCR image artifacts in GitHub packages
17
+ ## Quick Start
19
18
 
20
- ## How
21
-
22
- ### :white_check_mark: Data Loading
23
-
24
- 1. Writes Container Registry metadata to a database
25
- 2. Pre-processes data for optimized lookups
26
- 3. Optional: Export of database from runs for local analysis
27
-
28
- > :construction: Planned: Support for merging several such databases into one for local analysis
29
-
30
- ### :construction: Consistency Check
31
-
32
- 1. Run consistency check against the database
33
- 2. Optional: Export report of missing manifests from runs
34
-
35
- ### :construction: Safe cleanup of GHCR image artifacts
36
-
37
- 1. Use filter input to query database for related artifacts (images and manifests)
38
- 2. Optional `dry-run`: Export which image artifacts would be deleted
39
- 3. Delete image artifacts (without `dry-run`)
40
-
41
- ## Usage
19
+ For a first run, start with `cleanup` in `dry-run` mode.
42
20
 
43
21
  ```yaml
44
- concurrency:
45
- group: ghcr-manager__${{ inputs.owner }}__${{ inputs.package }}
46
22
  jobs:
47
- scan:
23
+ cleanup:
48
24
  runs-on: ubuntu-latest
49
25
  permissions:
50
26
  contents: read
51
27
  packages: read
52
- actions: write # only required when upload-db-artifact is true
28
+ actions: write
53
29
  concurrency:
54
- group: ghcr-manager
30
+ group: ghcr-manager__OWNER__PACKAGE
55
31
  steps:
56
32
  - uses: actions/checkout@v6
57
33
 
58
- - name: Run ghcr-manager action
59
- uses: gh-workflow/ghcr-manager@0.0.4
34
+ - name: Preview GHCR cleanup
35
+ id: ghcr-manager
36
+ uses: gh-workflow/ghcr-manager@0.9.0
60
37
  with:
38
+ command: cleanup
61
39
  github-token: ${{ github.token }}
62
40
  owner: OWNER
63
41
  package: PACKAGE
42
+ dry-run: true
43
+ delete-untagged: true
44
+ keep-n-tagged: "10"
45
+ exclude-tags: |
46
+ latest
64
47
  upload-db-artifact: true
65
48
  ```
66
49
 
67
- > Copy the [Manual Run Workflow](.github/workflows/manual-run.yml) as a ready-to-run manual scan workflow.
50
+ After the run:
68
51
 
69
- ## Inputs
52
+ 1. Open the GitHub step summary for the action run.
53
+ 2. Review which tags matched and which roots would be deleted, untagged, or blocked.
54
+ 3. Only download the DB artifact if you need deeper inspection.
55
+
56
+ ## Commands
57
+
58
+ The action supports three commands:
59
+
60
+ - `cleanup`: Cleans using filters; use `dry-run` to preview the result
61
+ - `untag`: Removes one or more tags directly
62
+ - `scan`: Scans one package and uploads the resulting DB artifact
63
+
64
+ ### Purpose of commands
65
+
66
+ - `cleanup`: Normal entry point for registry maintenance
67
+ - `untag`: Works directly without a full package scan
68
+ - `scan`: For investigation and audit
69
+
70
+ ## Common Usage
71
+
72
+ ### Preview cleanup
73
+
74
+ ```yaml
75
+ - uses: gh-workflow/ghcr-manager@0.9.0
76
+ with:
77
+ command: cleanup
78
+ github-token: ${{ github.token }}
79
+ owner: OWNER
80
+ package: PACKAGE
81
+ dry-run: true
82
+ delete-tags: |
83
+ pr-.*
84
+ use-regex: true
85
+ older-than: 30d
86
+ keep-n-tagged: "5"
87
+ exclude-tags: |
88
+ latest
89
+ stable
90
+ upload-db-artifact: true
91
+ ```
92
+
93
+ ### Apply cleanup
70
94
 
71
- <!-- markdownlint-disable MD013 -->
95
+ ```yaml
96
+ - uses: gh-workflow/ghcr-manager@0.9.0
97
+ with:
98
+ command: cleanup
99
+ github-token: ${{ github.token }}
100
+ owner: OWNER
101
+ package: PACKAGE
102
+ delete-untagged: true
103
+ keep-n-tagged: "10"
104
+ upload-db-artifact: true
105
+ scan-after-cleanup: true
106
+ ```
107
+
108
+ If `scan-after-cleanup` is `true`, `cleanup` performs a second scan so the uploaded DB reflects post-mutation state.
109
+
110
+ Note: the second scan only runs if cleanup actually makes changes.
111
+
112
+ ### Remove selected tags directly
113
+
114
+ ```yaml
115
+ - uses: gh-workflow/ghcr-manager@0.9.0
116
+ with:
117
+ command: untag
118
+ github-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
+ ### Scan one package
72
129
 
73
- | Input | Description | Required | Default |
74
- | ---------------------------- | --------------------------------------------------------------- | -------- | ------------------------------ |
75
- | `github-token` | GitHub token used for GitHub/GHCR API calls | Yes | `${{ github.token }}` |
76
- | `owner` | GitHub owner of the container package (user or org) | Yes | |
77
- | `package` | Container package name | Yes | |
78
- | `upload-db-artifact` | Whether to upload the scan database as a workflow run artifact | No | `false` |
79
- | `db-artifact-retention-days` | Optional retention days override for uploaded database artifact | No | `${{ github.retention_days }}` |
130
+ ```yaml
131
+ - uses: gh-workflow/ghcr-manager@0.9.0
132
+ with:
133
+ command: scan
134
+ github-token: ${{ github.token }}
135
+ owner: OWNER
136
+ package: PACKAGE
137
+ ```
138
+
139
+ `scan` always uploads a DB artifact.
140
+
141
+ ## Inputs
80
142
 
81
- <!-- markdownlint-enable MD013 -->
143
+ <!-- markdownlint-disable MD013 MD060 -->
144
+
145
+ | Input | Description | Cmds | Required | Default |
146
+ | ---------------------------- | ----------------------------------- | ---- | ----------- | ------------------------------ |
147
+ | `command` | `scan`, `cleanup`, or `untag` | all | Yes | |
148
+ | `github-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-db-artifact` | Upload DB and summary artifact | 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` |
167
+
168
+ <!-- markdownlint-enable MD013 MD060 -->
169
+
170
+ `Cmds`: `s` = `scan`, `c` = `cleanup`, `u` = `untag`
82
171
 
83
172
  ## Outputs
84
173
 
85
- | Output | Description |
86
- | --------- | ------------------------------------------------------- |
87
- | `db-path` | Path to the SQLite database in the GitHub action runner |
174
+ | Output | Description |
175
+ | -------------- | -------------------------------------- |
176
+ | `db-path` | SQLite DB path on the runner |
177
+ | `summary-json` | Summary JSON for `cleanup` and `untag` |
88
178
 
89
179
  ## Artifacts
90
180
 
91
- <!-- markdownlint-disable MD013 -->
181
+ When artifacts are enabled:
182
+
183
+ - `scan` always uploads one SQLite DB artifact
184
+ - `cleanup` optionally uploads the DB artifact and a cleanup summary JSON artifact
185
+ - `untag` uploads no artifacts
186
+
187
+ Current naming:
188
+
189
+ | Artifact type | Filename pattern |
190
+ | -------------------- | --------------------------------------------------- |
191
+ | scan or cleanup DB | `${OWNER}__${PACKAGE}.sqlite` |
192
+ | cleanup summary JSON | `${OWNER}__${PACKAGE}.sqlite--cleanup-summary.json` |
193
+
194
+ ## Documentation Map
195
+
196
+ - [docs/action-usage.md](docs/action-usage.md): action commands, including `cleanup`, `scan`, and `untag`
197
+ - [docs/db-merge-workflows.md](docs/db-merge-workflows.md): cleaning up multiple packages with one combined DB
198
+ - [docs/schema-description.md](docs/schema-description.md): practical explanation of the SQLite schema
199
+ - [docs/queries/missing-manifests-queries.md](docs/queries/missing-manifests-queries.md): SQL recipes for missing
200
+ manifest references
201
+ - [docs/cli-usage.md](docs/cli-usage.md): companion CLI usage
92
202
 
93
- | Name | Filename | Description |
94
- | ----------------------------- | --------------------------------- | -------------------------------------------------------------------- |
95
- | `${OWNER}__${PACKAGE}.sqlite` | `${OWNER}__${PACKAGE}.sqlite.zip` | Zipped SQLite database containing GitHub Container Registry metadata |
203
+ ## Acknowledgment
96
204
 
97
- <!-- markdownlint-enable MD013 -->
205
+ This project was influenced by [dataaxiom/ghcr-cleanup-action](https://github.com/dataaxiom/ghcr-cleanup-action), with a
206
+ similar problem focus and a different implementation approach.
@@ -0,0 +1,6 @@
1
+ import type { CleanupSummary } from "./_cleanup-summary.js";
2
+ export declare function renderCleanupSummaryMarkdown(summary: CleanupSummary, options: {
3
+ maxDirectTargetTags?: number;
4
+ maxRootsPerSection?: number;
5
+ maxTagsPerRoot?: number;
6
+ }): string;
@@ -0,0 +1,113 @@
1
+ const _DEFAULT_MAX_DIRECT_TARGET_TAGS = 20;
2
+ const _DEFAULT_MAX_ROOTS_PER_SECTION = 20;
3
+ const _DEFAULT_MAX_TAGS_PER_ROOT = 4;
4
+ export function renderCleanupSummaryMarkdown(summary, options) {
5
+ const maxDirectTargetTags = options.maxDirectTargetTags ?? _DEFAULT_MAX_DIRECT_TARGET_TAGS;
6
+ const maxRootsPerSection = options.maxRootsPerSection ?? _DEFAULT_MAX_ROOTS_PER_SECTION;
7
+ const maxTagsPerRoot = options.maxTagsPerRoot ?? _DEFAULT_MAX_TAGS_PER_ROOT;
8
+ const lines = [
9
+ "## Cleanup Summary",
10
+ "",
11
+ "| Field | Value |",
12
+ "| --- | --- |",
13
+ `| 📦 Package | \`${_escapeInlineCode(`${summary.owner}/${summary.packageName}`)}\` |`,
14
+ `| ⚙️ 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} |`,
19
+ ""
20
+ ];
21
+ lines.push(..._renderJsonDetails("⚙️ Cleanup filter", summary.plannerInputs));
22
+ 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));
26
+ if (!summary.dryRun && (summary.deletedPackageVersions.length > 0 || summary.untaggedTags.length > 0)) {
27
+ lines.push(..._renderLiveEffects(summary));
28
+ }
29
+ return `${lines.join("\n").trimEnd()}\n`;
30
+ }
31
+ function _renderJsonDetails(title, value) {
32
+ return [
33
+ `<details>`,
34
+ `<summary>${title}</summary>`,
35
+ "",
36
+ "```json",
37
+ JSON.stringify(value, null, 2),
38
+ "```",
39
+ "",
40
+ "</details>",
41
+ ""
42
+ ];
43
+ }
44
+ function _renderDirectTargetTags(tags, maxDirectTargetTags) {
45
+ if (tags.length === 0) {
46
+ return [];
47
+ }
48
+ const visibleTags = tags.slice(0, maxDirectTargetTags).map((tag) => `- \`${_escapeInlineCode(tag)}\``);
49
+ const lines = ["<details>", "<summary>🏷️ Matched tags</summary>", "", ...visibleTags];
50
+ if (tags.length > maxDirectTargetTags) {
51
+ lines.push("", `_Showing first ${maxDirectTargetTags} of ${tags.length} matched tags._`);
52
+ }
53
+ lines.push("", "</details>", "");
54
+ return lines;
55
+ }
56
+ function _renderRootSection(title, roots, maxRootsPerSection, maxTagsPerRoot) {
57
+ if (roots.length === 0) {
58
+ return [];
59
+ }
60
+ const lines = ["<details>", `<summary>${title}</summary>`, ""];
61
+ lines.push("| Version | Digest | Tags | Reason |");
62
+ lines.push("| --- | --- | --- | --- |");
63
+ for (const root of roots.slice(0, maxRootsPerSection)) {
64
+ lines.push(`| ${root.versionId} | \`${_escapeInlineCode(_shortDigest(root.digest))}\` | ${_escapeMarkdown(_formatTags(root, maxTagsPerRoot))} | ${_escapeMarkdown(_formatReason(root))} |`);
65
+ }
66
+ if (roots.length > maxRootsPerSection) {
67
+ lines.push("", `_Showing first ${maxRootsPerSection} of ${roots.length} ${title.toLowerCase()}._`);
68
+ }
69
+ lines.push("", "</details>", "");
70
+ return lines;
71
+ }
72
+ function _renderLiveEffects(summary) {
73
+ const lines = ["### Applied changes", ""];
74
+ lines.push(`- Deleted package versions: ${summary.deletedPackageVersions.length}`);
75
+ lines.push(`- Detached tags: ${summary.untaggedTags.length}`);
76
+ if (summary.unsupportedUntagRoots.length > 0) {
77
+ lines.push(`- Unsupported untag roots: ${summary.unsupportedUntagRoots.length}`);
78
+ }
79
+ lines.push("");
80
+ return lines;
81
+ }
82
+ function _formatTags(root, maxTagsPerRoot) {
83
+ const tags = root.rootTags.length > 0 ? root.rootTags : root.matchedTags;
84
+ if (tags.length === 0) {
85
+ return "(untagged)";
86
+ }
87
+ const visible = tags.slice(0, maxTagsPerRoot);
88
+ const suffix = tags.length > maxTagsPerRoot ? `, +${tags.length - maxTagsPerRoot} more` : "";
89
+ return visible.join(", ") + suffix;
90
+ }
91
+ function _formatReason(root) {
92
+ if (root.validationStatus === "blocked") {
93
+ const blocking = root.blockingDigest ? _shortDigest(root.blockingDigest) : "another root";
94
+ const overlap = root.overlapDigest ? ` via ${_shortDigest(root.overlapDigest)}` : "";
95
+ return `Blocked by ${blocking}${overlap}`;
96
+ }
97
+ if (root.validationStatus === "untag-only") {
98
+ return "Selected tags detach; root remains";
99
+ }
100
+ return "Root and closure can be deleted";
101
+ }
102
+ function _shortDigest(value) {
103
+ if (!value.startsWith("sha256:") || value.length <= 20) {
104
+ return value;
105
+ }
106
+ return `${value.slice(0, 15)}...${value.slice(-8)}`;
107
+ }
108
+ function _escapeInlineCode(value) {
109
+ return value.replaceAll("`", "\\`");
110
+ }
111
+ function _escapeMarkdown(value) {
112
+ return value.replaceAll("|", "\\|").replaceAll("\n", " ");
113
+ }
@@ -0,0 +1,40 @@
1
+ import type { DeletePlan } from "../db/index.js";
2
+ import type { DeleteExecutionSummary } from "../execute/index.js";
3
+ export interface CleanupSummaryRoot {
4
+ versionId: number;
5
+ digest: string;
6
+ manifestKind?: string;
7
+ rootTags: string[];
8
+ 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";
13
+ validationReason: string;
14
+ blockingVersionId?: number;
15
+ blockingDigest?: string;
16
+ overlapDigest?: string;
17
+ overlapManifestKind?: string;
18
+ }
19
+ export interface CleanupSummary {
20
+ command: "cleanup";
21
+ owner: string;
22
+ packageName: string;
23
+ scanCompletedAt: string;
24
+ dryRun: boolean;
25
+ plannerInputs: DeletePlan["plannerInputs"];
26
+ validationSummary: DeletePlan["validationSummary"];
27
+ directTargetTags: string[];
28
+ collateralTags: string[];
29
+ fullyDeletableRoots: CleanupSummaryRoot[];
30
+ untagOnlyRoots: CleanupSummaryRoot[];
31
+ blockedRoots: CleanupSummaryRoot[];
32
+ deletedPackageVersions: DeleteExecutionSummary["deletedPackageVersions"];
33
+ untaggedTags: DeleteExecutionSummary["untaggedTags"];
34
+ unsupportedUntagRoots: DeleteExecutionSummary["unsupportedUntagRoots"];
35
+ }
36
+ export declare function buildCleanupSummary(plan: DeletePlan, options: {
37
+ dryRun: boolean;
38
+ listRootTags: (versionId: number) => string[];
39
+ executionSummary?: DeleteExecutionSummary;
40
+ }): CleanupSummary;
@@ -0,0 +1,40 @@
1
+ export function buildCleanupSummary(plan, options) {
2
+ const directTargetTagSet = new Set(plan.directTargetTags);
3
+ const roots = plan.rootDecisions.map((decision) => _mapRootDecision(decision, directTargetTagSet, options.listRootTags));
4
+ return {
5
+ command: "cleanup",
6
+ owner: plan.owner,
7
+ packageName: plan.packageName,
8
+ scanCompletedAt: plan.scanCompletedAt,
9
+ dryRun: options.dryRun,
10
+ plannerInputs: plan.plannerInputs,
11
+ validationSummary: plan.validationSummary,
12
+ directTargetTags: plan.directTargetTags,
13
+ 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"),
17
+ deletedPackageVersions: options.executionSummary?.deletedPackageVersions ?? [],
18
+ untaggedTags: options.executionSummary?.untaggedTags ?? [],
19
+ unsupportedUntagRoots: options.executionSummary?.unsupportedUntagRoots ?? []
20
+ };
21
+ }
22
+ function _mapRootDecision(decision, directTargetTagSet, listRootTags) {
23
+ const rootTags = listRootTags(decision.versionId);
24
+ return {
25
+ versionId: decision.versionId,
26
+ digest: decision.digest,
27
+ manifestKind: decision.manifestKind,
28
+ rootTags,
29
+ matchedTags: rootTags.filter((tag) => directTargetTagSet.has(tag)),
30
+ selectionMode: decision.selectionMode,
31
+ selectionReason: decision.selectionReason,
32
+ validationStatus: decision.validationStatus,
33
+ validationReasonCode: decision.validationReasonCode,
34
+ validationReason: decision.validationReason,
35
+ blockingVersionId: decision.blockingVersionId,
36
+ blockingDigest: decision.blockingDigest,
37
+ overlapDigest: decision.overlapDigest,
38
+ overlapManifestKind: decision.overlapManifestKind
39
+ };
40
+ }
@@ -0,0 +1,2 @@
1
+ export { buildCleanupSummary, type CleanupSummary, type CleanupSummaryRoot } from "./_cleanup-summary.js";
2
+ export { renderCleanupSummaryMarkdown } from "./_cleanup-summary-markdown.js";
@@ -0,0 +1,2 @@
1
+ export { buildCleanupSummary } from "./_cleanup-summary.js";
2
+ export { renderCleanupSummaryMarkdown } from "./_cleanup-summary-markdown.js";
@@ -2,5 +2,6 @@ import { type LogLevel } from "./_logger.js";
2
2
  export declare function requireOption(args: string[], name: string): string;
3
3
  export declare function findOption(args: string[], name: string): string | undefined;
4
4
  export declare function collectRepeatedOption(args: string[], name: string): string[];
5
+ export declare function hasFlag(args: string[], name: string): boolean;
5
6
  export declare function resolveGitHubToken(args: string[]): string;
6
7
  export declare function resolveLogLevel(args: string[]): LogLevel;
package/dist/cli/_args.js CHANGED
@@ -22,6 +22,9 @@ export function collectRepeatedOption(args, name) {
22
22
  }
23
23
  return values;
24
24
  }
25
+ export function hasFlag(args, name) {
26
+ return args.includes(name);
27
+ }
25
28
  export function resolveGitHubToken(args) {
26
29
  const cliToken = findOption(args, "--token");
27
30
  if (cliToken) {
@@ -0,0 +1 @@
1
+ export declare function handleCleanup(args: string[]): Promise<number>;
@@ -0,0 +1,63 @@
1
+ import { buildCleanupSummary } from "../cleanup-summary/index.js";
2
+ import { CleanupRunWriter, openDatabase, PlannerRepository } from "../db/index.js";
3
+ import { executeDeletePlan } from "../execute/index.js";
4
+ import { hasFlag, resolveGitHubToken, resolveLogLevel } from "./_args.js";
5
+ import { createLogger } from "./_logger.js";
6
+ import { loadDeletePlan, resolvePlanCommandInputs } from "./_planner-options.js";
7
+ import { resolveTagSelectors } from "./_tag-selector-resolver.js";
8
+ export async function handleCleanup(args) {
9
+ const inputs = resolvePlanCommandInputs(args);
10
+ const dryRun = hasFlag(args, "--dry-run");
11
+ const token = dryRun ? undefined : resolveGitHubToken(args);
12
+ const logger = createLogger(resolveLogLevel(args));
13
+ const database = openDatabase(inputs.databasePath);
14
+ try {
15
+ const repository = new PlannerRepository(database, logger);
16
+ const cleanupRunWriter = new CleanupRunWriter(database);
17
+ logger.debug(`Starting cleanup for ${inputs.owner}/${inputs.packageName}`);
18
+ const plan = loadDeletePlan(repository, resolveTagSelectors(database, inputs));
19
+ cleanupRunWriter.persistCleanupRun(repository.getLatestCompletedScanId(inputs.owner, inputs.packageName), plan, {
20
+ dryRun,
21
+ cleanupStartedAt: new Date().toISOString()
22
+ });
23
+ if (dryRun) {
24
+ const summary = buildCleanupSummary(plan, {
25
+ dryRun: true,
26
+ listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId)
27
+ });
28
+ logger.debug(`Completed dry-run cleanup for ${inputs.owner}/${inputs.packageName}`);
29
+ console.log(JSON.stringify(summary));
30
+ return 0;
31
+ }
32
+ const executionSummary = await executeDeletePlan(plan, {
33
+ token: token,
34
+ logger,
35
+ listRootTags: (root) => _listRootTags(database, root.owner, root.packageName, root.versionId)
36
+ });
37
+ const summary = buildCleanupSummary(plan, {
38
+ dryRun: false,
39
+ listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId),
40
+ executionSummary
41
+ });
42
+ logger.debug(`Completed cleanup for ${inputs.owner}/${inputs.packageName}`);
43
+ console.log(JSON.stringify(summary));
44
+ return 0;
45
+ }
46
+ finally {
47
+ database.close();
48
+ }
49
+ }
50
+ function _listRootTags(database, owner, packageName, versionId) {
51
+ const rows = database
52
+ .prepare(`
53
+ SELECT tags.tag
54
+ FROM tags
55
+ INNER JOIN v_latest_scan_per_package latest_scan ON latest_scan.scan_id = tags.scan_id
56
+ WHERE latest_scan.owner = ?
57
+ AND latest_scan.package_name = ?
58
+ AND tags.version_id = ?
59
+ ORDER BY tags.tag
60
+ `)
61
+ .all(owner, packageName, versionId);
62
+ return rows.map((row) => row.tag);
63
+ }
@@ -0,0 +1 @@
1
+ export declare function handleDbMerge(args: string[]): Promise<number>;