Skip to content
Open
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
126 changes: 126 additions & 0 deletions .github/CS3D_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# OHIF ↔ CS3D integration workflows

This document describes the automated integration flow that consumes CS3D tarball release assets and opens/updates OHIF PRs for testing.

## Dependencies rewritten

Only **@cornerstonejs/** packages that have a matching release asset are rewritten. Asset filenames follow `cornerstonejs-<pkg>-<version>.tgz` (e.g. `cornerstonejs-core-4.18.5.tgz` → `@cornerstonejs/core`). The script updates:

- **dependencies**, **peerDependencies**, **optionalDependencies** in every `package.json` under the repo (excluding `libs/@cornerstonejs`)
- **resolutions** in the root `package.json`

Other @cornerstonejs packages (e.g. codec-*, calculate-suv) that are not in the CS3D release are left unchanged.

## Allowed file changes by mode

| Mode | Allowed to change |
|------|-------------------|
| **integration-only** | Only: any `package.json`, `yarn.lock` / `package-lock.json` / `bun.lock`, `.github/cs3d-integration.json`. Any other changed file fails verification. |
| **paired-change** / **merged-refresh** | Same manifest/lockfile/metadata changes; **in addition**, other source files may change (e.g. human OHIF edits). Automation only touches deps, lockfile, and metadata. |

In all modes, **CS3D dependency values** must point only to trusted release asset URLs (default: `https://github.com/<CS3D_TRUSTED_REPO>/releases/download/...`).

## Blocking merge when tarball deps are present

To avoid merging branches that still reference CS3D GitHub release tarballs (integration test state), the workflow **No CS3D tarball deps** runs on pull requests and push to `master`/`main`/`release/*`. It fails if any `package.json` has `@cornerstonejs/*` dependencies or resolutions set to HTTP tarball URLs.

1. **Enable the check in branch protection**
In the repo: **Settings → Branches → Branch protection rules** (for `main`/`master` or your default branch), add **"No CS3D tarball deps"** as a required status check. Merges will then be blocked until deps are reverted to npm versions.

2. **Greptile**
[.greptile/rules.md](../.greptile/rules.md) instructs Greptile not to comment recommending blocking merge solely because tarball refs are present, since the CI already enforces that.

## Secrets required

| Secret | Purpose |
|--------|--------|
| **GH_TOKEN** or **GITHUB_TOKEN** | Used to fetch release assets from the CS3D repo and to create/update branches and PRs in this repo. Default `github.token` is sufficient for same-repo; use a PAT or app token if you need to read releases from another repo. |

No extra secrets are required if CS3D is public and the workflow runs in the OHIF repo with default `GITHUB_TOKEN`.

## How to trigger

1. **From CS3D (automatic)**
When CS3D runs its integration workflow, it sends a `repository_dispatch` to this repo with:
- **Event type:** `cs3d-integration` (or `cs3d_integration_requested` / `cs3d_merged_update` if CS3D is updated to send those).
- **Payload:** `release_tag`, `source_repository`, `mode`, and (for PR) `cs3d_pr_number`, `cs3d_head_sha` or (for merged) `cs3d_merged_version`, `cs3d_merged_sha`.

2. **Manual trigger**
Use the GitHub Actions UI: **Run workflow** on **OHIF CS3D integration**, then choose **repository_dispatch** and enter a JSON **Payload** (e.g. event type and client_payload). Or use the API:

```bash
curl -X POST -H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/OWNER/OHIF_REPO/dispatches" \
-d '{"event_type":"cs3d-integration","client_payload":{"mode":"integration-only","release_tag":"cs3d-pr-123-abc1234","source_repository":"cornerstonejs/cornerstone3D","cs3d_pr_number":123,"cs3d_head_sha":"abc1234..."}}'
```

## How to test locally

### Recommended: `install:cs3d` (one command from a PR URL)

To point OHIF at the latest CS3D prerelease for a specific PR, run:

```bash
export GH_TOKEN=your_github_pat # required for GitHub API to resolve the release
bun run install:cs3d -- https://github.com/cornerstonejs/cornerstone3D/pull/2648
```

This script:

1. Parses the PR URL to get repo and PR number.
2. Fetches the repo’s releases and finds the **latest** prerelease whose tag matches `cs3d-pr-<PR number>-*`.
3. Fails with a clear message if no such release exists (e.g. the PR doesn’t have the `ohif-integration` label or the CS3D workflow hasn’t run yet).
4. Updates all `@cornerstonejs/*` dependency entries and root `resolutions` to the tarball URLs from that release.
5. Writes `.github/cs3d-integration.json` with metadata.
6. Runs `bun run install:update-lockfile` to refresh the lockfile.

After it succeeds, run the viewer as usual (e.g. `yarn dev`). To revert to published CS3D versions, restore `package.json` and lockfile from git and run `yarn install` again.

### Manual: update deps from a known release tag

If you already have a release tag (e.g. from [CS3D Releases](https://github.com/cornerstonejs/cornerstone3D/releases)):

```bash
export GH_TOKEN=your_github_token
node .github/scripts/update-cs3d-deps-from-assets.mjs \
--release-tag cs3d-pr-123-abc1234 \
--repo cornerstonejs/cornerstone3D \
--mode integration-only \
--cs3d-pr 123 \
--cs3d-sha abc1234567890
yarn install
node .github/scripts/verify-cs3d-integration-diff.mjs --mode integration-only
```

### Verify diff only

After making changes, to ensure only allowed files changed:

```bash
CS3D_TRUSTED_REPO=cornerstonejs/cornerstone3D node .github/scripts/verify-cs3d-integration-diff.mjs --mode integration-only
```

### Resolve merged branch (for merged-update flow)

```bash
node .github/scripts/update-open-cs3d-integration-prs.mjs --action merged-update
```

This prints the branch name (`bot/cs3d-merged`) and writes to `GITHUB_OUTPUT` if that env is set.

## Metadata file

`.github/cs3d-integration.json` is written by the update script and records:

```json
{
"mode": "integration-only",
"cs3dPr": 1234,
"cs3dSha": "abcdef...",
"releaseTag": "cs3d-pr-1234-abcdef1",
"cs3dRepo": "cornerstonejs/cornerstone3D"
}
```

For merged-refresh, `cs3dMergedVersion` and `cs3dMergedSha` may be set instead of `cs3dPr` / `cs3dSha`.
6 changes: 6 additions & 0 deletions .github/cs3d-integration.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"mode": "integration-only",
"cs3dPr": 2648,
"releaseTag": "cs3d-pr-2648-d5bfc4c",
"cs3dRepo": "cornerstonejs/cornerstone3D"
}
Comment on lines +1 to +6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integration metadata for a specific CS3D PR is checked in to the base branch

This file records integration state for CS3D PR #2648 with a specific release tag (cs3d-pr-2648-d5bfc4c). Per the CS3D_INTEGRATION.md documentation, this file is written by the automation and is expected to be a per-integration-branch artifact.

If this file is merged to master, verify-cs3d-integration-diff.mjs will fail on the next automation run because the metadata will describe a stale integration. More critically, all the @cornerstonejs/* deps in package.json and the lockfiles still point to tarballs from that CS3D PR, meaning master would depend on a non-published pre-release build of cornerstone3D permanently.

This file (along with the tarball URL changes in all package.json files) should be reverted to the stable semver versions before merging to master.

61 changes: 61 additions & 0 deletions .github/scripts/check-no-cs3d-tarball-deps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Exits with 1 if any package.json in the repo (excluding libs/@cornerstonejs)
* has @cornerstonejs/* dependencies or resolutions pointing at GitHub release
* tarball URLs. Used to block merging integration branches until deps are
* reverted to npm versions.
*/

import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');

async function findPackageJsonFiles(dir, list = []) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.name === 'node_modules') continue;
const relDir = path.relative(ROOT, full).replace(/\\/g, '/');
if (e.isDirectory() && (relDir === 'libs/@cornerstonejs' || relDir.startsWith('libs/@cornerstonejs/'))) continue;
if (e.isDirectory()) {
await findPackageJsonFiles(full, list);
continue;
}
if (e.name === 'package.json') list.push(full);
}
return list;
}

function collectTarballRefs(obj, filePath, out) {
if (!obj || typeof obj !== 'object') return;
for (const [key, value] of Object.entries(obj)) {
if (key.startsWith('@cornerstonejs/') && typeof value === 'string' && value.startsWith('http')) {
out.push({ file: path.relative(ROOT, filePath), pkg: key, value });
}
if (typeof value === 'object' && key !== 'scripts') collectTarballRefs(value, filePath, out);
}
}

async function main() {
const pkgPaths = await findPackageJsonFiles(ROOT);
const refs = [];
for (const p of pkgPaths) {
const obj = JSON.parse(await fs.readFile(p, 'utf-8'));
collectTarballRefs(obj, p, refs);
}
if (refs.length === 0) {
console.log('OK: No @cornerstonejs/* tarball URLs in package.json files.');
process.exit(0);
}
console.error('Merge block: package.json contains CS3D GitHub release tarball refs. Revert to npm versions before merging.');
refs.forEach(({ file, pkg, value }) => console.error(` ${file}: ${pkg} = ${value}`));
process.exit(1);
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
199 changes: 199 additions & 0 deletions .github/scripts/update-cs3d-deps-from-assets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* Updates OHIF package.json dependency references for @cornerstonejs/* packages
* to point at tarball URLs from a CS3D GitHub release. Writes .github/cs3d-integration.json.
*
* Usage:
* node update-cs3d-deps-from-assets.mjs --assets <path-to-JSON-or-TSV> [options]
* node update-cs3d-deps-from-assets.mjs --release-tag <tag> --repo <owner/repo> [options]
*
* Options:
* --mode integration-only | paired-change
* --metadata-path <path> default .github/cs3d-integration.json
* --cs3d-pr <number> for integration-only
* --cs3d-sha <sha>
* --cs3d-repo <owner/repo>
* --cs3d-merged-version <version> for merged-refresh
* --cs3d-merged-sha <sha>
*
* Asset list: JSON array of { name } or TSV with header row, first column = asset name.
* Package mapping: asset "cornerstonejs-<pkg>-<version>.tgz" -> @cornerstonejs/<pkg>
*/

import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');

/** Parse asset filename to @cornerstonejs package name. e.g. cornerstonejs-dicom-image-loader-4.18.5.tgz -> @cornerstonejs/dicom-image-loader */
function assetNameToPackage(assetName) {
if (!assetName.endsWith('.tgz')) return null;
const base = assetName.slice(0, -4);
if (!base.startsWith('cornerstonejs-')) return null;
const rest = base.slice('cornerstonejs-'.length);
const lastDash = rest.lastIndexOf('-');
if (lastDash === -1) return null;
const possibleVer = rest.slice(lastDash + 1);
if (!/^\d+\.\d+\.\d+(-.+)?$/.test(possibleVer)) return null;
const pkgPart = rest.slice(0, lastDash);
return `@cornerstonejs/${pkgPart}`;
}

/** Build tarball URL for a release asset. */
function tarballUrl(ownerRepo, tag, assetName) {
const encoded = encodeURIComponent(assetName);
return `https://github.com/${ownerRepo}/releases/download/${tag}/${encoded}`;
}

/** Recursively find all package.json under dir, excluding node_modules and libs/@cornerstonejs (CS3D sub-repo). */
async function findPackageJsonFiles(dir, list = []) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.name === 'node_modules') continue;
const relDir = path.relative(ROOT, full).replace(/\\/g, '/');
if (e.isDirectory() && (relDir === 'libs/@cornerstonejs' || relDir.startsWith('libs/@cornerstonejs/'))) continue;
if (e.isDirectory()) {
await findPackageJsonFiles(full, list);
continue;
}
if (e.name === 'package.json') list.push(full);
}
return list;
}

/** Update one package.json: replace any dep in packageMap (pkg -> tarball URL) in deps, peerDependencies, optionalDependencies. */
function updatePackageJson(obj, packageMap) {
let changed = false;
for (const key of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
if (!obj[key] || typeof obj[key] !== 'object') continue;
for (const [pkg, url] of Object.entries(packageMap)) {
if (obj[key][pkg] !== undefined) {
obj[key][pkg] = url;
changed = true;
}
}
}
return changed;
}

/** Update root package.json resolutions for packageMap. */
function updateResolutions(obj, packageMap) {
if (!obj.resolutions || typeof obj.resolutions !== 'object') return false;
let changed = false;
for (const [pkg, url] of Object.entries(packageMap)) {
if (obj.resolutions[pkg] !== undefined) {
obj.resolutions[pkg] = url;
changed = true;
}
}
return changed;
}

async function loadAssetList(assetsInput, releaseTag, ownerRepo, token) {
if (assetsInput) {
const raw = await fs.readFile(assetsInput, 'utf-8');
let names = [];
if (assetsInput.endsWith('.json')) {
const data = JSON.parse(raw);
names = Array.isArray(data) ? data.map((a) => (typeof a === 'string' ? a : a.name)) : Object.keys(data);
} else {
const lines = raw.trim().split('\n');
const first = lines[0];
const idx = first.includes('\t') ? 0 : 0;
names = lines.slice(1).map((line) => line.split(/\t/)[idx].trim()).filter(Boolean);
if (lines[0] && !lines[0].startsWith('cornerstonejs-')) names = [first.split(/\t/)[idx].trim(), ...names];
}
return names;
}
if (releaseTag && ownerRepo && token) {
const [owner, repo] = ownerRepo.split('/');
const res = await fetch(
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(releaseTag)}`,
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.v3+json' } }
);
if (!res.ok) throw new Error(`Release fetch failed: ${res.status} ${await res.text()}`);
const release = await res.json();
return (release.assets || []).map((a) => a.name);
}
throw new Error('Provide either --assets <file> or --release-tag and --repo and GH token');
}

function parseArgs() {
const args = process.argv.slice(2);
const out = {
assets: null,
releaseTag: null,
repo: null,
mode: 'integration-only',
metadataPath: path.join(ROOT, '.github', 'cs3d-integration.json'),
cs3dPr: null,
cs3dSha: null,
cs3dRepo: null,
cs3dMergedVersion: null,
cs3dMergedSha: null,
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--assets' && args[i + 1]) { out.assets = path.resolve(args[++i]); continue; }
if (args[i] === '--release-tag' && args[i + 1]) { out.releaseTag = args[++i]; continue; }
if (args[i] === '--repo' && args[i + 1]) { out.repo = args[++i]; continue; }
if (args[i] === '--mode' && args[i + 1]) { out.mode = args[++i]; continue; }
if (args[i] === '--metadata-path' && args[i + 1]) { out.metadataPath = path.resolve(args[++i]); continue; }
if (args[i] === '--cs3d-pr' && args[i + 1]) { out.cs3dPr = args[++i]; continue; }
if (args[i] === '--cs3d-sha' && args[i + 1]) { out.cs3dSha = args[++i]; continue; }
if (args[i] === '--cs3d-repo' && args[i + 1]) { out.cs3dRepo = args[++i]; continue; }
if (args[i] === '--cs3d-merged-version' && args[i + 1]) { out.cs3dMergedVersion = args[++i]; continue; }
if (args[i] === '--cs3d-merged-sha' && args[i + 1]) { out.cs3dMergedSha = args[++i]; continue; }
}
return out;
}

async function main() {
const opts = parseArgs();
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
const assetNames = await loadAssetList(opts.assets, opts.releaseTag, opts.repo, token);

const packageToUrl = {};
const ownerRepo = opts.repo || opts.cs3dRepo;
const tag = opts.releaseTag;
if (!ownerRepo || !tag) throw new Error('Need --repo and --release-tag (or from payload) for tarball URLs');

for (const name of assetNames) {
const pkg = assetNameToPackage(name);
if (pkg) packageToUrl[pkg] = tarballUrl(ownerRepo, tag, name);
}

if (Object.keys(packageToUrl).length === 0) {
console.warn('No @cornerstonejs tarball assets found in asset list.');
process.exit(1);
}

const pkgPaths = await findPackageJsonFiles(ROOT);
for (const p of pkgPaths) {
const obj = JSON.parse(await fs.readFile(p, 'utf-8'));
const isRoot = path.dirname(p) === ROOT;
let changed = updatePackageJson(obj, packageToUrl);
if (isRoot) changed = updateResolutions(obj, packageToUrl) || changed;
if (changed) await fs.writeFile(p, JSON.stringify(obj, null, 2) + '\n');
}

const metadata = {
mode: opts.mode,
cs3dPr: opts.cs3dPr ? Number(opts.cs3dPr) : undefined,
cs3dSha: opts.cs3dSha || undefined,
releaseTag: tag,
cs3dRepo: ownerRepo,
cs3dMergedVersion: opts.cs3dMergedVersion || undefined,
cs3dMergedSha: opts.cs3dMergedSha || undefined,
};
await fs.mkdir(path.dirname(opts.metadataPath), { recursive: true });
await fs.writeFile(opts.metadataPath, JSON.stringify(metadata, null, 2) + '\n');
console.log('Updated CS3D deps to release', tag, 'and wrote', opts.metadataPath);
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
Loading
Loading