Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/check-vendored-contracts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Check Vendored Contracts

# Reusable workflow for consumer repos to drift-check their vendored copies
# of cross-repo release contracts (dispatch.schema.json, TagVerifier,
# TagVerificationResult) against the canonical sources here. Fails the
# calling job on drift.
#
# See tools/release/src/VendoredFileChecker.php for the canonical file list.
# See tools/release/contracts/dispatch.schema.json and
# tools/release/src/TagVerifier.php for the contracts themselves.
#
# Caller example (in a consumer repo's PR workflow):
#
# jobs:
# drift:
# uses: openemr/openemr-devops/.github/workflows/check-vendored-contracts.yml@master
# with:
# consumer_subpath: tools/release
#
# website-openemr (which vendored into a non-canonical layout) passes overrides:
#
# with:
# consumer_subpath: tools/release-docs
# path_overrides: |
# src/TagVerifier.php=src/Release/TagVerifier.php
# src/TagVerificationResult.php=src/Release/TagVerificationResult.php

on:
workflow_call:
inputs:
consumer_subpath:
description: 'Path within the caller repo holding the vendored copies (e.g. tools/release).'
required: true
type: string
canonical_ref:
description: 'Ref of openemr/openemr-devops to compare against. Defaults to master.'
required: false
type: string
default: master
path_overrides:
description: |
Optional newline-delimited canonical=consumer path overrides for consumers
that vendored into a non-canonical layout. Example:
src/TagVerifier.php=src/Release/TagVerifier.php
required: false
type: string
default: ''

permissions:
contents: read

jobs:
check:
name: vendored contracts
runs-on: ubuntu-24.04
steps:
- name: Checkout consumer
uses: actions/checkout@v6

- name: Checkout canonical openemr-devops
uses: actions/checkout@v6
with:
repository: openemr/openemr-devops
ref: ${{ inputs.canonical_ref }}
path: _canonical-openemr-devops

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'

- name: Install canonical release-tools dependencies
working-directory: _canonical-openemr-devops/tools/release
run: composer install --no-interaction --no-progress --no-dev

- name: Check vendored contracts
env:
CONSUMER_PATH: ${{ github.workspace }}/${{ inputs.consumer_subpath }}
PATH_OVERRIDES: ${{ inputs.path_overrides }}
working-directory: _canonical-openemr-devops/tools/release
run: php bin/check-vendored.php --consumer "$CONSUMER_PATH" --overrides "$PATH_OVERRIDES"
59 changes: 59 additions & 0 deletions .github/workflows/vendored-contracts-self-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Test Vendored Contracts (self-test)

# Exercises check-vendored-contracts.yml against committed fixtures so the
# reusable workflow has end-to-end CI coverage in this repo. Without this,
# its only validation is whatever consumer adopts it first.
#
# good — canonical layout, no overrides. Validates checkout
# wiring, composer install, and CLI invocation.
# good-with-overrides — same content under a website-openemr-style
# src/Release/ layout. Exercises the path_overrides
# input end-to-end (newline-delimited string →
# PHP-side parsing in --overrides).
#
# Drift-detection failure propagation isn't tested here — exit-code → job
# status is GHA's intrinsic behavior, so a drifted fixture just turns the
# test red without proving anything new about the workflow.
#
# canonical_ref pins to the PR head SHA so the test compares against this
# PR's canonical files, not whatever happens to be on master.

on:
pull_request:
paths:
- '.github/workflows/check-vendored-contracts.yml'
- '.github/workflows/vendored-contracts-self-test.yml'
- 'tools/release/bin/check-vendored.php'
- 'tools/release/src/VendoredFileChecker.php'
- 'tools/release/src/VendoredDriftIssue.php'
- 'tools/release/contracts/dispatch.schema.json'
- 'tools/release/src/TagVerifier.php'
- 'tools/release/src/TagVerificationResult.php'
- 'tools/release/tests/fixtures/vendored/**'
- 'tools/release/composer.json'
- 'tools/release/composer.lock'

Comment thread
kojiromike marked this conversation as resolved.
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
good:
name: good fixture matches canonical
uses: ./.github/workflows/check-vendored-contracts.yml
with:
consumer_subpath: tools/release/tests/fixtures/vendored/good
canonical_ref: ${{ github.event.pull_request.head.sha }}

good-with-overrides:
name: good fixture under website-style layout via path_overrides
uses: ./.github/workflows/check-vendored-contracts.yml
with:
consumer_subpath: tools/release/tests/fixtures/vendored/good-overrides
canonical_ref: ${{ github.event.pull_request.head.sha }}
path_overrides: |
src/TagVerifier.php=src/Release/TagVerifier.php
src/TagVerificationResult.php=src/Release/TagVerificationResult.php
58 changes: 57 additions & 1 deletion tools/release/bin/check-vendored.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@
InputOption::VALUE_REQUIRED,
'Path to the consumer repo dir holding the vendored copies',
)
->addOption(
'override',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Map a canonical path to a different consumer-relative path '
. '(e.g. --override src/TagVerifier.php=src/Release/TagVerifier.php). Repeatable.',
)
->addOption(
'overrides',
null,
InputOption::VALUE_REQUIRED,
'Same mapping as --override but as a single newline-delimited string '
. '(blank lines and surrounding whitespace ignored). Convenient for '
. 'CI inputs that arrive as multi-line strings. Combines with --override.',
)
->setCode(function (InputInterface $input, OutputInterface $output): int {
$canonical = $input->getOption('canonical');
if (!is_string($canonical) || $canonical === '') {
Expand All @@ -63,7 +78,48 @@
return 1;
}

$issues = (new VendoredFileChecker($canonical, $consumer))->check();
$rawOverrides = $input->getOption('override');
if (!is_array($rawOverrides)) {
$rawOverrides = [];
}
$multiline = $input->getOption('overrides');
if (is_string($multiline) && $multiline !== '') {
$lines = preg_split('/\R/', $multiline);
if ($lines === false) {
$output->writeln('<error>--overrides could not be parsed</error>');
return 1;
}
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed !== '') {
$rawOverrides[] = $trimmed;
}
}
}
$overrides = [];
foreach ($rawOverrides as $entry) {
if (!is_string($entry) || !str_contains($entry, '=')) {
$output->writeln(sprintf(
'<error>override entry expects CANONICAL=CONSUMER, got: %s</error>',
is_string($entry) ? $entry : gettype($entry),
));
return 1;
}
[$canonicalPath, $consumerPath] = explode('=', $entry, 2);
if ($canonicalPath === '' || $consumerPath === '') {
$output->writeln(sprintf('<error>override entry has empty side: %s</error>', $entry));
return 1;
}
$overrides[$canonicalPath] = $consumerPath;
}

try {
$checker = new VendoredFileChecker($canonical, $consumer, $overrides);
} catch (\InvalidArgumentException $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
$issues = $checker->check();
if ($issues === []) {
$output->writeln(sprintf(
'<info>✓</info> All %d vendored file(s) match canonical.',
Expand Down
50 changes: 46 additions & 4 deletions tools/release/src/VendoredFileChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
* Layout assumption: the consumer mirrors the canonical relative paths under
* its vendored dir. A consumer that vendors to `vendored/openemr-devops/`
* therefore has `vendored/openemr-devops/contracts/dispatch.schema.json` and
* `vendored/openemr-devops/src/TagVerifier.php`.
* `vendored/openemr-devops/src/TagVerifier.php`. A consumer that vendored
* into a different layout (e.g. `src/Release/TagVerifier.php`) can pass a
* `$pathOverrides` map keyed by canonical path → consumer-relative path.
*
* Equivalence is per-file-type, per the openemr-devops#664 spec:
*
Expand Down Expand Up @@ -44,10 +46,49 @@
'src/TagVerificationResult.php',
];

/** @var array<string, string> */
private array $pathOverrides;

/**
* @param array<array-key, mixed> $pathOverrides Map of canonical relative
* path → consumer relative path. Validated at runtime since CLI/CI
* callers can produce arbitrary input: keys and values must both be
* strings; unmapped entries default to the canonical path; unknown
* keys (not in VENDORED_PATHS) throw, as do values that are absolute
* or contain `..` segments — overrides must stay inside the
* consumer dir.
*/
public function __construct(
private string $canonicalRoot,
private string $consumerDir,
array $pathOverrides = [],
) {
$validated = [];
foreach ($pathOverrides as $key => $value) {
if (!is_string($key) || !is_string($value)) {
throw new \InvalidArgumentException(sprintf(
'Override entries must be string=>string, got %s=>%s',
get_debug_type($key),
get_debug_type($value),
));
}
if (!in_array($key, self::VENDORED_PATHS, true)) {
throw new \InvalidArgumentException(sprintf(
'Unknown override key %s; must be one of: %s',
$key,
implode(', ', self::VENDORED_PATHS),
));
}
if ($value === '' || $value[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $value) === 1) {
throw new \InvalidArgumentException(sprintf(
'Override value for %s must be a relative path inside the consumer dir, got: %s',
$key,
$value,
));
}
Comment thread
kojiromike marked this conversation as resolved.
$validated[$key] = $value;
}
Comment thread
kojiromike marked this conversation as resolved.
$this->pathOverrides = $validated;
}

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

if (!is_file($canonicalAbs)) {
$issues[] = new VendoredDriftIssue(
Expand All @@ -70,15 +112,15 @@ public function check(): array
}
if (!is_file($consumerAbs)) {
$issues[] = new VendoredDriftIssue(
$rel,
$consumerRel,
'missing_consumer',
'Consumer copy missing — vendor it from canonical at ' . $canonicalAbs,
);
continue;
}
if (!$this->equivalent($rel, $canonicalAbs, $consumerAbs)) {
$issues[] = new VendoredDriftIssue(
$rel,
$consumerRel,
'drift',
'Consumer copy differs from canonical — re-vendor from ' . $canonicalAbs,
);
Expand Down
Loading