vouchington-tooling 0.50.0 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.50.0",
3
+ "version": "0.50.1",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -2,7 +2,17 @@
2
2
  # Sourced by ghcr-package-retention.sh for --main-history. Expects package_root, packages, apply,
3
3
  # keep_main and min_age_days from the caller.
4
4
 
5
+ # shellcheck source=/dev/null
6
+ . "$(dirname "${BASH_SOURCE[0]}")/ghcr-referenced-digests.sh"
7
+
5
8
  prune_main_history() {
9
+ # The registry namespace of the packages: GHCR_OWNER, else the workflow's repository owner.
10
+ local owner="${GHCR_OWNER:-${GITHUB_REPOSITORY_OWNER:-}}"
11
+ if [ -z "$owner" ]; then
12
+ echo '::error::ghcr-package-retention: GHCR_OWNER is required with --main-history' >&2
13
+ exit 2
14
+ fi
15
+
6
16
  # A zero keep count would delete every main image in one run.
7
17
  case "$keep_main" in
8
18
  '' | *[!0-9]*) keep_main=0 ;;
@@ -49,10 +59,13 @@ prune_main_history() {
49
59
  | min) as $oldest_image
50
60
  | ($versions | map(select(attachment and idle and $oldest_image != null
51
61
  and created < $oldest_image - 86400) | . + {reason: "orphan"})) as $orphans
52
- | ($old_main + $unmerged + $orphans)[]
53
- | "\(.id) \(tags | if length == 0 then "<untagged>" else join(",") end) \(.reason)"'
62
+ | {roots: ($versions | map(select((attachment | not) and (.id | IN($doomed[]) | not)) | .name)),
63
+ candidates: ($old_main + $unmerged + $orphans | map({id, name, reason,
64
+ tags: (tags | if length == 0 then "<untagged>" else join(",") end)}))}'
65
+ local filter='.candidates | map(select(.name | IN($referenced[]) | not))[]
66
+ | "\(.id) \(.tags) \(.reason)"'
54
67
 
55
- local deleted_total=0 package path error deletions id tags reason
68
+ local deleted_total=0 package path error deletions id tags reason referenced image resolved
56
69
  for package in "${packages[@]}"; do
57
70
  path="$package_root/$package/versions"
58
71
  if ! error=$(gh api --paginate "$path?per_page=100" 2>&1 > "$work/pages"); then
@@ -67,8 +80,18 @@ prune_main_history() {
67
80
  exit 1
68
81
  fi
69
82
 
70
- deletions=$(jq -r -s --rawfile revisions "$work/main-revisions" --argjson keep "$keep_main" \
71
- --argjson cutoff "$cutoff" "$plan" "$work/pages")
83
+ jq -s --rawfile revisions "$work/main-revisions" --argjson keep "$keep_main" \
84
+ --argjson cutoff "$cutoff" "$plan" "$work/pages" > "$work/plan"
85
+ # Never delete a digest that a kept image or index still references (platform manifests,
86
+ # attestations, subjects). An unreadable kept manifest aborts rather than risk a delete.
87
+ referenced='[]'
88
+ if [ "$(jq '.candidates | length' "$work/plan")" -gt 0 ]; then
89
+ jq -r '.roots[]' "$work/plan" > "$work/roots"
90
+ image="$owner/$(printf '%b' "${package//%/\\x}")"
91
+ resolved=$(resolve_referenced "$image" "$owner" "$work" "$work/roots") || exit 1
92
+ referenced=$(printf '%s\n' "$resolved" | jq -R . | jq -s .)
93
+ fi
94
+ deletions=$(jq -r --argjson referenced "$referenced" "$filter" "$work/plan")
72
95
  echo "· $package: $(jq -s 'add // [] | length' "$work/pages") version(s)"
73
96
  while read -r id tags reason; do
74
97
  [ -n "$id" ] || continue
@@ -12,6 +12,10 @@
12
12
  # - An attachment: untagged or tagged only sha256-*. Attestations and their referrer indexes are
13
13
  # pushed after their image, and platform manifests moments before it, so an attachment created
14
14
  # over a day before the oldest surviving image, and idle for the same age, belongs to none.
15
+ # Before deleting, the manifests of every surviving image are read from the registry (GH_TOKEN or
16
+ # GITHUB_TOKEN, else gh auth token) and anything they reference through manifests[] or subject is
17
+ # kept; if one cannot be read, the package is left untouched. The registry namespace is GHCR_OWNER,
18
+ # else GITHUB_REPOSITORY_OWNER (required for user-owned packages too).
15
19
  # Packages that do not exist (HTTP 404) are skipped. Defaults to a dry run; pass --apply to delete.
16
20
  #
17
21
  # Packages come from the arguments, or from GHCR_PACKAGES (space separated) when none are given.
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced by ghcr-main-history.sh. Walks OCI manifests through manifests[] and subject.
3
+
4
+ # registry_token <image> <owner> <work-dir>: exchanges the GitHub token for a registry pull token
5
+ # (GHCR's documented /token endpoint, Basic auth) and prints it. Credentials go to curl on stdin.
6
+ registry_token() {
7
+ local image="$1" owner="$2" dir="$3" token registry
8
+ registry="${GHCR_REGISTRY_URL:-https://ghcr.io}"
9
+ token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
10
+ if [ -z "$token" ]; then
11
+ token=$(gh auth token) || {
12
+ echo '::error::No GH_TOKEN or GITHUB_TOKEN, and gh auth token failed' >&2
13
+ return 1
14
+ }
15
+ fi
16
+ if ! printf 'user = "%s:%s"\n' "$owner" "$token" |
17
+ curl -fsS -K - "$registry/token?scope=repository:$image:pull" \
18
+ > "$dir/token.json" 2> "$dir/curl-error"; then
19
+ printf '::error::Could not get a registry token for %s: %s\n' "$image" \
20
+ "$(cat "$dir/curl-error")" >&2
21
+ return 1
22
+ fi
23
+ jq -er '.token | select(type == "string" and . != "")' "$dir/token.json" || {
24
+ printf '::error::Registry token response for %s had no token\n' "$image" >&2
25
+ return 1
26
+ }
27
+ }
28
+
29
+ # resolve_referenced <image> <owner> <work-dir> <roots-file>: prints every digest reachable from
30
+ # the root digests (one per line in the file), roots included. Returns non-zero on any failure, so
31
+ # the caller deletes nothing.
32
+ resolve_referenced() {
33
+ local image="$1" owner="$2" dir="$3" digest registry_bearer registry accept
34
+ registry="${GHCR_REGISTRY_URL:-https://ghcr.io}"
35
+ registry_bearer=$(registry_token "$image" "$owner" "$dir") || return 1
36
+ accept='application/vnd.oci.image.index.v1+json'
37
+ accept="$accept,application/vnd.oci.image.manifest.v1+json"
38
+ accept="$accept,application/vnd.docker.distribution.manifest.list.v2+json"
39
+ accept="$accept,application/vnd.docker.distribution.manifest.v2+json"
40
+
41
+ : > "$dir/seen"
42
+ cp "$4" "$dir/queue" || return 1
43
+ while [ -s "$dir/queue" ]; do
44
+ digest=$(head -n 1 "$dir/queue")
45
+ sed -i.bak 1d "$dir/queue" || return 1
46
+ rm -f "$dir/queue.bak"
47
+ [ -n "$digest" ] || continue
48
+ ! grep -qxF "$digest" "$dir/seen" || continue
49
+ echo "$digest" >> "$dir/seen"
50
+ if ! printf 'header = "Authorization: Bearer %s"\n' "$registry_bearer" |
51
+ curl -fsS -K - -H "Accept: $accept" "$registry/v2/$image/manifests/$digest" \
52
+ > "$dir/manifest" 2> "$dir/curl-error"; then
53
+ printf '::error::Could not read manifest %s of %s: %s\n' "$digest" "$image" \
54
+ "$(cat "$dir/curl-error")" >&2
55
+ return 1
56
+ fi
57
+ if ! jq -r '[(.manifests // [])[].digest, (.subject.digest // empty)] | .[]' \
58
+ "$dir/manifest" >> "$dir/queue" 2> "$dir/jq-error"; then
59
+ printf '::error::Could not parse manifest %s of %s: %s\n' "$digest" "$image" \
60
+ "$(cat "$dir/jq-error")" >&2
61
+ return 1
62
+ fi
63
+ done
64
+ cat "$dir/seen"
65
+ }