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
199 changes: 199 additions & 0 deletions .github/workflows/vendor-official-measure.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Vendor an official measure artifact WITH the VSAC credential, and hand back the committable files.
#
# ## Why this exists
#
# `WORKWELL_VSAC_API_KEY_VENDOR` is a GitHub secret. Secrets are write-only — `gh secret list` returns
# names, never values — so nobody working from a clone can produce a *completed* artifact, even though
# the credential is configured and CI uses it on every push.
#
# That produced a false blocker. Three priority measures (CMS130, CMS165 capped; CMS138 absent — see
# ADR-041 and ADR-053) were recorded as needing "an owner step", when what they actually needed was a
# way to run the existing vendor command in the one place the credential already lives. This is that
# way: a manual trigger that vendors, VERIFIES, and uploads `bundle.json` + `manifest.json` as an
# artifact to download and commit.
#
# ## Why it uploads rather than commits
#
# It needs no `contents: write`. A workflow that can push to the repo is a standing capability; this is
# a one-shot that produces two files a human then reviews and commits. The reproducibility gate in
# `ci.yml` re-derives those exact bytes on the next push, so a bad upload cannot survive review.
#
# ## What is safe to upload, and what is not
#
# `bundle.json` and `manifest.json` are **committed to this public repo already** — the manifest carries
# counts, provenance and the sidecar's SHA-256, no codes. `terminology.json` is NOT uploaded: it holds
# thousands of AMA CPT and SNOMED CT codes under an NLM licence and is gitignored for that reason
# (ADR-036). Uploading it would redistribute licensed content through an artifact URL, so the copy step
# names the two files explicitly rather than globbing the directory.
#
# ## Dispatch inputs never reach a shell (review of #365, P1)
#
# Every `${{ inputs.* }}` is passed through `env:` and validated before use. Interpolating them into a
# `run:` script would splice attacker-controlled text into a step that holds the VSAC credential —
# command substitution executes inside double quotes, so `$(...)` in an input would run with the secret
# in the environment. Only users with write access can dispatch, which lowers the odds and not the
# severity.
name: Vendor official measure (credentialed)

on:
workflow_dispatch:
inputs:
measure:
description: "Upstream measure directory, e.g. CMS138FHIRTobaccoScrnCessation"
required: true
type: string
catalog_id:
description: "WorkWell catalog id, lowercase alphanumeric, e.g. cms138"
required: true
type: string
ref:
description: "Upstream commit sha (blank = the pinned default in the vendor script)"
required: false
type: string

permissions:
contents: read

jobs:
vendor:
name: Vendor with the VSAC credential
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend-ts
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- uses: pnpm/action-setup@v4
with: { version: 10 }

# Validated BEFORE anything uses them, and re-exported as step outputs so later steps read a value
# this step has already checked rather than the raw input. The character classes are the same ones
# `parseArgs` enforces in the vendor script, so a value that passes here cannot be rejected there.
- name: Validate dispatch inputs
id: inputs
env:
IN_MEASURE: ${{ inputs.measure }}
IN_CATALOG_ID: ${{ inputs.catalog_id }}
IN_REF: ${{ inputs.ref }}
run: |
case "$IN_MEASURE" in
*[!A-Za-z0-9]*|"") echo "::error::measure must be alphanumeric (got '$IN_MEASURE')"; exit 1 ;;
esac
case "$IN_CATALOG_ID" in
*[!a-z0-9]*|"") echo "::error::catalog_id must be lowercase alphanumeric (got '$IN_CATALOG_ID')"; exit 1 ;;
esac
if [ -n "$IN_REF" ]; then
case "$IN_REF" in
*[!0-9a-f]*) echo "::error::ref must be a 40-char lowercase hex sha"; exit 1 ;;
esac
[ "${#IN_REF}" -eq 40 ] || { echo "::error::ref must be exactly 40 characters"; exit 1; }
fi
{
echo "measure=$IN_MEASURE"
echo "catalog_id=$IN_CATALOG_ID"
echo "ref=$IN_REF"
} >> "$GITHUB_OUTPUT"

# Fails the job rather than vendoring an incomplete artifact that looks fine. Without the
# credential the script warns and leaves capped/absent value sets exactly as upstream shipped
# them — correct behaviour, and precisely the outcome this workflow exists to avoid producing.
- name: Refuse to run without the credential
env:
WORKWELL_VSAC_API_KEY: ${{ secrets.WORKWELL_VSAC_API_KEY_VENDOR }}
run: |
if [ -z "$WORKWELL_VSAC_API_KEY" ]; then
echo "::error::WORKWELL_VSAC_API_KEY_VENDOR is not available here. A fork PR or a repo without the secret cannot produce a completed artifact — that is the whole point of this workflow."
exit 1
fi

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Same sparse checkout the eCQM gate uses, so the bundle is read locally at the pinned commit
# instead of pulling ~17 MB from raw.githubusercontent.
- name: Fetch official content (pinned commit)
run: pwsh -NoProfile -File scripts/fetch-official-cases.ps1

# The measurement from ADR-053, run BEFORE vendoring: it says whether this measure declares a
# value set the bundle does not ship, which decides whether the run below can succeed at all.
# Never fails the job — it is a measurement, and `official-terminology-audit.mjs` exits 0 by design.
- name: Audit declared-vs-shipped terminology
continue-on-error: true
env:
MEASURE: ${{ steps.inputs.outputs.measure }}
run: node scripts/official-terminology-audit.mjs "$MEASURE"

- name: Vendor with completion
env:
WORKWELL_VSAC_API_KEY: ${{ secrets.WORKWELL_VSAC_API_KEY_VENDOR }}
MEASURE: ${{ steps.inputs.outputs.measure }}
CATALOG_ID: ${{ steps.inputs.outputs.catalog_id }}
REF: ${{ steps.inputs.outputs.ref }}
run: |
set -- --measure "$MEASURE" --catalog-id "$CATALOG_ID" --strip-elm-annotations --complete-terminology
if [ -n "$REF" ]; then set -- "$@" --ref "$REF"; fi
node scripts/vendor-official-measure.mjs "$@"

# THE GATE, and it is not a formality (review of #365, P2).
#
# `completeTerminology` fails CLOSED: an expired key, an unreachable VSAC, a short expansion or a
# wrong-OID echo all leave the terminology as upstream shipped it and exit 0. So the vendor step
# succeeding says nothing about whether the artifact is usable, and the first cut of this workflow
# checked only `manifest.terminology.truncated` — which an ABSENT value set never appears in.
# For CMS138, the one measure this was built for, that check was warning-free by construction and
# the workflow would have uploaded exactly the unroutable artifact it claims to reject.
#
# So it now runs the REAL runtime predicates over the produced artifact: `absentValueSets` (the
# ELM's declared canonicals minus what the sidecar holds) and the manifest's own `truncated`. Same
# functions `officialRoutingProblems` calls, so "this passed here" and "routing will accept it"
# cannot drift apart.
- name: Verify the artifact is actually complete
env:
CATALOG_ID: ${{ steps.inputs.outputs.catalog_id }}
run: |
pnpm exec node --import tsx -e '
const id = process.env.CATALOG_ID;
const { loadOfficialArtifact } = await import("./src/wiring/official-artifacts.ts");
const { absentValueSets } = await import("./src/wiring/official-terminology.ts");
const { requiredOids } = await import("./src/wiring/official-executor-adapter.ts");
const artifact = loadOfficialArtifact(id);
if (!artifact) { console.log(`::error::${id}: the vendor step produced no loadable artifact`); process.exit(1); }
const m = artifact.manifest, t = m.terminology ?? {};
console.log(`measure ${m.measureName} v${m.version} (${m.cmsId ?? "no cmsId"})`);
console.log(`terminology ${t.valueSets} value sets, ${t.codes} codes`);
console.log(`completion ${JSON.stringify(t.completion ?? null, null, 2)}`);
const truncated = t.truncated ?? [];
const absent = absentValueSets(artifact, requiredOids(artifact));
console.log(`truncated ${JSON.stringify(truncated)}`);
console.log(`absent ${JSON.stringify(absent)}`);
if (truncated.length > 0 || absent.length > 0) {
console.log(`::error::${id} is INCOMPLETE — ${truncated.length} capped, ${absent.length} absent. Routing would refuse it, so nothing is uploaded. Check the vendor log above for the VSAC warning that explains why.`);
process.exit(1);
}
console.log(`${id}: terminology complete — nothing capped, nothing absent.`);
'

# Explicit paths, never a directory glob: `terminology.json` sits beside these two and must not
# leave the runner (see the header).
- name: Stage the committable files only
env:
CATALOG_ID: ${{ steps.inputs.outputs.catalog_id }}
run: |
mkdir -p "../_vendored/$CATALOG_ID"
cp "measures/official/$CATALOG_ID/bundle.json" "../_vendored/$CATALOG_ID/"
cp "measures/official/$CATALOG_ID/manifest.json" "../_vendored/$CATALOG_ID/"
echo "staged:"; ls -la "../_vendored/$CATALOG_ID/"

# `path:` is the STAGED directory, so the archive root holds the two files. `gh run download -n`
# extracts an artifact's contents directly into `-D`, so the documented command points `-D` at the
# catalog directory itself (review of #365) — pointing it at `measures/official/` would drop the
# files a level too high, where neither the vendor script nor the runtime looks for them.
- uses: actions/upload-artifact@v4
with:
name: vendored-${{ inputs.catalog_id }}
path: _vendored/${{ inputs.catalog_id }}
if-no-files-found: error
retention-days: 7
13 changes: 12 additions & 1 deletion backend-ts/scripts/fetch-official-cases.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,28 @@ param(

$ErrorActionPreference = "Stop"
$repo = "https://github.com/cqframework/dqm-content-qicore-2025.git"
# The five GATED measures, plus three CANDIDATES. A candidate is checked out but is deliberately NOT in
# `OFFICIAL_GATED_MEASURES` — its artifact is not vendored yet, so adding it to the gate would fail the
# deck. Checking them out is what lets `pnpm official:terminology-audit` and the credentialed
# `vendor-official-measure.yml` workflow read their bundles at the pinned commit without a 17 MB pull
# (ADR-053: that audit is how CMS138's absent value set was found).
$paths = @(
"bundles/measure/CMS122FHIRDiabetesAssessGT9Pct",
"bundles/measure/CMS125FHIRBreastCancerScreen",
"bundles/measure/CMS2FHIRPCSDepScreenAndFollowUp",
"bundles/measure/CMS68FHIRDocumentationCurrentMeds",
"bundles/measure/CMS951FHIRKidneyHealthEval",
"bundles/measure/CMS130FHIRColorectalCancerScrn",
"bundles/measure/CMS138FHIRTobaccoScrnCessation",
"bundles/measure/CMS165FHIRControllingHighBP",
"input/tests/measure/CMS122FHIRDiabetesAssessGT9Pct",
"input/tests/measure/CMS125FHIRBreastCancerScreen",
"input/tests/measure/CMS2FHIRPCSDepScreenAndFollowUp",
"input/tests/measure/CMS68FHIRDocumentationCurrentMeds",
"input/tests/measure/CMS951FHIRKidneyHealthEval"
"input/tests/measure/CMS951FHIRKidneyHealthEval",
"input/tests/measure/CMS130FHIRColorectalCancerScrn",
"input/tests/measure/CMS138FHIRTobaccoScrnCessation",
"input/tests/measure/CMS165FHIRControllingHighBP"
)
$ContentDir = [System.IO.Path]::GetFullPath($ContentDir)

Expand Down
Loading
Loading