Skip to content

Commit b912a19

Browse files
committed
ci: add manual workflow to validate DOCKERHUB_TOKEN before a build
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/<repo>/ — 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
1 parent aca0cd4 commit b912a19

9 files changed

Lines changed: 375 additions & 1 deletion
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Docker Hub Credential Check
2+
3+
# Manual workflow that proves DOCKERHUB_USERNAME / DOCKERHUB_TOKEN are valid
4+
# for the readme-push API path before triggering an expensive build. Issue #714
5+
# rotated the secret because the readme push was returning Forbidden; this lets
6+
# whoever rotates next confirm the new value works in seconds, without pushing
7+
# an image.
8+
#
9+
# Validates two paths: registry login (used by docker/login-action in the
10+
# build workflows) and the Docker Hub API (used by peter-evans/dockerhub-description
11+
# to PATCH the repo description). A token can pass the first and fail the
12+
# second, which is exactly the failure mode that prompted #714.
13+
14+
on:
15+
workflow_dispatch:
16+
17+
permissions: {}
18+
19+
jobs:
20+
check:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@v6
24+
25+
- name: Validate registry login
26+
uses: docker/login-action@v4
27+
with:
28+
username: ${{ secrets.DOCKERHUB_USERNAME }}
29+
password: ${{ secrets.DOCKERHUB_TOKEN }}
30+
31+
- name: Setup PHP
32+
uses: shivammathur/setup-php@v2
33+
with:
34+
php-version: '8.5'
35+
36+
- name: Install Task
37+
uses: arduino/setup-task@v2
38+
39+
- name: Validate Docker Hub API auth
40+
env:
41+
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
42+
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
43+
working-directory: tools/release
44+
run: task ci:check-dockerhub-credential

tools/release/Taskfile.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ tasks:
153153
php bin/lint-versions.php
154154
--repo={{shellQuote (default "../.." .ROTATE_REPO)}}
155155
156+
ci:check-dockerhub-credential:
157+
desc: Smoke-test DOCKERHUB_USERNAME / DOCKERHUB_TOKEN against the readme-push API path
158+
deps: [setup]
159+
cmds:
160+
- >-
161+
php bin/check-dockerhub-credential.php
162+
{{if .REPOSITORY}}--repository={{shellQuote .REPOSITORY}}{{end}}
163+
156164
release:verify-tag:
157165
desc: Verify a release tag is annotated and well-formed per #664 spec
158166
requires:
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
/**
5+
* Validate DOCKERHUB_USERNAME / DOCKERHUB_TOKEN against the Docker Hub API
6+
* path that peter-evans/dockerhub-description uses.
7+
*
8+
* Manual smoke test invoked from the dockerhub-credential-check workflow.
9+
* Exits non-zero on failure with a `::error::` line; on success emits a
10+
* `::notice::` line.
11+
*
12+
* @package openemr-devops
13+
* @link https://www.open-emr.org
14+
* @author Michael A. Smith <michael@opencoreemr.com>
15+
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
16+
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
require dirname(__DIR__) . '/vendor/autoload.php';
22+
23+
use OpenEMR\Release\DockerHubCredentialChecker;
24+
use Symfony\Component\Console\Input\InputInterface;
25+
use Symfony\Component\Console\Input\InputOption;
26+
use Symfony\Component\Console\Output\OutputInterface;
27+
use Symfony\Component\Console\SingleCommandApplication;
28+
29+
(new SingleCommandApplication())
30+
->setName('check-dockerhub-credential')
31+
->setDescription('Validate DOCKERHUB_USERNAME / DOCKERHUB_TOKEN for the readme-push API path')
32+
->addOption(
33+
'repository',
34+
null,
35+
InputOption::VALUE_REQUIRED,
36+
'Docker Hub repository (owner/name)',
37+
'openemr/openemr',
38+
)
39+
->setCode(function (InputInterface $input, OutputInterface $output): int {
40+
$username = getenv('DOCKERHUB_USERNAME');
41+
$token = getenv('DOCKERHUB_TOKEN');
42+
if (!is_string($username) || $username === '') {
43+
$output->writeln('::error::DOCKERHUB_USERNAME env var is required');
44+
return 1;
45+
}
46+
if (!is_string($token) || $token === '') {
47+
$output->writeln('::error::DOCKERHUB_TOKEN env var is required');
48+
return 1;
49+
}
50+
/** @var string $repository */
51+
$repository = $input->getOption('repository');
52+
53+
$result = (new DockerHubCredentialChecker())->check($username, $token, $repository);
54+
$output->writeln($result->toGithubActionsLine());
55+
return $result->isOk() ? 0 : 1;
56+
})
57+
->run();

tools/release/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"license": "GPL-2.0-or-later",
66
"require": {
77
"php": "^8.5",
8+
"ext-curl": "*",
89
"ext-mbstring": "*",
910
"nikic/php-parser": "^5.0",
1011
"symfony/console": "^7.0",

tools/release/composer.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/**
4+
* Outcome of a Docker Hub credential check.
5+
*
6+
* @package openemr-devops
7+
* @link https://www.open-emr.org
8+
* @author Michael A. Smith <michael@opencoreemr.com>
9+
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
10+
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
11+
*/
12+
13+
declare(strict_types=1);
14+
15+
namespace OpenEMR\Release;
16+
17+
final readonly class DockerHubCredentialCheckResult
18+
{
19+
public function __construct(
20+
public DockerHubCredentialCheckStatus $status,
21+
public string $repository,
22+
public ?int $httpStatus = null,
23+
) {
24+
}
25+
26+
/**
27+
* Map raw HTTP responses from the Docker Hub login + repository endpoints
28+
* to a result. Pure: no network. Tested directly.
29+
*/
30+
public static function interpret(string $repository, ?string $jwt, ?int $repoStatus): self
31+
{
32+
if (in_array($jwt, [null, '', 'null'], true)) {
33+
return new self(DockerHubCredentialCheckStatus::INVALID_CREDENTIAL, $repository);
34+
}
35+
return match ($repoStatus) {
36+
200 => new self(DockerHubCredentialCheckStatus::OK, $repository, 200),
37+
403 => new self(DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE, $repository, 403),
38+
default => new self(DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE, $repository, $repoStatus),
39+
};
40+
}
41+
42+
public function isOk(): bool
43+
{
44+
return $this->status === DockerHubCredentialCheckStatus::OK;
45+
}
46+
47+
/**
48+
* Format as a single GitHub-Actions workflow command line
49+
* (`::error::…` or `::notice::…`).
50+
*/
51+
public function toGithubActionsLine(): string
52+
{
53+
return match ($this->status) {
54+
DockerHubCredentialCheckStatus::OK => sprintf(
55+
"::notice::Credential is valid for %s (read access confirmed).",
56+
$this->repository,
57+
),
58+
DockerHubCredentialCheckStatus::INVALID_CREDENTIAL =>
59+
'::error::Login returned no JWT — DOCKERHUB_USERNAME / DOCKERHUB_TOKEN appear invalid.',
60+
DockerHubCredentialCheckStatus::INSUFFICIENT_SCOPE => sprintf(
61+
"::error::Login succeeded but the token lacks access to %s. Verify R/W/D scope.",
62+
$this->repository,
63+
),
64+
DockerHubCredentialCheckStatus::UNEXPECTED_RESPONSE => sprintf(
65+
"::error::Unexpected HTTP %s from Docker Hub API for %s.",
66+
$this->httpStatus ?? '(unknown)',
67+
$this->repository,
68+
),
69+
};
70+
}
71+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/**
4+
* @package openemr-devops
5+
* @link https://www.open-emr.org
6+
* @author Michael A. Smith <michael@opencoreemr.com>
7+
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
8+
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace OpenEMR\Release;
14+
15+
enum DockerHubCredentialCheckStatus: string
16+
{
17+
case OK = 'ok';
18+
case INVALID_CREDENTIAL = 'invalid_credential';
19+
case INSUFFICIENT_SCOPE = 'insufficient_scope';
20+
case UNEXPECTED_RESPONSE = 'unexpected_response';
21+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/**
4+
* Validate Docker Hub credentials against the API path that
5+
* peter-evans/dockerhub-description uses (PATCH /v2/repositories/<repo>/).
6+
*
7+
* Distinguishes "bad credential" from "credential lacks scope on this repo" —
8+
* a token can pass docker login (registry auth) and still 403 on the API
9+
* path, which is exactly the failure mode openemr/openemr-devops#714 had to
10+
* recover from.
11+
*
12+
* @package openemr-devops
13+
* @link https://www.open-emr.org
14+
* @author Michael A. Smith <michael@opencoreemr.com>
15+
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
16+
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
namespace OpenEMR\Release;
22+
23+
final readonly class DockerHubCredentialChecker
24+
{
25+
private const DEFAULT_API_BASE = 'https://hub.docker.com/v2';
26+
27+
public function __construct(
28+
private string $apiBase = self::DEFAULT_API_BASE,
29+
) {
30+
}
31+
32+
public function check(string $username, string $token, string $repository): DockerHubCredentialCheckResult
33+
{
34+
$jwt = $this->mintJwt($username, $token);
35+
$repoStatus = $jwt !== null
36+
? $this->probeRepository($jwt, $repository)
37+
: null;
38+
return DockerHubCredentialCheckResult::interpret($repository, $jwt, $repoStatus);
39+
}
40+
41+
private function mintJwt(string $username, string $token): ?string
42+
{
43+
$body = json_encode(['username' => $username, 'password' => $token], JSON_THROW_ON_ERROR);
44+
[$status, $responseBody] = $this->httpRequest('POST', $this->apiBase . '/users/login/', [
45+
'Content-Type: application/json',
46+
], $body);
47+
48+
if ($status !== 200) {
49+
return null;
50+
}
51+
$decoded = json_decode($responseBody, true, flags: JSON_THROW_ON_ERROR);
52+
if (!is_array($decoded) || !isset($decoded['token']) || !is_string($decoded['token'])) {
53+
return null;
54+
}
55+
return $decoded['token'];
56+
}
57+
58+
private function probeRepository(string $jwt, string $repository): int
59+
{
60+
[$status] = $this->httpRequest('GET', $this->apiBase . '/repositories/' . $repository . '/', [
61+
'Authorization: JWT ' . $jwt,
62+
]);
63+
return $status;
64+
}
65+
66+
/**
67+
* @param non-empty-string $method
68+
* @param list<string> $headers
69+
* @return array{int, string}
70+
*/
71+
private function httpRequest(string $method, string $url, array $headers, ?string $body = null): array
72+
{
73+
$ch = curl_init($url);
74+
if ($ch === false) {
75+
throw new \RuntimeException('curl_init failed');
76+
}
77+
curl_setopt_array($ch, [
78+
CURLOPT_CUSTOMREQUEST => $method,
79+
CURLOPT_RETURNTRANSFER => true,
80+
CURLOPT_HTTPHEADER => $headers,
81+
CURLOPT_TIMEOUT => 10,
82+
]);
83+
if ($body !== null) {
84+
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
85+
}
86+
$response = curl_exec($ch);
87+
if ($response === false) {
88+
$error = curl_error($ch);
89+
throw new \RuntimeException("curl error for {$method} {$url}: {$error}");
90+
}
91+
/** @var int $status */
92+
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
93+
return [$status, is_string($response) ? $response : ''];
94+
}
95+
}

0 commit comments

Comments
 (0)