Skip to content

Commit 354df91

Browse files
committed
Annotate mirrored DHI GHCR indexes
1 parent bdf5adc commit 354df91

3 files changed

Lines changed: 162 additions & 20 deletions

File tree

.github/scripts/mirror-dhi-image.sh

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
if [ "$#" -ne 6 ]; then
5-
echo "usage: $0 <name> <source-ref> <target-ref> <expected-source-index-digest> <expected-target-index-digest|auto> <required-arches>" >&2
4+
if [ "$#" -ne 10 ]; then
5+
echo "usage: $0 <name> <source-ref> <target-ref> <expected-source-index-digest> <expected-target-index-digest|auto> <required-arches> <description> <original-ref> <original-url> <source-repo-url>" >&2
66
exit 2
77
fi
88

@@ -12,6 +12,10 @@ target_ref="$3"
1212
expected_source_digest="$4"
1313
expected_target_digest="$5"
1414
required_arches_csv="$6"
15+
description="$7"
16+
original_ref="$8"
17+
original_url="$9"
18+
source_repo_url="${10}"
1519

1620
tmpdir="$(mktemp -d)"
1721
trap 'rm -rf "$tmpdir"' EXIT
@@ -50,6 +54,17 @@ echo "::group::copy ${name}"
5054
skopeo copy --retry-times 3 --all "docker://${source_ref}" "docker://${target_ref}"
5155
echo "::endgroup::"
5256

57+
echo "::group::annotate target ${name}"
58+
docker buildx imagetools create \
59+
--annotation "index:org.opencontainers.image.description=${description}" \
60+
--annotation "index:org.opencontainers.image.source=${source_repo_url}" \
61+
--annotation "index:org.opencontainers.image.url=${original_url}" \
62+
--annotation "index:io.terrarium.dhi.original-ref=${original_ref}" \
63+
--annotation "index:io.terrarium.dhi.original-url=${original_url}" \
64+
--tag "${target_ref}" \
65+
"${target_ref}"
66+
echo "::endgroup::"
67+
5368
echo "::group::verify target ${name}"
5469
skopeo inspect --raw "docker://${target_ref}" >"$target_raw"
5570
target_digest="$(digest_file "$target_raw")"
@@ -59,6 +74,29 @@ if [ "$expected_target_digest" != "auto" ] && [ "$target_digest" != "$expected_t
5974
exit 1
6075
fi
6176

77+
if [ "$expected_target_digest" = "auto" ]; then
78+
echo "::notice title=Final annotated GHCR digest::${target_ref}@${target_digest}"
79+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
80+
{
81+
echo "| Image | Tag | Final annotated digest |"
82+
echo "| --- | --- | --- |"
83+
echo "| ${name} | ${target_ref} | ${target_digest} |"
84+
} >>"$GITHUB_STEP_SUMMARY"
85+
fi
86+
fi
87+
88+
jq -e \
89+
--arg description "$description" \
90+
--arg source_repo_url "$source_repo_url" \
91+
--arg original_ref "$original_ref" \
92+
--arg original_url "$original_url" \
93+
'.annotations["org.opencontainers.image.description"] == $description
94+
and .annotations["org.opencontainers.image.source"] == $source_repo_url
95+
and .annotations["org.opencontainers.image.url"] == $original_url
96+
and .annotations["io.terrarium.dhi.original-ref"] == $original_ref
97+
and .annotations["io.terrarium.dhi.original-url"] == $original_url' \
98+
"$target_raw" >/dev/null
99+
62100
for arch in "${required_arches[@]}"; do
63101
source_arch_digest="$(jq -r --arg arch "$arch" '.manifests[] | select((.platform.os // "") == "linux" and (.platform.architecture // "") == $arch) | .digest' "$source_raw" | head -n1)"
64102
target_arch_digest="$(jq -r --arg arch "$arch" '.manifests[] | select((.platform.os // "") == "linux" and (.platform.architecture // "") == $arch) | .digest' "$target_raw" | head -n1)"

.github/workflows/mirror-hardened-images.yml

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,23 @@ jobs:
3030
target: ghcr.io/terion-name/terrarium-dhi-oauth2-proxy:7.15.2-debian13
3131
package: terrarium-dhi-oauth2-proxy
3232
source_digest: sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e
33-
target_digest: sha256:c5ec2ff7b486e72e7e6868efdc4c058f6280dba2ea472751c639d7b0e2bd43de
33+
target_digest: auto
3434
arches: amd64,arm64
35+
description: "Mirror of Docker Hardened Image dhi.io/oauth2-proxy:7.15.2-debian13 for pulling without Docker authentication. Original: https://hub.docker.com/hardened-images/catalog/dhi/oauth2-proxy"
36+
original_ref: dhi.io/oauth2-proxy:7.15.2-debian13@sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e
37+
original_url: https://hub.docker.com/hardened-images/catalog/dhi/oauth2-proxy
38+
source_repo_url: https://github.com/terion-name/terrarium
3539
- name: postgres
36-
source: dhi.io/postgres@sha256:53e316c761bfcaae02cdc6015c3a11a747fe9d0cde9cd0c3d4c871326862e7ed
37-
target: ghcr.io/terion-name/terrarium-dhi-postgres:17.9-alpine3.22
40+
source: dhi.io/postgres@sha256:a8da88e1ff62d2764fc63b0f1b0f912ff06fc629d964a260d876be615bd0857b
41+
target: ghcr.io/terion-name/terrarium-dhi-postgres:17.10-alpine3.22
3842
package: terrarium-dhi-postgres
39-
source_digest: sha256:53e316c761bfcaae02cdc6015c3a11a747fe9d0cde9cd0c3d4c871326862e7ed
40-
target_digest: sha256:de305976d6a81c4c1ad260861ec5028faafbb1be0bca68ab379eb2fb621abe34
43+
source_digest: sha256:a8da88e1ff62d2764fc63b0f1b0f912ff06fc629d964a260d876be615bd0857b
44+
target_digest: auto
4145
arches: amd64,arm64
46+
description: "Mirror of Docker Hardened Image dhi.io/postgres:17.10-alpine3.22 for pulling without Docker authentication. Original: https://hub.docker.com/hardened-images/catalog/dhi/postgres"
47+
original_ref: dhi.io/postgres:17.10-alpine3.22@sha256:a8da88e1ff62d2764fc63b0f1b0f912ff06fc629d964a260d876be615bd0857b
48+
original_url: https://hub.docker.com/hardened-images/catalog/dhi/postgres
49+
source_repo_url: https://github.com/terion-name/terrarium
4250
env:
4351
REGISTRY_AUTH_FILE: /tmp/terrarium-containers-auth.json
4452
HAS_DOCKERHUB_CREDS: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
@@ -76,7 +84,13 @@ jobs:
7684

7785
- name: Login to GHCR
7886
if: env.HAS_DOCKERHUB_CREDS == 'true'
79-
run: skopeo login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin <<<"${{ secrets.GITHUB_TOKEN }}"
87+
run: |
88+
skopeo login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin <<<"${{ secrets.GITHUB_TOKEN }}"
89+
docker login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin <<<"${{ secrets.GITHUB_TOKEN }}"
90+
91+
- name: Set up Docker Buildx
92+
if: env.HAS_DOCKERHUB_CREDS == 'true'
93+
uses: docker/setup-buildx-action@v3
8094

8195
- name: Mirror pinned image index
8296
if: env.HAS_DOCKERHUB_CREDS == 'true'
@@ -88,6 +102,10 @@ jobs:
88102
"${{ matrix.source_digest }}"
89103
"${{ matrix.target_digest }}"
90104
"${{ matrix.arches }}"
105+
"${{ matrix.description }}"
106+
"${{ matrix.original_ref }}"
107+
"${{ matrix.original_url }}"
108+
"${{ matrix.source_repo_url }}"
91109
92110
- name: Make GHCR package public when permitted
93111
if: env.HAS_DOCKERHUB_CREDS == 'true'

tests/hardened-image-mirror.test.ts

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,32 @@ import { describe, expect, test } from "bun:test";
44

55
const repoRoot = join(import.meta.dir, "..");
66

7+
const SOURCE_REPO_URL = "https://github.com/terion-name/terrarium";
78
const OAUTH2_PROXY_DIGEST = "sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e";
8-
const OAUTH2_PROXY_MIRROR_DIGEST = "sha256:c5ec2ff7b486e72e7e6868efdc4c058f6280dba2ea472751c639d7b0e2bd43de";
9-
const POSTGRES_DIGEST = "sha256:53e316c761bfcaae02cdc6015c3a11a747fe9d0cde9cd0c3d4c871326862e7ed";
9+
const POSTGRES_DIGEST = "sha256:a8da88e1ff62d2764fc63b0f1b0f912ff06fc629d964a260d876be615bd0857b";
10+
const OAUTH2_PROXY_DESCRIPTION =
11+
"Mirror of Docker Hardened Image dhi.io/oauth2-proxy:7.15.2-debian13 for pulling without Docker authentication. Original: https://hub.docker.com/hardened-images/catalog/dhi/oauth2-proxy";
12+
const POSTGRES_DESCRIPTION =
13+
"Mirror of Docker Hardened Image dhi.io/postgres:17.10-alpine3.22 for pulling without Docker authentication. Original: https://hub.docker.com/hardened-images/catalog/dhi/postgres";
14+
15+
const workflowPath = join(repoRoot, ".github/workflows/mirror-hardened-images.yml");
16+
const scriptPath = join(repoRoot, ".github/scripts/mirror-dhi-image.sh");
17+
18+
const readWorkflow = () => readFileSync(workflowPath, "utf8");
19+
const readScript = () => readFileSync(scriptPath, "utf8");
20+
21+
const requiredAnnotationKeys = [
22+
"org.opencontainers.image.description",
23+
"org.opencontainers.image.source",
24+
"org.opencontainers.image.url",
25+
"io.terrarium.dhi.original-ref",
26+
"io.terrarium.dhi.original-url",
27+
] as const;
1028

1129
describe("Docker Hardened Image mirror workflow", () => {
1230
test("mirrors pinned multi-arch DHI indexes to GHCR with digest checks", () => {
13-
const workflow = readFileSync(join(repoRoot, ".github/workflows/mirror-hardened-images.yml"), "utf8");
14-
const script = readFileSync(join(repoRoot, ".github/scripts/mirror-dhi-image.sh"), "utf8");
31+
const workflow = readWorkflow();
32+
const script = readScript();
1533

1634
expect(workflow).toContain("packages: write");
1735
expect(workflow).toContain("DOCKERHUB_USERNAME");
@@ -20,18 +38,19 @@ describe("Docker Hardened Image mirror workflow", () => {
2038
expect(workflow).toContain("workflow_dispatch");
2139
expect(workflow).toContain("skopeo login dhi.io");
2240
expect(workflow).toContain("skopeo login ghcr.io");
41+
expect(workflow).toContain("docker login ghcr.io");
2342
expect(workflow).toContain("/user/packages/container/${PACKAGE_NAME}/visibility");
2443
expect(workflow).toContain("/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${PACKAGE_NAME}/visibility");
25-
expect(workflow).toContain(`dhi.io/oauth2-proxy@${OAUTH2_PROXY_DIGEST}`);
26-
expect(workflow).not.toContain(`dhi.io/oauth2-proxy:7.15.2-debian13@${OAUTH2_PROXY_DIGEST}`);
27-
expect(workflow).toContain(`ghcr.io/terion-name/terrarium-dhi-oauth2-proxy:7.15.2-debian13`);
44+
expect(workflow).toContain(`source: dhi.io/oauth2-proxy@${OAUTH2_PROXY_DIGEST}`);
45+
expect(workflow).not.toContain(`source: dhi.io/oauth2-proxy:7.15.2-debian13@${OAUTH2_PROXY_DIGEST}`);
46+
expect(workflow).toContain("target: ghcr.io/terion-name/terrarium-dhi-oauth2-proxy:7.15.2-debian13");
2847
expect(workflow).toContain(`source_digest: ${OAUTH2_PROXY_DIGEST}`);
29-
expect(workflow).toContain(`target_digest: ${OAUTH2_PROXY_MIRROR_DIGEST}`);
30-
expect(workflow).toContain(`dhi.io/postgres@${POSTGRES_DIGEST}`);
48+
expect(workflow).toContain(`source: dhi.io/postgres@${POSTGRES_DIGEST}`);
3149
expect(workflow).not.toContain("17.9-alpine3.22-fips");
32-
expect(workflow).toContain("target: ghcr.io/terion-name/terrarium-dhi-postgres:17.9-alpine3.22");
50+
expect(workflow).not.toContain("target: ghcr.io/terion-name/terrarium-dhi-postgres:17.9-alpine3.22");
51+
expect(workflow).toContain("target: ghcr.io/terion-name/terrarium-dhi-postgres:17.10-alpine3.22");
3352
expect(workflow).toContain(`source_digest: ${POSTGRES_DIGEST}`);
34-
expect(workflow.match(/target_digest: sha256:de305976d6a81c4c1ad260861ec5028faafbb1be0bca68ab379eb2fb621abe34/g)).toHaveLength(1);
53+
expect(workflow.match(/target_digest: auto/g)).toHaveLength(2);
3554
expect(workflow.match(/arches: amd64,arm64/g)).toHaveLength(2);
3655

3756
expect(script).toContain("skopeo inspect --raw");
@@ -46,8 +65,75 @@ describe("Docker Hardened Image mirror workflow", () => {
4665
expect(script).toContain('(.platform.architecture // "") == $arch');
4766
});
4867

68+
test("defines explicit GHCR package metadata for each mirrored DHI image", () => {
69+
const workflow = readWorkflow();
70+
const descriptions = [...workflow.matchAll(/description: "([^"]+)"/g)].map((match) => match[1]);
71+
72+
expect(workflow).toContain(`description: "${OAUTH2_PROXY_DESCRIPTION}"`);
73+
expect(workflow).toContain(`original_ref: dhi.io/oauth2-proxy:7.15.2-debian13@${OAUTH2_PROXY_DIGEST}`);
74+
expect(workflow).toContain("original_url: https://hub.docker.com/hardened-images/catalog/dhi/oauth2-proxy");
75+
expect(workflow).toContain(`source_repo_url: ${SOURCE_REPO_URL}`);
76+
expect(workflow).toContain(`description: "${POSTGRES_DESCRIPTION}"`);
77+
expect(workflow).toContain(`original_ref: dhi.io/postgres:17.10-alpine3.22@${POSTGRES_DIGEST}`);
78+
expect(workflow).toContain("original_url: https://hub.docker.com/hardened-images/catalog/dhi/postgres");
79+
expect(workflow.match(new RegExp(`source_repo_url: ${SOURCE_REPO_URL}`, "g"))).toHaveLength(2);
80+
81+
expect(descriptions).toHaveLength(2);
82+
for (const description of descriptions) {
83+
expect(description.length).toBeLessThan(512);
84+
expect(description).toContain("Mirror of Docker Hardened Image");
85+
expect(description).toContain("for pulling without Docker authentication");
86+
expect(description).toContain("Original: https://hub.docker.com/hardened-images/catalog/dhi/");
87+
}
88+
});
89+
90+
test("sets up Buildx and passes the metadata contract to the mirror helper", () => {
91+
const workflow = readWorkflow();
92+
const invocation = workflow.match(/\.github\/scripts\/mirror-dhi-image\.sh[\s\S]*?\n\n - name: Make GHCR package public when permitted/)?.[0] ?? "";
93+
94+
expect(workflow).toContain("uses: docker/setup-buildx-action@v3");
95+
expect(invocation.match(/\$\{\{ matrix\./g)).toHaveLength(10);
96+
expect(invocation).toContain('"${{ matrix.name }}"');
97+
expect(invocation).toContain('"${{ matrix.source }}"');
98+
expect(invocation).toContain('"${{ matrix.target }}"');
99+
expect(invocation).toContain('"${{ matrix.source_digest }}"');
100+
expect(invocation).toContain('"${{ matrix.target_digest }}"');
101+
expect(invocation).toContain('"${{ matrix.arches }}"');
102+
expect(invocation).toContain('"${{ matrix.description }}"');
103+
expect(invocation).toContain('"${{ matrix.original_ref }}"');
104+
expect(invocation).toContain('"${{ matrix.original_url }}"');
105+
expect(invocation).toContain('"${{ matrix.source_repo_url }}"');
106+
});
107+
108+
test("annotates the final GHCR index and verifies annotation metadata", () => {
109+
const script = readScript();
110+
111+
expect(script).toContain('if [ "$#" -ne 10 ]; then');
112+
expect(script).toContain("<description> <original-ref> <original-url> <source-repo-url>");
113+
expect(script).toContain("docker buildx imagetools create");
114+
expect(script).toContain('--tag "${target_ref}"');
115+
expect(script).toContain('"${target_ref}"');
116+
expect(script).toContain("skopeo inspect --raw \"docker://${target_ref}\" >\"$target_raw\"");
117+
expect(script).toContain("jq -e");
118+
for (const key of requiredAnnotationKeys) {
119+
expect(script).toContain(`index:${key}`);
120+
expect(script).toContain(`.annotations["${key}"]`);
121+
}
122+
});
123+
124+
test("reports auto target digests without failing and writes a summary", () => {
125+
const script = readScript();
126+
127+
expect(script).toContain('if [ "$expected_target_digest" != "auto" ]');
128+
expect(script).toContain('if [ "$expected_target_digest" = "auto" ]; then');
129+
expect(script).toContain("::notice title=Final annotated GHCR digest::");
130+
expect(script).toContain("GITHUB_STEP_SUMMARY");
131+
expect(script).toContain("| Image | Tag | Final annotated digest |");
132+
expect(script).toContain("| ${name} | ${target_ref} | ${target_digest} |");
133+
});
134+
49135
test("keeps the mirror helper executable for GitHub Actions", () => {
50-
const mode = statSync(join(repoRoot, ".github/scripts/mirror-dhi-image.sh")).mode;
136+
const mode = statSync(scriptPath).mode;
51137

52138
expect(mode & 0o111).not.toBe(0);
53139
});

0 commit comments

Comments
 (0)