Skip to content

Commit e1a5b49

Browse files
authored
feat(release): reusable check-vendored-contracts workflow + path overrides (#717)
Adds the missing piece for catching cross-repo contract drift in CI: a reusable workflow consumer repos call to drift-check their vendored copies of `dispatch.schema.json`, `TagVerifier.php`, and `TagVerificationResult.php` against canonical, plus path-override flags that let website-openemr's non-canonical layout participate. Refs #664. Closes the May 8 status snapshot's open follow-up: *"Drift-check tooling for the vendored `dispatch.schema.json` copies — today proved the manual propagation is a real failure mode."* ## Why now The check-vendored tool itself has existed since #665 and was tightened in #668 (semantic equivalence: JSON canonicalization, namespace stripping). But **no consumer's CI runs it.** I confirmed this by running the tool locally against checkouts of openemr/openemr and openemr/website-openemr — both are silently drifted right now. The failure mode #700 hit (manual propagation skipped) is alive and well; without a CI gate in each consumer it'll keep biting us. ## What this PR adds 1. **`tools/release/src/VendoredFileChecker.php`** — optional canonical→consumer path-overrides map. Required because website-openemr vendored `TagVerifier` and `TagVerificationResult` into `src/Release/` instead of `src/`, so the checker could never validate it before. Drift findings now report the *consumer-relative* path so the failure points at the file the consumer actually has on disk. 2. **`tools/release/bin/check-vendored.php`** — two complementary flags: - `--override CANON=CONSUMER` (repeatable) for direct CLI use. - `--overrides` (single value, newline-delimited) for CI inputs that arrive as multi-line strings; the PHP CLI does the parsing so the calling workflow doesn't have to construct a flag list in shell. 3. **`.github/workflows/check-vendored-contracts.yml`** — `workflow_call` reusable workflow consumers invoke with one job: ```yaml jobs: drift: uses: openemr/openemr-devops/.github/workflows/check-vendored-contracts.yml@master with: consumer_subpath: tools/release ``` website-openemr passes `path_overrides` to map its `src/Release/...` layout. 4. **`.github/workflows/vendored-contracts-self-test.yml`** + fixtures — exercises the reusable workflow against a canonical-layout fixture and a website-style override fixture inside this PR's CI, so the workflow has end-to-end coverage before any consumer adopts it. Same shape as the reusable-workflow shim the May 8 follow-up on #664 proposes for the conductor itself; building it here at lower stakes validates the pattern before we apply it to the conductor. ## Adoption (separate PRs, after this lands) - openemr/openemr — add a workflow that calls this one with `consumer_subpath: tools/release`. Will fail until the existing PHP-file drift is reconciled (this PR doesn't touch the canonical content, only the checker). - openemr/website-openemr — add a workflow with `consumer_subpath: tools/release-docs` and the path overrides. Same drift-reconciliation prerequisite, plus the `TagVerificationResult` ↔ `VerificationResult` rename to sort out. Both adoptions are blocked on a separate drift-reconciliation pass; this PR only ships the tooling so the gate *can* be added later without further changes here. ## Test plan - [x] `vendor/bin/phpunit` — 14 tests, +4 covering override mapping, override-aware drift reporting, and unknown-key validation - [x] `vendor/bin/phpcs` — clean - [x] `vendor/bin/phpstan analyse` — clean - [x] `actionlint` on both new workflows — clean - [x] CLI smoke-tested against both consumer checkouts — overrides resolve correctly, drift findings name the consumer's actual paths - [x] Self-test workflow green on PR CI — both `good` and `good-with-overrides` jobs pass, validating the reusable workflow end-to-end
1 parent 424f428 commit e1a5b49

11 files changed

Lines changed: 861 additions & 5 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Check Vendored Contracts
2+
3+
# Reusable workflow for consumer repos to drift-check their vendored copies
4+
# of cross-repo release contracts (dispatch.schema.json, TagVerifier,
5+
# TagVerificationResult) against the canonical sources here. Fails the
6+
# calling job on drift.
7+
#
8+
# See tools/release/src/VendoredFileChecker.php for the canonical file list.
9+
# See tools/release/contracts/dispatch.schema.json and
10+
# tools/release/src/TagVerifier.php for the contracts themselves.
11+
#
12+
# Caller example (in a consumer repo's PR workflow):
13+
#
14+
# jobs:
15+
# drift:
16+
# uses: openemr/openemr-devops/.github/workflows/check-vendored-contracts.yml@master
17+
# with:
18+
# consumer_subpath: tools/release
19+
#
20+
# website-openemr (which vendored into a non-canonical layout) passes overrides:
21+
#
22+
# with:
23+
# consumer_subpath: tools/release-docs
24+
# path_overrides: |
25+
# src/TagVerifier.php=src/Release/TagVerifier.php
26+
# src/TagVerificationResult.php=src/Release/TagVerificationResult.php
27+
28+
on:
29+
workflow_call:
30+
inputs:
31+
consumer_subpath:
32+
description: 'Path within the caller repo holding the vendored copies (e.g. tools/release).'
33+
required: true
34+
type: string
35+
canonical_ref:
36+
description: 'Ref of openemr/openemr-devops to compare against. Defaults to master.'
37+
required: false
38+
type: string
39+
default: master
40+
path_overrides:
41+
description: |
42+
Optional newline-delimited canonical=consumer path overrides for consumers
43+
that vendored into a non-canonical layout. Example:
44+
src/TagVerifier.php=src/Release/TagVerifier.php
45+
required: false
46+
type: string
47+
default: ''
48+
49+
permissions:
50+
contents: read
51+
52+
jobs:
53+
check:
54+
name: vendored contracts
55+
runs-on: ubuntu-24.04
56+
steps:
57+
- name: Checkout consumer
58+
uses: actions/checkout@v6
59+
60+
- name: Checkout canonical openemr-devops
61+
uses: actions/checkout@v6
62+
with:
63+
repository: openemr/openemr-devops
64+
ref: ${{ inputs.canonical_ref }}
65+
path: _canonical-openemr-devops
66+
67+
- name: Setup PHP
68+
uses: shivammathur/setup-php@v2
69+
with:
70+
php-version: '8.5'
71+
72+
- name: Install canonical release-tools dependencies
73+
working-directory: _canonical-openemr-devops/tools/release
74+
run: composer install --no-interaction --no-progress --no-dev
75+
76+
- name: Check vendored contracts
77+
env:
78+
CONSUMER_PATH: ${{ github.workspace }}/${{ inputs.consumer_subpath }}
79+
PATH_OVERRIDES: ${{ inputs.path_overrides }}
80+
working-directory: _canonical-openemr-devops/tools/release
81+
run: php bin/check-vendored.php --consumer "$CONSUMER_PATH" --overrides "$PATH_OVERRIDES"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Test Vendored Contracts (self-test)
2+
3+
# Exercises check-vendored-contracts.yml against committed fixtures so the
4+
# reusable workflow has end-to-end CI coverage in this repo. Without this,
5+
# its only validation is whatever consumer adopts it first.
6+
#
7+
# good — canonical layout, no overrides. Validates checkout
8+
# wiring, composer install, and CLI invocation.
9+
# good-with-overrides — same content under a website-openemr-style
10+
# src/Release/ layout. Exercises the path_overrides
11+
# input end-to-end (newline-delimited string →
12+
# PHP-side parsing in --overrides).
13+
#
14+
# Drift-detection failure propagation isn't tested here — exit-code → job
15+
# status is GHA's intrinsic behavior, so a drifted fixture just turns the
16+
# test red without proving anything new about the workflow.
17+
#
18+
# canonical_ref pins to the PR head SHA so the test compares against this
19+
# PR's canonical files, not whatever happens to be on master.
20+
21+
on:
22+
pull_request:
23+
paths:
24+
- '.github/workflows/check-vendored-contracts.yml'
25+
- '.github/workflows/vendored-contracts-self-test.yml'
26+
- 'tools/release/bin/check-vendored.php'
27+
- 'tools/release/src/VendoredFileChecker.php'
28+
- 'tools/release/src/VendoredDriftIssue.php'
29+
- 'tools/release/contracts/dispatch.schema.json'
30+
- 'tools/release/src/TagVerifier.php'
31+
- 'tools/release/src/TagVerificationResult.php'
32+
- 'tools/release/tests/fixtures/vendored/**'
33+
- 'tools/release/composer.json'
34+
- 'tools/release/composer.lock'
35+
36+
permissions:
37+
contents: read
38+
39+
concurrency:
40+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
41+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
42+
43+
jobs:
44+
good:
45+
name: good fixture matches canonical
46+
uses: ./.github/workflows/check-vendored-contracts.yml
47+
with:
48+
consumer_subpath: tools/release/tests/fixtures/vendored/good
49+
canonical_ref: ${{ github.event.pull_request.head.sha }}
50+
51+
good-with-overrides:
52+
name: good fixture under website-style layout via path_overrides
53+
uses: ./.github/workflows/check-vendored-contracts.yml
54+
with:
55+
consumer_subpath: tools/release/tests/fixtures/vendored/good-overrides
56+
canonical_ref: ${{ github.event.pull_request.head.sha }}
57+
path_overrides: |
58+
src/TagVerifier.php=src/Release/TagVerifier.php
59+
src/TagVerificationResult.php=src/Release/TagVerificationResult.php

tools/release/bin/check-vendored.php

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@
4242
InputOption::VALUE_REQUIRED,
4343
'Path to the consumer repo dir holding the vendored copies',
4444
)
45+
->addOption(
46+
'override',
47+
null,
48+
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
49+
'Map a canonical path to a different consumer-relative path '
50+
. '(e.g. --override src/TagVerifier.php=src/Release/TagVerifier.php). Repeatable.',
51+
)
52+
->addOption(
53+
'overrides',
54+
null,
55+
InputOption::VALUE_REQUIRED,
56+
'Same mapping as --override but as a single newline-delimited string '
57+
. '(blank lines and surrounding whitespace ignored). Convenient for '
58+
. 'CI inputs that arrive as multi-line strings. Combines with --override.',
59+
)
4560
->setCode(function (InputInterface $input, OutputInterface $output): int {
4661
$canonical = $input->getOption('canonical');
4762
if (!is_string($canonical) || $canonical === '') {
@@ -63,7 +78,48 @@
6378
return 1;
6479
}
6580

66-
$issues = (new VendoredFileChecker($canonical, $consumer))->check();
81+
$rawOverrides = $input->getOption('override');
82+
if (!is_array($rawOverrides)) {
83+
$rawOverrides = [];
84+
}
85+
$multiline = $input->getOption('overrides');
86+
if (is_string($multiline) && $multiline !== '') {
87+
$lines = preg_split('/\R/', $multiline);
88+
if ($lines === false) {
89+
$output->writeln('<error>--overrides could not be parsed</error>');
90+
return 1;
91+
}
92+
foreach ($lines as $line) {
93+
$trimmed = trim($line);
94+
if ($trimmed !== '') {
95+
$rawOverrides[] = $trimmed;
96+
}
97+
}
98+
}
99+
$overrides = [];
100+
foreach ($rawOverrides as $entry) {
101+
if (!is_string($entry) || !str_contains($entry, '=')) {
102+
$output->writeln(sprintf(
103+
'<error>override entry expects CANONICAL=CONSUMER, got: %s</error>',
104+
is_string($entry) ? $entry : gettype($entry),
105+
));
106+
return 1;
107+
}
108+
[$canonicalPath, $consumerPath] = explode('=', $entry, 2);
109+
if ($canonicalPath === '' || $consumerPath === '') {
110+
$output->writeln(sprintf('<error>override entry has empty side: %s</error>', $entry));
111+
return 1;
112+
}
113+
$overrides[$canonicalPath] = $consumerPath;
114+
}
115+
116+
try {
117+
$checker = new VendoredFileChecker($canonical, $consumer, $overrides);
118+
} catch (\InvalidArgumentException $e) {
119+
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
120+
return 1;
121+
}
122+
$issues = $checker->check();
67123
if ($issues === []) {
68124
$output->writeln(sprintf(
69125
'<info>✓</info> All %d vendored file(s) match canonical.',

tools/release/src/VendoredFileChecker.php

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
* Layout assumption: the consumer mirrors the canonical relative paths under
99
* its vendored dir. A consumer that vendors to `vendored/openemr-devops/`
1010
* therefore has `vendored/openemr-devops/contracts/dispatch.schema.json` and
11-
* `vendored/openemr-devops/src/TagVerifier.php`.
11+
* `vendored/openemr-devops/src/TagVerifier.php`. A consumer that vendored
12+
* into a different layout (e.g. `src/Release/TagVerifier.php`) can pass a
13+
* `$pathOverrides` map keyed by canonical path → consumer-relative path.
1214
*
1315
* Equivalence is per-file-type, per the openemr-devops#664 spec:
1416
*
@@ -44,10 +46,49 @@
4446
'src/TagVerificationResult.php',
4547
];
4648

49+
/** @var array<string, string> */
50+
private array $pathOverrides;
51+
52+
/**
53+
* @param array<array-key, mixed> $pathOverrides Map of canonical relative
54+
* path → consumer relative path. Validated at runtime since CLI/CI
55+
* callers can produce arbitrary input: keys and values must both be
56+
* strings; unmapped entries default to the canonical path; unknown
57+
* keys (not in VENDORED_PATHS) throw, as do values that are absolute
58+
* or contain `..` segments — overrides must stay inside the
59+
* consumer dir.
60+
*/
4761
public function __construct(
4862
private string $canonicalRoot,
4963
private string $consumerDir,
64+
array $pathOverrides = [],
5065
) {
66+
$validated = [];
67+
foreach ($pathOverrides as $key => $value) {
68+
if (!is_string($key) || !is_string($value)) {
69+
throw new \InvalidArgumentException(sprintf(
70+
'Override entries must be string=>string, got %s=>%s',
71+
get_debug_type($key),
72+
get_debug_type($value),
73+
));
74+
}
75+
if (!in_array($key, self::VENDORED_PATHS, true)) {
76+
throw new \InvalidArgumentException(sprintf(
77+
'Unknown override key %s; must be one of: %s',
78+
$key,
79+
implode(', ', self::VENDORED_PATHS),
80+
));
81+
}
82+
if ($value === '' || $value[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $value) === 1) {
83+
throw new \InvalidArgumentException(sprintf(
84+
'Override value for %s must be a relative path inside the consumer dir, got: %s',
85+
$key,
86+
$value,
87+
));
88+
}
89+
$validated[$key] = $value;
90+
}
91+
$this->pathOverrides = $validated;
5192
}
5293

5394
/**
@@ -58,7 +99,8 @@ public function check(): array
5899
$issues = [];
59100
foreach (self::VENDORED_PATHS as $rel) {
60101
$canonicalAbs = $this->canonicalRoot . '/' . $rel;
61-
$consumerAbs = $this->consumerDir . '/' . $rel;
102+
$consumerRel = $this->pathOverrides[$rel] ?? $rel;
103+
$consumerAbs = $this->consumerDir . '/' . $consumerRel;
62104

63105
if (!is_file($canonicalAbs)) {
64106
$issues[] = new VendoredDriftIssue(
@@ -70,15 +112,15 @@ public function check(): array
70112
}
71113
if (!is_file($consumerAbs)) {
72114
$issues[] = new VendoredDriftIssue(
73-
$rel,
115+
$consumerRel,
74116
'missing_consumer',
75117
'Consumer copy missing — vendor it from canonical at ' . $canonicalAbs,
76118
);
77119
continue;
78120
}
79121
if (!$this->equivalent($rel, $canonicalAbs, $consumerAbs)) {
80122
$issues[] = new VendoredDriftIssue(
81-
$rel,
123+
$consumerRel,
82124
'drift',
83125
'Consumer copy differs from canonical — re-vendor from ' . $canonicalAbs,
84126
);

0 commit comments

Comments
 (0)