From b912a19ceefc198f2ac7f8744c62871729231a85 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 May 2026 11:26:11 -0400 Subject: [PATCH 1/7] ci: add manual workflow to validate DOCKERHUB_TOKEN before a build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #714 had to recover from a Forbidden response on the readme push after the DOCKERHUB_TOKEN secret silently lost the right scope. The only ways to confirm a rotation worked were edit OVERVIEW.md and wait for the dockerhub-description workflow to fire, or trigger a full nightly build — both expensive ways to discover the token is still wrong. Add a workflow_dispatch that exercises both auth paths the build workflows depend on: - docker/login-action — registry-level auth (the path image-push uses) - Direct Docker Hub API auth via /v2/users/login/ + /v2/repositories// — the path peter-evans/dockerhub-description uses for PATCH A token can pass the first and fail the second, which is exactly the failure mode #714 had to recover from. Logic lives under the existing release tooling pattern at tools/release/: - src/DockerHubCredentialChecker.php — orchestrates the two HTTP calls - src/DockerHubCredentialCheckResult.php — pure result interpretation (the testable surface, no HTTP mocking required in tests) - src/DockerHubCredentialCheckStatus.php — outcome enum - bin/check-dockerhub-credential.php — Symfony Console one-shot - tests/DockerHubCredentialCheckResultTest.php — covers every status - Taskfile entry: ci:check-dockerhub-credential The workflow becomes checkout → docker login → setup-php + setup-task → `task ci:check-dockerhub-credential`. ext-curl is added to composer requires so composer-require-checker stays clean. Refs #714. Assisted-by: Claude Code --- .../workflows/dockerhub-credential-check.yml | 44 +++++++++ tools/release/Taskfile.yml | 8 ++ .../bin/check-dockerhub-credential.php | 57 +++++++++++ tools/release/composer.json | 1 + tools/release/composer.lock | 3 +- .../src/DockerHubCredentialCheckResult.php | 71 ++++++++++++++ .../src/DockerHubCredentialCheckStatus.php | 21 ++++ .../src/DockerHubCredentialChecker.php | 95 +++++++++++++++++++ .../DockerHubCredentialCheckResultTest.php | 76 +++++++++++++++ 9 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/dockerhub-credential-check.yml create mode 100644 tools/release/bin/check-dockerhub-credential.php create mode 100644 tools/release/src/DockerHubCredentialCheckResult.php create mode 100644 tools/release/src/DockerHubCredentialCheckStatus.php create mode 100644 tools/release/src/DockerHubCredentialChecker.php create mode 100644 tools/release/tests/DockerHubCredentialCheckResultTest.php diff --git a/.github/workflows/dockerhub-credential-check.yml b/.github/workflows/dockerhub-credential-check.yml new file mode 100644 index 00000000..c858938a --- /dev/null +++ b/.github/workflows/dockerhub-credential-check.yml @@ -0,0 +1,44 @@ +name: Docker Hub Credential Check + +# Manual workflow that proves DOCKERHUB_USERNAME / DOCKERHUB_TOKEN are valid +# for the readme-push API path before triggering an expensive build. Issue #714 +# rotated the secret because the readme push was returning Forbidden; this lets +# whoever rotates next confirm the new value works in seconds, without pushing +# an image. +# +# Validates two paths: registry login (used by docker/login-action in the +# build workflows) and the Docker Hub API (used by peter-evans/dockerhub-description +# to PATCH the repo description). A token can pass the first and fail the +# second, which is exactly the failure mode that prompted #714. + +on: + workflow_dispatch: + +permissions: {} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Validate registry login + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + + - name: Install Task + uses: arduino/setup-task@v2 + + - name: Validate Docker Hub API auth + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + working-directory: tools/release + run: task ci:check-dockerhub-credential diff --git a/tools/release/Taskfile.yml b/tools/release/Taskfile.yml index ccb19f89..775df811 100644 --- a/tools/release/Taskfile.yml +++ b/tools/release/Taskfile.yml @@ -153,6 +153,14 @@ tasks: php bin/lint-versions.php --repo={{shellQuote (default "../.." .ROTATE_REPO)}} + ci:check-dockerhub-credential: + desc: Smoke-test DOCKERHUB_USERNAME / DOCKERHUB_TOKEN against the readme-push API path + deps: [setup] + cmds: + - >- + php bin/check-dockerhub-credential.php + {{if .REPOSITORY}}--repository={{shellQuote .REPOSITORY}}{{end}} + release:verify-tag: desc: Verify a release tag is annotated and well-formed per #664 spec requires: diff --git a/tools/release/bin/check-dockerhub-credential.php b/tools/release/bin/check-dockerhub-credential.php new file mode 100644 index 00000000..a2c96ce7 --- /dev/null +++ b/tools/release/bin/check-dockerhub-credential.php @@ -0,0 +1,57 @@ +#!/usr/bin/env php + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +require dirname(__DIR__) . '/vendor/autoload.php'; + +use OpenEMR\Release\DockerHubCredentialChecker; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\SingleCommandApplication; + +(new SingleCommandApplication()) + ->setName('check-dockerhub-credential') + ->setDescription('Validate DOCKERHUB_USERNAME / DOCKERHUB_TOKEN for the readme-push API path') + ->addOption( + 'repository', + null, + InputOption::VALUE_REQUIRED, + 'Docker Hub repository (owner/name)', + 'openemr/openemr', + ) + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $username = getenv('DOCKERHUB_USERNAME'); + $token = getenv('DOCKERHUB_TOKEN'); + if (!is_string($username) || $username === '') { + $output->writeln('::error::DOCKERHUB_USERNAME env var is required'); + return 1; + } + if (!is_string($token) || $token === '') { + $output->writeln('::error::DOCKERHUB_TOKEN env var is required'); + return 1; + } + /** @var string $repository */ + $repository = $input->getOption('repository'); + + $result = (new DockerHubCredentialChecker())->check($username, $token, $repository); + $output->writeln($result->toGithubActionsLine()); + return $result->isOk() ? 0 : 1; + }) + ->run(); diff --git a/tools/release/composer.json b/tools/release/composer.json index f0e743dc..ee2618d1 100644 --- a/tools/release/composer.json +++ b/tools/release/composer.json @@ -5,6 +5,7 @@ "license": "GPL-2.0-or-later", "require": { "php": "^8.5", + "ext-curl": "*", "ext-mbstring": "*", "nikic/php-parser": "^5.0", "symfony/console": "^7.0", diff --git a/tools/release/composer.lock b/tools/release/composer.lock index 884dd3be..f74a7f8e 100644 --- a/tools/release/composer.lock +++ b/tools/release/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "eeeeeebec849bc51c28929c3abd7fdd4", + "content-hash": "bd0efe5b5b3aae4391277ea8d769414e", "packages": [ { "name": "nikic/php-parser", @@ -3943,6 +3943,7 @@ "prefer-lowest": false, "platform": { "php": "^8.5", + "ext-curl": "*", "ext-mbstring": "*" }, "platform-dev": {}, diff --git a/tools/release/src/DockerHubCredentialCheckResult.php b/tools/release/src/DockerHubCredentialCheckResult.php new file mode 100644 index 00000000..1a3973d8 --- /dev/null +++ b/tools/release/src/DockerHubCredentialCheckResult.php @@ -0,0 +1,71 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release; + +final readonly class DockerHubCredentialCheckResult +{ + public function __construct( + public DockerHubCredentialCheckStatus $status, + public string $repository, + public ?int $httpStatus = null, + ) { + } + + /** + * Map raw HTTP responses from the Docker Hub login + repository endpoints + * to a result. Pure: no network. Tested directly. + */ + public static function interpret(string $repository, ?string $jwt, ?int $repoStatus): self + { + if (in_array($jwt, [null, '', 'null'], true)) { + return new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository); + } + return match ($repoStatus) { + 200 => new self(DockerHubCredentialCheckStatus::OK, $repository, 200), + 403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403), + default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $repoStatus), + }; + } + + public function isOk(): bool + { + return $this->status === DockerHubCredentialCheckStatus::OK; + } + + /** + * Format as a single GitHub-Actions workflow command line + * (`::error::…` or `::notice::…`). + */ + public function toGithubActionsLine(): string + { + return match ($this->status) { + DockerHubCredentialCheckStatus::OK => sprintf( + "::notice::Credential is valid for %s (read access confirmed).", + $this->repository, + ), + DockerHubCredentialCheckStatus::INVALID_CREDENTIAL => + '::error::Login returned no JWT — DOCKERHUB_USERNAME / DOCKERHUB_TOKEN appear invalid.', + DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE => sprintf( + "::error::Login succeeded but the token lacks access to %s. Verify R/W/D scope.", + $this->repository, + ), + DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE => sprintf( + "::error::Unexpected HTTP %s from Docker Hub API for %s.", + $this->httpStatus ?? '(unknown)', + $this->repository, + ), + }; + } +} diff --git a/tools/release/src/DockerHubCredentialCheckStatus.php b/tools/release/src/DockerHubCredentialCheckStatus.php new file mode 100644 index 00000000..98180a2f --- /dev/null +++ b/tools/release/src/DockerHubCredentialCheckStatus.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release; + +enum DockerHubCredentialCheckStatus: string +{ + case OK = 'ok'; + case INVALID_CREDENTIAL = 'invalid_credential'; + case INSUFFICIENT_SCOPE = 'insufficient_scope'; + case UNEXPECTED_RESPONSE = 'unexpected_response'; +} diff --git a/tools/release/src/DockerHubCredentialChecker.php b/tools/release/src/DockerHubCredentialChecker.php new file mode 100644 index 00000000..66ba513c --- /dev/null +++ b/tools/release/src/DockerHubCredentialChecker.php @@ -0,0 +1,95 @@ +/). + * + * Distinguishes "bad credential" from "credential lacks scope on this repo" — + * a token can pass docker login (registry auth) and still 403 on the API + * path, which is exactly the failure mode openemr/openemr-devops#714 had to + * recover from. + * + * @package openemr-devops + * @link https://www.open-emr.org + * @author Michael A. Smith + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release; + +final readonly class DockerHubCredentialChecker +{ + private const DEFAULT_API_BASE = 'https://hub.docker.com/v2'; + + public function __construct( + private string $apiBase = self::DEFAULT_API_BASE, + ) { + } + + public function check(string $username, string $token, string $repository): DockerHubCredentialCheckResult + { + $jwt = $this->mintJwt($username, $token); + $repoStatus = $jwt !== null + ? $this->probeRepository($jwt, $repository) + : null; + return DockerHubCredentialCheckResult::interpret($repository, $jwt, $repoStatus); + } + + private function mintJwt(string $username, string $token): ?string + { + $body = json_encode(['username' => $username, 'password' => $token], JSON_THROW_ON_ERROR); + [$status, $responseBody] = $this->httpRequest('POST', $this->apiBase . '/users/login/', [ + 'Content-Type: application/json', + ], $body); + + if ($status !== 200) { + return null; + } + $decoded = json_decode($responseBody, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($decoded) || !isset($decoded['token']) || !is_string($decoded['token'])) { + return null; + } + return $decoded['token']; + } + + private function probeRepository(string $jwt, string $repository): int + { + [$status] = $this->httpRequest('GET', $this->apiBase . '/repositories/' . $repository . '/', [ + 'Authorization: JWT ' . $jwt, + ]); + return $status; + } + + /** + * @param non-empty-string $method + * @param list $headers + * @return array{int, string} + */ + private function httpRequest(string $method, string $url, array $headers, ?string $body = null): array + { + $ch = curl_init($url); + if ($ch === false) { + throw new \RuntimeException('curl_init failed'); + } + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 10, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + if ($response === false) { + $error = curl_error($ch); + throw new \RuntimeException("curl error for {$method} {$url}: {$error}"); + } + /** @var int $status */ + $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + return [$status, is_string($response) ? $response : '']; + } +} diff --git a/tools/release/tests/DockerHubCredentialCheckResultTest.php b/tools/release/tests/DockerHubCredentialCheckResultTest.php new file mode 100644 index 00000000..8dae2360 --- /dev/null +++ b/tools/release/tests/DockerHubCredentialCheckResultTest.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc. + * @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3 + */ + +declare(strict_types=1); + +namespace OpenEMR\Release\Tests; + +use OpenEMR\Release\DockerHubCredentialCheckResult; +use OpenEMR\Release\DockerHubCredentialCheckStatus; +use PHPUnit\Framework\TestCase; + +final class DockerHubCredentialCheckResultTest extends TestCase +{ + public function testInterpretReturnsInvalidCredentialWhenJwtMissing(): void + { + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', null, null); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + self::assertFalse($result->isOk()); + self::assertStringContainsString('::error::Login returned no JWT', $result->toGithubActionsLine()); + } + + public function testInterpretRejectsLiteralStringNullJwt(): void + { + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'null', null); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + } + + public function testInterpretReturnsOkOnHttp200(): void + { + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200); + + self::assertSame(DockerHubCredentialCheckStatus::OK, $result->status); + self::assertTrue($result->isOk()); + self::assertSame(200, $result->httpStatus); + self::assertStringContainsString( + '::notice::Credential is valid for openemr/openemr', + $result->toGithubActionsLine(), + ); + } + + public function testInterpretReturnsInsufficientScopeOnHttp403(): void + { + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 403); + + self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); + self::assertFalse($result->isOk()); + self::assertStringContainsString('lacks access to openemr/openemr', $result->toGithubActionsLine()); + self::assertStringContainsString('R/W/D scope', $result->toGithubActionsLine()); + } + + public function testInterpretReturnsUnexpectedForOtherStatus(): void + { + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 500); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + self::assertSame(500, $result->httpStatus); + self::assertStringContainsString('Unexpected HTTP 500', $result->toGithubActionsLine()); + } + + public function testGithubActionsLineIsSingleLine(): void + { + foreach (DockerHubCredentialCheckStatus::cases() as $status) { + $result = new DockerHubCredentialCheckResult($status, 'openemr/openemr', 200); + self::assertStringNotContainsString("\n", $result->toGithubActionsLine(), $status->value); + } + } +} From 9efb48790963dbfaafca4d3aac1f8791031350b5 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 May 2026 11:38:38 -0400 Subject: [PATCH 2/7] ci: probe write scope via no-op PATCH, not just GET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: GET /v2/repositories// only confirms read access. A read-only token would still pass that check but 403 on the actual PATCH the readme push uses, producing a false positive. Read the current description / full_description, then PATCH the same values back. That exercises the exact endpoint peter-evans/dockerhub-description uses, so the smoke test now fails for a token that lacks write scope. Side effect: the no-op PATCH bumps the repo's last-modified timestamp on Docker Hub — the real readme push already does this every run, so nothing novel. Update interpret() to take separate read and write statuses; add a test covering the read-OK / write-403 path. Assisted-by: Claude Code --- .../workflows/dockerhub-credential-check.yml | 6 +++ .../src/DockerHubCredentialCheckResult.php | 34 +++++++++---- .../src/DockerHubCredentialChecker.php | 51 ++++++++++++++++--- .../DockerHubCredentialCheckResultTest.php | 42 +++++++++------ 4 files changed, 102 insertions(+), 31 deletions(-) diff --git a/.github/workflows/dockerhub-credential-check.yml b/.github/workflows/dockerhub-credential-check.yml index c858938a..ab00d5f8 100644 --- a/.github/workflows/dockerhub-credential-check.yml +++ b/.github/workflows/dockerhub-credential-check.yml @@ -10,6 +10,12 @@ name: Docker Hub Credential Check # build workflows) and the Docker Hub API (used by peter-evans/dockerhub-description # to PATCH the repo description). A token can pass the first and fail the # second, which is exactly the failure mode that prompted #714. +# +# The API check writes the current description back to itself via a no-op PATCH +# so it actually exercises the write/delete scope peter-evans uses; a read-only +# token would 200 on a GET but 403 here. Side effect: bumps Docker Hub's +# last-modified timestamp on the repo (the same side effect the real readme +# push already produces every time it runs, so nothing novel). on: workflow_dispatch: diff --git a/tools/release/src/DockerHubCredentialCheckResult.php b/tools/release/src/DockerHubCredentialCheckResult.php index 1a3973d8..0680d1e2 100644 --- a/tools/release/src/DockerHubCredentialCheckResult.php +++ b/tools/release/src/DockerHubCredentialCheckResult.php @@ -24,18 +24,32 @@ public function __construct( } /** - * Map raw HTTP responses from the Docker Hub login + repository endpoints - * to a result. Pure: no network. Tested directly. + * Map raw HTTP responses from Docker Hub's login + repository read + + * repository write probes to a result. Pure: no network. Tested directly. + * + * - $jwt is the token returned by /v2/users/login/ (null on auth failure) + * - $readStatus is the GET /v2/repositories// status (null if we + * never reached that step) + * - $writeStatus is the no-op PATCH /v2/repositories// status + * (null if we couldn't read the existing description and so couldn't + * send a no-op write) */ - public static function interpret(string $repository, ?string $jwt, ?int $repoStatus): self - { + public static function interpret( + string $repository, + ?string $jwt, + ?int $readStatus, + ?int $writeStatus, + ): self { if (in_array($jwt, [null, '', 'null'], true)) { return new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository); } - return match ($repoStatus) { + if ($readStatus !== 200) { + return new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, $readStatus); + } + return match ($writeStatus) { 200 => new self(DockerHubCredentialCheckStatus::OK, $repository, 200), 403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403), - default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $repoStatus), + default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $writeStatus), }; } @@ -52,17 +66,19 @@ public function toGithubActionsLine(): string { return match ($this->status) { DockerHubCredentialCheckStatus::OK => sprintf( - "::notice::Credential is valid for %s (read access confirmed).", + '::notice::Credential is valid for %s (read + no-op write confirmed).', $this->repository, ), DockerHubCredentialCheckStatus::INVALID_CREDENTIAL => '::error::Login returned no JWT — DOCKERHUB_USERNAME / DOCKERHUB_TOKEN appear invalid.', DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE => sprintf( - "::error::Login succeeded but the token lacks access to %s. Verify R/W/D scope.", + '::error::Login succeeded but the token lacks required scope on %s (HTTP %s). ' + . 'Verify R/W/D scope on this repository.', $this->repository, + $this->httpStatus ?? '(unknown)', ), DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE => sprintf( - "::error::Unexpected HTTP %s from Docker Hub API for %s.", + '::error::Unexpected HTTP %s from Docker Hub API for %s.', $this->httpStatus ?? '(unknown)', $this->repository, ), diff --git a/tools/release/src/DockerHubCredentialChecker.php b/tools/release/src/DockerHubCredentialChecker.php index 66ba513c..11103faf 100644 --- a/tools/release/src/DockerHubCredentialChecker.php +++ b/tools/release/src/DockerHubCredentialChecker.php @@ -9,6 +9,13 @@ * path, which is exactly the failure mode openemr/openemr-devops#714 had to * recover from. * + * Verifies write scope by reading the current repo description fields and + * issuing a no-op PATCH that writes the same values back. That exercises the + * exact endpoint peter-evans/dockerhub-description uses; a read-only token + * passes the GET but 403s on the PATCH. Side effect: bumps Docker Hub's + * last-modified timestamp on the repo. The actual readme push does the same + * thing every time it runs, so this is not a novel side effect. + * * @package openemr-devops * @link https://www.open-emr.org * @author Michael A. Smith @@ -32,10 +39,17 @@ public function __construct( public function check(string $username, string $token, string $repository): DockerHubCredentialCheckResult { $jwt = $this->mintJwt($username, $token); - $repoStatus = $jwt !== null - ? $this->probeRepository($jwt, $repository) - : null; - return DockerHubCredentialCheckResult::interpret($repository, $jwt, $repoStatus); + if ($jwt === null) { + return DockerHubCredentialCheckResult::interpret($repository, null, null, null); + } + + [$readStatus, $descriptions] = $this->fetchDescriptions($jwt, $repository); + if ($readStatus !== 200 || $descriptions === null) { + return DockerHubCredentialCheckResult::interpret($repository, $jwt, $readStatus, null); + } + + $writeStatus = $this->probeWrite($jwt, $repository, $descriptions); + return DockerHubCredentialCheckResult::interpret($repository, $jwt, $readStatus, $writeStatus); } private function mintJwt(string $username, string $token): ?string @@ -55,11 +69,36 @@ private function mintJwt(string $username, string $token): ?string return $decoded['token']; } - private function probeRepository(string $jwt, string $repository): int + /** + * @return array{int, ?array{description: string, full_description: string}} + */ + private function fetchDescriptions(string $jwt, string $repository): array { - [$status] = $this->httpRequest('GET', $this->apiBase . '/repositories/' . $repository . '/', [ + [$status, $body] = $this->httpRequest('GET', $this->apiBase . '/repositories/' . $repository . '/', [ 'Authorization: JWT ' . $jwt, ]); + if ($status !== 200) { + return [$status, null]; + } + $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + return [$status, null]; + } + $description = is_string($decoded['description'] ?? null) ? $decoded['description'] : ''; + $fullDescription = is_string($decoded['full_description'] ?? null) ? $decoded['full_description'] : ''; + return [$status, ['description' => $description, 'full_description' => $fullDescription]]; + } + + /** + * @param array{description: string, full_description: string} $descriptions + */ + private function probeWrite(string $jwt, string $repository, array $descriptions): int + { + $body = json_encode($descriptions, JSON_THROW_ON_ERROR); + [$status] = $this->httpRequest('PATCH', $this->apiBase . '/repositories/' . $repository . '/', [ + 'Authorization: JWT ' . $jwt, + 'Content-Type: application/json', + ], $body); return $status; } diff --git a/tools/release/tests/DockerHubCredentialCheckResultTest.php b/tools/release/tests/DockerHubCredentialCheckResultTest.php index 8dae2360..f6fa1d53 100644 --- a/tools/release/tests/DockerHubCredentialCheckResultTest.php +++ b/tools/release/tests/DockerHubCredentialCheckResultTest.php @@ -20,7 +20,7 @@ final class DockerHubCredentialCheckResultTest extends TestCase { public function testInterpretReturnsInvalidCredentialWhenJwtMissing(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', null, null); + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', null, null, null); self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); self::assertFalse($result->isOk()); @@ -29,14 +29,33 @@ public function testInterpretReturnsInvalidCredentialWhenJwtMissing(): void public function testInterpretRejectsLiteralStringNullJwt(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'null', null); + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'null', null, null); self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); } - public function testInterpretReturnsOkOnHttp200(): void + public function testInterpretReturnsInsufficientScopeWhenReadFails(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200); + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 403, null); + + self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); + self::assertSame(403, $result->httpStatus); + self::assertStringContainsString('lacks required scope on openemr/openemr', $result->toGithubActionsLine()); + self::assertStringContainsString('HTTP 403', $result->toGithubActionsLine()); + } + + public function testInterpretReturnsInsufficientScopeWhenReadSucceedsButWriteIs403(): void + { + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200, 403); + + self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); + self::assertSame(403, $result->httpStatus, 'httpStatus reflects the failing probe (write)'); + self::assertStringContainsString('R/W/D scope', $result->toGithubActionsLine()); + } + + public function testInterpretReturnsOkWhenBothReadAndWriteSucceed(): void + { + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200, 200); self::assertSame(DockerHubCredentialCheckStatus::OK, $result->status); self::assertTrue($result->isOk()); @@ -45,21 +64,12 @@ public function testInterpretReturnsOkOnHttp200(): void '::notice::Credential is valid for openemr/openemr', $result->toGithubActionsLine(), ); + self::assertStringContainsString('read + no-op write confirmed', $result->toGithubActionsLine()); } - public function testInterpretReturnsInsufficientScopeOnHttp403(): void - { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 403); - - self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); - self::assertFalse($result->isOk()); - self::assertStringContainsString('lacks access to openemr/openemr', $result->toGithubActionsLine()); - self::assertStringContainsString('R/W/D scope', $result->toGithubActionsLine()); - } - - public function testInterpretReturnsUnexpectedForOtherStatus(): void + public function testInterpretReturnsUnexpectedForOtherWriteStatus(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 500); + $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200, 500); self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); self::assertSame(500, $result->httpStatus); From 68300467a2490a397b5184bc9fea3e84755e72c6 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 May 2026 11:58:38 -0400 Subject: [PATCH 3/7] ci: classify Docker Hub responses precisely; tolerate transport/parse errors Per review feedback: - Non-200 GET / non-200 PATCH responses were always classified as INSUFFICIENT_SCOPE, which would mislabel a 500/404/rate-limit as a permissions issue and prompt unnecessary secret rotation. Map only 401/403 to permission failures (INVALID_CREDENTIAL on login, INSUFFICIENT_SCOPE on access). Everything else routes to UNEXPECTED_RESPONSE with the actual HTTP status surfaced. - json_decode + JSON_THROW_ON_ERROR could escape the checker on a non-JSON response (HTML error page from a CDN/WAF, partial body, etc.) and abort the workflow without the intended `::error::` line. Catch JsonException internally and signal "unparseable response" through the existing return tuple. Wrap each step in check() in a try/catch for transport-level RuntimeException and route through a new NETWORK_ERROR status so the workflow always emits exactly one diagnostic line. interpret() now takes the full set of step inputs (login status, jwt, read status, descriptions-parsed flag, write status) and routes via small private factories. Tests cover every status branch (93 tests total, 16 specific to this class). Assisted-by: Claude Code --- .../src/DockerHubCredentialCheckResult.php | 92 ++++++++++++--- .../src/DockerHubCredentialCheckStatus.php | 1 + .../src/DockerHubCredentialChecker.php | 101 +++++++++++++--- .../DockerHubCredentialCheckResultTest.php | 108 ++++++++++++++---- 4 files changed, 247 insertions(+), 55 deletions(-) diff --git a/tools/release/src/DockerHubCredentialCheckResult.php b/tools/release/src/DockerHubCredentialCheckResult.php index 0680d1e2..31cbb1b0 100644 --- a/tools/release/src/DockerHubCredentialCheckResult.php +++ b/tools/release/src/DockerHubCredentialCheckResult.php @@ -20,37 +20,49 @@ public function __construct( public DockerHubCredentialCheckStatus $status, public string $repository, public ?int $httpStatus = null, + public ?string $detail = null, ) { } /** - * Map raw HTTP responses from Docker Hub's login + repository read + + * Map raw HTTP statuses from Docker Hub's login + repository read + * repository write probes to a result. Pure: no network. Tested directly. * - * - $jwt is the token returned by /v2/users/login/ (null on auth failure) - * - $readStatus is the GET /v2/repositories// status (null if we - * never reached that step) + * - $loginStatus is the POST /v2/users/login/ HTTP status (null if the + * request itself failed at the transport layer) + * - $jwt is the token extracted from the login response (null if the + * response was unparseable JSON or missing the token field) + * - $readStatus is the GET /v2/repositories// status (null if the + * step was not reached) + * - $descriptionsParsed is whether the GET response body was usable JSON + * with the expected fields (null if read step not reached) * - $writeStatus is the no-op PATCH /v2/repositories// status - * (null if we couldn't read the existing description and so couldn't - * send a no-op write) + * (null if the step was not reached) */ public static function interpret( string $repository, + ?int $loginStatus, ?string $jwt, ?int $readStatus, + ?bool $descriptionsParsed, ?int $writeStatus, ): self { + if ($loginStatus === null) { + return new self(DockerHubCredentialCheckStatus::NETWORK_ERROR, $repository); + } if (in_array($jwt, [null, '', 'null'], true)) { - return new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository); + return self::fromAuthFailure($repository, $loginStatus); } - if ($readStatus !== 200) { - return new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, $readStatus); + if ($readStatus === null) { + return new self(DockerHubCredentialCheckStatus::NETWORK_ERROR, $repository); } - return match ($writeStatus) { - 200 => new self(DockerHubCredentialCheckStatus::OK, $repository, 200), - 403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403), - default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $writeStatus), - }; + if ($readStatus !== 200 || $descriptionsParsed !== true) { + return self::fromAccessFailure($repository, $readStatus); + } + if ($writeStatus === null) { + return new self(DockerHubCredentialCheckStatus::NETWORK_ERROR, $repository); + } + return self::fromWriteStatus($repository, $writeStatus); } public function isOk(): bool @@ -70,18 +82,62 @@ public function toGithubActionsLine(): string $this->repository, ), DockerHubCredentialCheckStatus::INVALID_CREDENTIAL => - '::error::Login returned no JWT — DOCKERHUB_USERNAME / DOCKERHUB_TOKEN appear invalid.', + '::error::Login failed (HTTP ' . $this->httpStatusOrUnknown() + . ') — DOCKERHUB_USERNAME / DOCKERHUB_TOKEN appear invalid.', DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE => sprintf( '::error::Login succeeded but the token lacks required scope on %s (HTTP %s). ' . 'Verify R/W/D scope on this repository.', $this->repository, - $this->httpStatus ?? '(unknown)', + $this->httpStatusOrUnknown(), ), DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE => sprintf( - '::error::Unexpected HTTP %s from Docker Hub API for %s.', - $this->httpStatus ?? '(unknown)', + '::error::Unexpected response from Docker Hub API for %s (HTTP %s). %s', + $this->repository, + $this->httpStatusOrUnknown(), + $this->detail ?? 'Re-run, check status.docker.com, then re-evaluate.', + ), + DockerHubCredentialCheckStatus::NETWORK_ERROR => sprintf( + '::error::Could not reach Docker Hub API for %s. %s', $this->repository, + $this->detail ?? 'Re-run, check status.docker.com, then re-evaluate.', ), }; } + + private static function fromAuthFailure(string $repository, int $loginStatus): self + { + return match (true) { + in_array($loginStatus, [401, 403], true) => + new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository, $loginStatus), + default => + new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $loginStatus), + }; + } + + private static function fromAccessFailure(string $repository, int $readStatus): self + { + return match (true) { + in_array($readStatus, [401, 403], true) => + new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, $readStatus), + default => + new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $readStatus), + }; + } + + private static function fromWriteStatus(string $repository, int $writeStatus): self + { + return match (true) { + $writeStatus === 200 => + new self(DockerHubCredentialCheckStatus::OK, $repository, 200), + in_array($writeStatus, [401, 403], true) => + new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, $writeStatus), + default => + new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $writeStatus), + }; + } + + private function httpStatusOrUnknown(): string + { + return $this->httpStatus !== null ? (string) $this->httpStatus : '(unknown)'; + } } diff --git a/tools/release/src/DockerHubCredentialCheckStatus.php b/tools/release/src/DockerHubCredentialCheckStatus.php index 98180a2f..64da3e70 100644 --- a/tools/release/src/DockerHubCredentialCheckStatus.php +++ b/tools/release/src/DockerHubCredentialCheckStatus.php @@ -18,4 +18,5 @@ enum DockerHubCredentialCheckStatus: string case INVALID_CREDENTIAL = 'invalid_credential'; case INSUFFICIENT_SCOPE = 'insufficient_scope'; case UNEXPECTED_RESPONSE = 'unexpected_response'; + case NETWORK_ERROR = 'network_error'; } diff --git a/tools/release/src/DockerHubCredentialChecker.php b/tools/release/src/DockerHubCredentialChecker.php index 11103faf..b680c433 100644 --- a/tools/release/src/DockerHubCredentialChecker.php +++ b/tools/release/src/DockerHubCredentialChecker.php @@ -16,6 +16,11 @@ * last-modified timestamp on the repo. The actual readme push does the same * thing every time it runs, so this is not a novel side effect. * + * Internally tolerant of transport errors and unparseable responses (HTML + * error pages, partial JSON, etc.) — both surface as a structured + * DockerHubCredentialCheckResult so the workflow always emits exactly one + * `::error::` or `::notice::` line. + * * @package openemr-devops * @link https://www.open-emr.org * @author Michael A. Smith @@ -38,35 +43,93 @@ public function __construct( public function check(string $username, string $token, string $repository): DockerHubCredentialCheckResult { - $jwt = $this->mintJwt($username, $token); - if ($jwt === null) { - return DockerHubCredentialCheckResult::interpret($repository, null, null, null); + try { + [$loginStatus, $jwt] = $this->mintJwt($username, $token); + } catch (\RuntimeException $e) { + return new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + $repository, + detail: $e->getMessage(), + ); + } + if ($loginStatus !== 200 || $jwt === null) { + return DockerHubCredentialCheckResult::interpret( + $repository, + $loginStatus, + $jwt, + null, + null, + null, + ); } - [$readStatus, $descriptions] = $this->fetchDescriptions($jwt, $repository); + try { + [$readStatus, $descriptions] = $this->fetchDescriptions($jwt, $repository); + } catch (\RuntimeException $e) { + return new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + $repository, + detail: $e->getMessage(), + ); + } if ($readStatus !== 200 || $descriptions === null) { - return DockerHubCredentialCheckResult::interpret($repository, $jwt, $readStatus, null); + return DockerHubCredentialCheckResult::interpret( + $repository, + $loginStatus, + $jwt, + $readStatus, + $descriptions !== null, + null, + ); + } + + try { + $writeStatus = $this->probeWrite($jwt, $repository, $descriptions); + } catch (\RuntimeException $e) { + return new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + $repository, + detail: $e->getMessage(), + ); } - $writeStatus = $this->probeWrite($jwt, $repository, $descriptions); - return DockerHubCredentialCheckResult::interpret($repository, $jwt, $readStatus, $writeStatus); + return DockerHubCredentialCheckResult::interpret( + $repository, + $loginStatus, + $jwt, + $readStatus, + true, + $writeStatus, + ); } - private function mintJwt(string $username, string $token): ?string + /** + * @return array{int, ?string} + */ + private function mintJwt(string $username, string $token): array { - $body = json_encode(['username' => $username, 'password' => $token], JSON_THROW_ON_ERROR); + try { + $body = json_encode(['username' => $username, 'password' => $token], JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + // Encoding our own input shouldn't fail; treat as transport-level. + throw new \RuntimeException('failed to encode login payload: ' . $e->getMessage(), 0, $e); + } [$status, $responseBody] = $this->httpRequest('POST', $this->apiBase . '/users/login/', [ 'Content-Type: application/json', ], $body); if ($status !== 200) { - return null; + return [$status, null]; + } + try { + $decoded = json_decode($responseBody, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return [$status, null]; } - $decoded = json_decode($responseBody, true, flags: JSON_THROW_ON_ERROR); if (!is_array($decoded) || !isset($decoded['token']) || !is_string($decoded['token'])) { - return null; + return [$status, null]; } - return $decoded['token']; + return [$status, $decoded['token']]; } /** @@ -80,7 +143,11 @@ private function fetchDescriptions(string $jwt, string $repository): array if ($status !== 200) { return [$status, null]; } - $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + try { + $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return [$status, null]; + } if (!is_array($decoded)) { return [$status, null]; } @@ -94,7 +161,11 @@ private function fetchDescriptions(string $jwt, string $repository): array */ private function probeWrite(string $jwt, string $repository, array $descriptions): int { - $body = json_encode($descriptions, JSON_THROW_ON_ERROR); + try { + $body = json_encode($descriptions, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException('failed to encode patch payload: ' . $e->getMessage(), 0, $e); + } [$status] = $this->httpRequest('PATCH', $this->apiBase . '/repositories/' . $repository . '/', [ 'Authorization: JWT ' . $jwt, 'Content-Type: application/json', diff --git a/tools/release/tests/DockerHubCredentialCheckResultTest.php b/tools/release/tests/DockerHubCredentialCheckResultTest.php index f6fa1d53..29aa0fda 100644 --- a/tools/release/tests/DockerHubCredentialCheckResultTest.php +++ b/tools/release/tests/DockerHubCredentialCheckResultTest.php @@ -18,68 +18,132 @@ final class DockerHubCredentialCheckResultTest extends TestCase { - public function testInterpretReturnsInvalidCredentialWhenJwtMissing(): void + private const REPO = 'openemr/openemr'; + + public function testNetworkErrorWhenLoginStatusUnknown(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, null, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::NETWORK_ERROR, $result->status); + self::assertStringContainsString('Could not reach Docker Hub', $result->toGithubActionsLine()); + } + + public function testInvalidCredentialOnLogin401(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', null, null, null); + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 401, null, null, null, null); self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); - self::assertFalse($result->isOk()); - self::assertStringContainsString('::error::Login returned no JWT', $result->toGithubActionsLine()); + self::assertSame(401, $result->httpStatus); + self::assertStringContainsString('HTTP 401', $result->toGithubActionsLine()); } - public function testInterpretRejectsLiteralStringNullJwt(): void + public function testInvalidCredentialOnLogin403(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'null', null, null); + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 403, null, null, null, null); self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); } - public function testInterpretReturnsInsufficientScopeWhenReadFails(): void + public function testUnexpectedResponseOnLogin500(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 500, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + self::assertSame(500, $result->httpStatus); + self::assertStringContainsString('HTTP 500', $result->toGithubActionsLine()); + } + + public function testUnexpectedResponseWhenLogin200ButNoJwt(): void + { + // Server returned 200 but the body wasn't parseable JSON or lacked the token field. + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, null, null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testRejectsLiteralStringNullJwt(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'null', null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testNetworkErrorWhenReadStatusUnknown(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', null, null, null); + + self::assertSame(DockerHubCredentialCheckStatus::NETWORK_ERROR, $result->status); + } + + public function testInsufficientScopeOnRead403(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 403, null); + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 403, false, null); self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); self::assertSame(403, $result->httpStatus); - self::assertStringContainsString('lacks required scope on openemr/openemr', $result->toGithubActionsLine()); - self::assertStringContainsString('HTTP 403', $result->toGithubActionsLine()); } - public function testInterpretReturnsInsufficientScopeWhenReadSucceedsButWriteIs403(): void + public function testUnexpectedResponseOnRead404(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 404, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + self::assertSame(404, $result->httpStatus); + } + + public function testUnexpectedResponseOnRead500(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 500, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testUnexpectedResponseWhenRead200ButUnparseable(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); + } + + public function testNetworkErrorWhenWriteStatusUnknown(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, null); + + self::assertSame(DockerHubCredentialCheckStatus::NETWORK_ERROR, $result->status); + } + + public function testInsufficientScopeWhenReadOkButWrite403(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200, 403); + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 403); self::assertSame(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $result->status); self::assertSame(403, $result->httpStatus, 'httpStatus reflects the failing probe (write)'); self::assertStringContainsString('R/W/D scope', $result->toGithubActionsLine()); } - public function testInterpretReturnsOkWhenBothReadAndWriteSucceed(): void + public function testOkWhenAllStepsSucceed(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200, 200); + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 200); self::assertSame(DockerHubCredentialCheckStatus::OK, $result->status); self::assertTrue($result->isOk()); self::assertSame(200, $result->httpStatus); - self::assertStringContainsString( - '::notice::Credential is valid for openemr/openemr', - $result->toGithubActionsLine(), - ); + self::assertStringContainsString('::notice::Credential is valid', $result->toGithubActionsLine()); self::assertStringContainsString('read + no-op write confirmed', $result->toGithubActionsLine()); } - public function testInterpretReturnsUnexpectedForOtherWriteStatus(): void + public function testUnexpectedResponseOnWrite500(): void { - $result = DockerHubCredentialCheckResult::interpret('openemr/openemr', 'jwt-value', 200, 500); + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 500); self::assertSame(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $result->status); self::assertSame(500, $result->httpStatus); - self::assertStringContainsString('Unexpected HTTP 500', $result->toGithubActionsLine()); } public function testGithubActionsLineIsSingleLine(): void { foreach (DockerHubCredentialCheckStatus::cases() as $status) { - $result = new DockerHubCredentialCheckResult($status, 'openemr/openemr', 200); + $result = new DockerHubCredentialCheckResult($status, self::REPO, 200, 'detail'); self::assertStringNotContainsString("\n", $result->toGithubActionsLine(), $status->value); } } From a5b9bb9ca9e4d3bd439a0194914bd44fba7ee91d Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 May 2026 12:09:29 -0400 Subject: [PATCH 4/7] ci: treat missing description fields as a parse failure Per review feedback: substituting empty strings for missing `description` / `full_description` and PATCHing them back would clear the live Docker Hub repo description if the API shape ever changed (field renamed, becomes nullable, etc.). Strict-check both fields. If either is missing or non-string, fetchDescriptions() returns null, which interpret() routes to UNEXPECTED_RESPONSE without issuing the PATCH. Better to surface a diagnostic than silently overwrite real content with empty strings. Already-existing test (testUnexpectedResponseWhenRead200ButUnparseable) covers the resulting interpret() path end-to-end. Assisted-by: Claude Code --- tools/release/src/DockerHubCredentialChecker.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/release/src/DockerHubCredentialChecker.php b/tools/release/src/DockerHubCredentialChecker.php index b680c433..6c0a183a 100644 --- a/tools/release/src/DockerHubCredentialChecker.php +++ b/tools/release/src/DockerHubCredentialChecker.php @@ -151,8 +151,16 @@ private function fetchDescriptions(string $jwt, string $repository): array if (!is_array($decoded)) { return [$status, null]; } - $description = is_string($decoded['description'] ?? null) ? $decoded['description'] : ''; - $fullDescription = is_string($decoded['full_description'] ?? null) ? $decoded['full_description'] : ''; + // Strict: if either field is missing or not a string, treat as a parse + // failure rather than substituting empty strings. Substituting and then + // PATCHing would clear the live description if the API shape ever + // changed (e.g. description becomes nullable or gets renamed). Better + // to surface UNEXPECTED_RESPONSE than to write garbage back. + $description = $decoded['description'] ?? null; + $fullDescription = $decoded['full_description'] ?? null; + if (!is_string($description) || !is_string($fullDescription)) { + return [$status, null]; + } return [$status, ['description' => $description, 'full_description' => $fullDescription]]; } From fbf58a64bbf9bb7e4f58f27e8f0dfab321e434bc Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 May 2026 12:17:30 -0400 Subject: [PATCH 5/7] ci: prevent workflow-command injection via repository / detail strings Per review feedback: toGithubActionsLine() interpolated repository and detail directly into the `::error::` / `::notice::` workflow command. GitHub Actions parses workflow commands per-line, so an embedded newline could inject a second command (e.g. set-output, set-env). Two-layer defense: - bin/check-dockerhub-credential.php now validates --repository against the standard Docker Hub owner/name pattern up front, rejecting anything else with a clear error. - DockerHubCredentialCheckResult constructor scrubs CR/LF from repository and detail before storing them. Belt-and-braces, so any future caller that bypasses bin still gets a safe single-line render. Tests cover that repository and detail with embedded \r\n produce single-line output with no carriage returns or newlines. Assisted-by: Claude Code --- .../bin/check-dockerhub-credential.php | 4 +++ .../src/DockerHubCredentialCheckResult.php | 27 ++++++++++++--- .../DockerHubCredentialCheckResultTest.php | 33 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/tools/release/bin/check-dockerhub-credential.php b/tools/release/bin/check-dockerhub-credential.php index a2c96ce7..ef4243f0 100644 --- a/tools/release/bin/check-dockerhub-credential.php +++ b/tools/release/bin/check-dockerhub-credential.php @@ -49,6 +49,10 @@ } /** @var string $repository */ $repository = $input->getOption('repository'); + if (preg_match('#^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+$#', $repository) !== 1) { + $output->writeln('::error::--repository must match owner/name (alphanumeric, dot, underscore, dash).'); + return 1; + } $result = (new DockerHubCredentialChecker())->check($username, $token, $repository); $output->writeln($result->toGithubActionsLine()); diff --git a/tools/release/src/DockerHubCredentialCheckResult.php b/tools/release/src/DockerHubCredentialCheckResult.php index 31cbb1b0..1c0f1264 100644 --- a/tools/release/src/DockerHubCredentialCheckResult.php +++ b/tools/release/src/DockerHubCredentialCheckResult.php @@ -16,12 +16,31 @@ final readonly class DockerHubCredentialCheckResult { + public DockerHubCredentialCheckStatus $status; + public string $repository; + public ?int $httpStatus; + public ?string $detail; + public function __construct( - public DockerHubCredentialCheckStatus $status, - public string $repository, - public ?int $httpStatus = null, - public ?string $detail = null, + DockerHubCredentialCheckStatus $status, + string $repository, + ?int $httpStatus = null, + ?string $detail = null, ) { + // Defensively scrub CR/LF from caller-controlled strings before they + // ever get formatted into a `::error::` / `::notice::` line. The + // workflow-command syntax is line-based; an embedded newline could + // inject a second command. Belt-and-braces — the bin layer also + // validates repository against an owner/name pattern up front. + $this->status = $status; + $this->repository = $this->scrubLineBreaks($repository); + $this->httpStatus = $httpStatus; + $this->detail = $detail !== null ? $this->scrubLineBreaks($detail) : null; + } + + private function scrubLineBreaks(string $value): string + { + return strtr($value, ["\r" => ' ', "\n" => ' ']); } /** diff --git a/tools/release/tests/DockerHubCredentialCheckResultTest.php b/tools/release/tests/DockerHubCredentialCheckResultTest.php index 29aa0fda..6250a32a 100644 --- a/tools/release/tests/DockerHubCredentialCheckResultTest.php +++ b/tools/release/tests/DockerHubCredentialCheckResultTest.php @@ -147,4 +147,37 @@ public function testGithubActionsLineIsSingleLine(): void self::assertStringNotContainsString("\n", $result->toGithubActionsLine(), $status->value); } } + + public function testScrubsLineBreaksFromRepository(): void + { + $result = new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::OK, + "openemr/openemr\n::error::pwned", + 200, + ); + + // GitHub Actions workflow commands are recognized only at the start + // of a line. Stripping CR/LF means a malicious payload can still + // appear inline as text but cannot start a new command line. + self::assertStringNotContainsString("\n", $result->repository); + self::assertStringNotContainsString("\r", $result->repository); + self::assertStringNotContainsString("\n", $result->toGithubActionsLine()); + self::assertStringNotContainsString("\r", $result->toGithubActionsLine()); + } + + public function testScrubsCarriageReturnsAndNewlinesFromDetail(): void + { + $result = new DockerHubCredentialCheckResult( + DockerHubCredentialCheckStatus::NETWORK_ERROR, + self::REPO, + null, + "curl error\r\n::warning::injected", + ); + + self::assertNotNull($result->detail); + self::assertStringNotContainsString("\n", $result->detail); + self::assertStringNotContainsString("\r", $result->detail); + self::assertStringNotContainsString("\n", $result->toGithubActionsLine()); + self::assertStringNotContainsString("\r", $result->toGithubActionsLine()); + } } From 1e288b634dcaef8ef1a96df671a3a91b64e191af Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 May 2026 12:29:09 -0400 Subject: [PATCH 6/7] ci: pin arduino/setup-task to 3.x Per review feedback: matches the pinning convention used by build-release.yml and build-patch.yml. Taskfile declares `version: '3'`, so an upstream default change to setup-task wouldn't unexpectedly land us on a Task 4.x runtime that re-interprets the schema. Assisted-by: Claude Code --- .github/workflows/dockerhub-credential-check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/dockerhub-credential-check.yml b/.github/workflows/dockerhub-credential-check.yml index ab00d5f8..a0287c7a 100644 --- a/.github/workflows/dockerhub-credential-check.yml +++ b/.github/workflows/dockerhub-credential-check.yml @@ -41,6 +41,8 @@ jobs: - name: Install Task uses: arduino/setup-task@v2 + with: + version: 3.x - name: Validate Docker Hub API auth env: From fae2ea2bc4ed91202256bac13d9dcf5ac144ec67 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 May 2026 12:36:36 -0400 Subject: [PATCH 7/7] ci: distinguish 401 (invalid auth) from 403 (insufficient scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: at the read and write probes the JWT is already proven valid by the prior login. A 401 on those endpoints means the token isn't accepted by this resource (rotate the credential); a 403 means it's accepted but lacks scope (grant scope). Two different remediations — they should produce two different statuses in the operator-facing output line. fromAccessFailure() and fromWriteStatus() now route 401 → INVALID_CREDENTIAL and 403 → INSUFFICIENT_SCOPE distinctly. New tests lock both 401 paths in. Login (fromAuthFailure) keeps 401 and 403 both as INVALID_CREDENTIAL — at the login step neither distinction is meaningful (the call is "are you who you say you are", and a 403 there is "no, in any flavor"). Assisted-by: Claude Code --- .../src/DockerHubCredentialCheckResult.php | 24 +++++++++---------- .../DockerHubCredentialCheckResultTest.php | 18 ++++++++++++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/tools/release/src/DockerHubCredentialCheckResult.php b/tools/release/src/DockerHubCredentialCheckResult.php index 1c0f1264..d6e15f5a 100644 --- a/tools/release/src/DockerHubCredentialCheckResult.php +++ b/tools/release/src/DockerHubCredentialCheckResult.php @@ -135,23 +135,23 @@ private static function fromAuthFailure(string $repository, int $loginStatus): s private static function fromAccessFailure(string $repository, int $readStatus): self { - return match (true) { - in_array($readStatus, [401, 403], true) => - new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, $readStatus), - default => - new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $readStatus), + // 401 = JWT not accepted by this endpoint (rotate the credential). + // 403 = JWT recognized but lacks scope on this repo (grant scope). + // Distinct remediations, distinct statuses. + return match ($readStatus) { + 401 => new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository, 401), + 403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403), + default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $readStatus), }; } private static function fromWriteStatus(string $repository, int $writeStatus): self { - return match (true) { - $writeStatus === 200 => - new self(DockerHubCredentialCheckStatus::OK, $repository, 200), - in_array($writeStatus, [401, 403], true) => - new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, $writeStatus), - default => - new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $writeStatus), + return match ($writeStatus) { + 200 => new self(DockerHubCredentialCheckStatus::OK, $repository, 200), + 401 => new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository, 401), + 403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403), + default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $writeStatus), }; } diff --git a/tools/release/tests/DockerHubCredentialCheckResultTest.php b/tools/release/tests/DockerHubCredentialCheckResultTest.php index 6250a32a..eeabdfd5 100644 --- a/tools/release/tests/DockerHubCredentialCheckResultTest.php +++ b/tools/release/tests/DockerHubCredentialCheckResultTest.php @@ -83,6 +83,16 @@ public function testInsufficientScopeOnRead403(): void self::assertSame(403, $result->httpStatus); } + public function testInvalidCredentialOnRead401(): void + { + // 401 from the repo endpoint after a successful login means the JWT is + // not accepted here — distinct from "JWT recognized but lacks scope". + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 401, false, null); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + self::assertSame(401, $result->httpStatus); + } + public function testUnexpectedResponseOnRead404(): void { $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 404, false, null); @@ -121,6 +131,14 @@ public function testInsufficientScopeWhenReadOkButWrite403(): void self::assertStringContainsString('R/W/D scope', $result->toGithubActionsLine()); } + public function testInvalidCredentialWhenReadOkButWrite401(): void + { + $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 401); + + self::assertSame(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $result->status); + self::assertSame(401, $result->httpStatus); + } + public function testOkWhenAllStepsSucceed(): void { $result = DockerHubCredentialCheckResult::interpret(self::REPO, 200, 'jwt', 200, true, 200);