diff --git a/.github/renovate-controller-policy.md b/.github/renovate-controller-policy.md index 75042d7bd..c857b2f85 100644 --- a/.github/renovate-controller-policy.md +++ b/.github/renovate-controller-policy.md @@ -61,4 +61,10 @@ After changing either controller: python3 .github/scripts/test_renovate_app_version.py ``` +Before a full scan, verify that Docker Hub accepts the configured repository secrets: + +```bash +gh workflow run renovate-dockerhub-preflight.yml --ref localApps +``` + Validate both JSON configurations with the Renovate version used by `.github/workflows/renovate.yml`, then run the self-hosted workflow manually. Confirm that its log lists only `docker-compose`, its branches start with `selfhosted-renovate/`, and the hosted App does not close them before retiring an older duplicate dashboard. diff --git a/.github/scripts/check_dockerhub_credentials.mjs b/.github/scripts/check_dockerhub_credentials.mjs new file mode 100644 index 000000000..8c5ee94c1 --- /dev/null +++ b/.github/scripts/check_dockerhub_credentials.mjs @@ -0,0 +1,76 @@ +const AUTH_URL = + "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull"; +const MANIFEST_URL = + "https://registry-1.docker.io/v2/library/alpine/manifests/latest"; + +export async function checkDockerHubCredentials({ + username, + password, + fetchImpl = fetch, +}) { + if (!username || !password) { + throw new Error( + "Configure DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repository secrets.", + ); + } + + const basicAuth = Buffer.from(`${username}:${password}`).toString("base64"); + const authResponse = await fetchImpl(AUTH_URL, { + headers: { authorization: `Basic ${basicAuth}` }, + }); + if (!authResponse.ok) { + throw new Error( + `Docker Hub rejected the configured credentials (HTTP ${authResponse.status}).`, + ); + } + + const authPayload = await authResponse.json(); + const bearer = authPayload.token ?? authPayload.access_token; + if (!bearer) { + throw new Error("Docker Hub did not return a registry bearer token."); + } + + const manifestResponse = await fetchImpl(MANIFEST_URL, { + headers: { + accept: + "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json", + authorization: `Bearer ${bearer}`, + }, + }); + if (!manifestResponse.ok) { + throw new Error( + `Docker Hub authenticated manifest lookup failed (HTTP ${manifestResponse.status}).`, + ); + } + + return { + limit: + manifestResponse.headers.get("ratelimit-limit") ?? + manifestResponse.headers.get("x-ratelimit-limit") ?? + "not reported", + remaining: + manifestResponse.headers.get("ratelimit-remaining") ?? + manifestResponse.headers.get("x-ratelimit-remaining") ?? + "not reported", + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + const result = await checkDockerHubCredentials({ + username: process.env.RENOVATE_DOCKERHUB_USERNAME, + password: process.env.RENOVATE_DOCKERHUB_TOKEN, + }); + console.log( + `Docker Hub credential preflight passed (limit=${result.limit}, remaining=${result.remaining}).`, + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; diff --git a/.github/scripts/test_renovate_app_version.py b/.github/scripts/test_renovate_app_version.py index 7f1432997..4ae8f1069 100644 --- a/.github/scripts/test_renovate_app_version.py +++ b/.github/scripts/test_renovate_app_version.py @@ -211,6 +211,12 @@ class RenovateAppVersionTests(unittest.TestCase): def test_self_hosted_renovate_requires_docker_hub_credentials(self): workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8") + preflight = ( + REPO_ROOT / ".github" / "workflows" / "renovate-dockerhub-preflight.yml" + ).read_text(encoding="utf-8") + checker = ( + REPO_ROOT / ".github" / "scripts" / "check_dockerhub_credentials.mjs" + ).read_text(encoding="utf-8") self.assertIn("configurationFile: .github/renovate-global.js", workflow) self.assertIn( @@ -220,7 +226,53 @@ class RenovateAppVersionTests(unittest.TestCase): self.assertIn("RENOVATE_DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}", workflow) self.assertIn("RENOVATE_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}", workflow) self.assertIn("name: Check Docker Hub credentials", workflow) - self.assertIn("Configure DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repository secrets", workflow) + self.assertIn("node .github/scripts/check_dockerhub_credentials.mjs", workflow) + self.assertIn("workflow_dispatch:", preflight) + self.assertIn("node .github/scripts/check_dockerhub_credentials.mjs", preflight) + self.assertIn("https://auth.docker.io/token", checker) + self.assertIn("https://registry-1.docker.io/v2/library/alpine/manifests/latest", checker) + self.assertNotIn("console.log(token", checker) + self.assertNotIn("console.log(password", checker) + + def test_docker_hub_preflight_validates_auth_and_manifest_without_logging_token(self): + checker = REPO_ROOT / ".github" / "scripts" / "check_dockerhub_credentials.mjs" + expression = f""" + import {{ checkDockerHubCredentials }} from {json.dumps(checker.as_uri())}; + const requests = []; + const responses = [ + {{ ok: true, status: 200, json: async () => ({{ token: 'bearer-secret' }}) }}, + {{ + ok: true, + status: 200, + headers: new Headers({{ + 'ratelimit-limit': '200;w=21600', + 'ratelimit-remaining': '199;w=21600', + }}), + }}, + ]; + const result = await checkDockerHubCredentials({{ + username: 'test-user', + password: 'test-password', + fetchImpl: async (url, options) => {{ + requests.push({{ url, authorization: options.headers.authorization }}); + return responses.shift(); + }}, + }}); + console.log(JSON.stringify({{ result, requests }})); + """ + + result = subprocess.run( + ["node", "--input-type=module", "-e", expression], + text=True, + capture_output=True, + check=False, + ) + payload = json.loads(result.stdout) + + self.assertEqual(0, result.returncode) + self.assertEqual("199;w=21600", payload["result"]["remaining"]) + self.assertTrue(payload["requests"][0]["authorization"].startswith("Basic ")) + self.assertEqual("Bearer bearer-secret", payload["requests"][1]["authorization"]) def test_self_hosted_renovate_uses_a_pinned_persistent_cache(self): workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text( diff --git a/.github/workflows/renovate-dockerhub-preflight.yml b/.github/workflows/renovate-dockerhub-preflight.yml new file mode 100644 index 000000000..9c686be0c --- /dev/null +++ b/.github/workflows/renovate-dockerhub-preflight.yml @@ -0,0 +1,20 @@ +name: Renovate Docker Hub credential preflight + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + preflight: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Check Docker Hub credentials + env: + RENOVATE_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RENOVATE_DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: node .github/scripts/check_dockerhub_credentials.mjs diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 91a905277..685029d8d 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -40,11 +40,7 @@ jobs: env: RENOVATE_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} RENOVATE_DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - run: | - if [[ -z "${RENOVATE_DOCKERHUB_USERNAME:-}" || -z "${RENOVATE_DOCKERHUB_TOKEN:-}" ]]; then - echo "::error::Configure DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repository secrets before running Renovate." - exit 1 - fi + run: node .github/scripts/check_dockerhub_credentials.mjs - name: Run Renovate uses: renovatebot/github-action@b50d2ba2bd928235abdcc14d06dfafc217f1c565 # v46.1.18 with: