Skip to content

Commit 4fe5f37

Browse files
committed
fix(release): validate published schemas and reuse accepted processing runtime
1 parent 345b735 commit 4fe5f37

9 files changed

Lines changed: 228 additions & 56 deletions

File tree

‎.github/workflows/build.yaml‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
workflow_dispatch:
77
inputs:
88
publish:
9-
description: Publish the accepted stable 2.0.0 from main
9+
description: Publish the accepted version from main
1010
type: boolean
1111
default: false
1212
permissions:
@@ -26,7 +26,8 @@ jobs:
2626
sudo apt-get update
2727
sudo apt-get install -y podman graphviz
2828
uv venv --python 3.12 .venv
29-
uv pip sync --python .venv/bin/python --require-hashes requirements-notebooks.txt
29+
uv pip sync --python .venv/bin/python --require-hashes requirements/development.txt
30+
uv pip install --python .venv/bin/python --no-deps --no-build-isolation -e . -e runner
3031
uv pip check --python .venv/bin/python
3132
- name: Validate release contracts and wrapper API
3233
run: |
@@ -42,8 +43,13 @@ jobs:
4243
python scripts/prepare-oci.py --source-dir cwl-workflow --wrapped-dir acceptance-results/wrapped --output acceptance-results/annotations --revision "$GITHUB_SHA" --version "$(jq -er .version codemeta.json)"
4344
- name: Test the processing runner
4445
run: |
45-
cd runner
46-
../.venv/bin/python -m nose2 -v
46+
.venv/bin/python -m pytest -q
47+
- name: Build the exact candidate processing runtime
48+
run: |
49+
mkdir -p acceptance-results/runtime
50+
docker build -t ghcr.io/eoap/application-package-patterns/runner:acceptance runner
51+
docker save -o acceptance-results/runtime/runner.tar ghcr.io/eoap/application-package-patterns/runner:acceptance
52+
podman load -i acceptance-results/runtime/runner.tar
4753
- name: Start authenticated disposable S3
4854
run: |
4955
printf '%s' '{"identities":[{"name":"test","credentials":[{"accessKey":"test","secretKey":"test"}],"actions":["Admin","Read","Write","List","Tagging"]}]}' > /tmp/s3.json
@@ -54,7 +60,7 @@ jobs:
5460
- name: Execute all sixteen real-data wrapped cases and verify S3
5561
run: |
5662
source .venv/bin/activate
57-
python scripts/run-acceptance.py --runner cwltool --output "$GITHUB_WORKSPACE/acceptance-results/workflows"
63+
python scripts/run-acceptance.py --processing-image ghcr.io/eoap/application-package-patterns/runner:acceptance --runner cwltool --output "$GITHUB_WORKSPACE/acceptance-results/workflows"
5864
- name: Build documentation strictly
5965
run: .venv/bin/mkdocs build --strict --site-dir acceptance-results/site
6066
- uses: actions/upload-artifact@v4
@@ -87,12 +93,15 @@ jobs:
8793
if gh release view "$version" >/dev/null 2>&1; then echo 'Version already published'; exit 1; fi
8894
mkdir release-assets
8995
cp cwl-workflow/*.cwl cwl-workflow/*-inputs.json requirements-notebooks.txt requirements-oci.txt mastering-staging.lock.json release-assets/
90-
cp -r templates release-assets/
96+
cp -r templates requirements release-assets/
9197
mkdir release-assets/wrapped
9298
cp accepted/wrapped/*.cwl release-assets/wrapped/
9399
cp -r accepted/annotations release-assets/
94100
uv venv --python 3.12 .oci-venv
95101
uv pip sync --python .oci-venv/bin/python --require-hashes requirements-oci.txt
102+
uv venv --python 3.12 .venv
103+
uv pip sync --python .venv/bin/python --require-hashes requirements/development.txt
104+
uv pip install --python .venv/bin/python --no-deps --no-build-isolation -e . -e runner
96105
cd release-assets
97106
find . -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
98107
- name: Refuse to replace any existing CWL artifact
@@ -110,6 +119,22 @@ jobs:
110119
echo "Artifact already exists: $name-wrapped:$VERSION"; exit 1
111120
fi
112121
done
122+
- name: Publish the exact accepted processing archive
123+
run: |
124+
image="ghcr.io/eoap/application-package-patterns/runner:$VERSION"
125+
printf '%s' "$GH_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
126+
printf '%s' "$GH_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
127+
if oras resolve "$image" >/dev/null 2>&1; then echo "Image tag already published"; exit 1; fi
128+
docker load -i accepted/runtime/runner.tar
129+
docker tag ghcr.io/eoap/application-package-patterns/runner:acceptance "$image"
130+
docker push "$image"
131+
digest=$(oras resolve "$image")
132+
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
133+
reference="ghcr.io/eoap/application-package-patterns/runner@$digest"
134+
.venv/bin/python -m patterns.release --image "$reference" --directory release-assets
135+
printf '%s\n' "$reference" > release-assets/processing-image.txt
136+
.venv/bin/python scripts/cwl-docs.py --source release-assets --validate --output accepted/published-cwl-docs
137+
.oci-venv/bin/python scripts/prepare-oci.py --source-dir release-assets --wrapped-dir release-assets/wrapped --output release-assets/annotations --revision "$GITHUB_SHA" --version "$VERSION"
113138
- name: Publish and verify standalone and wrapped CWL on GHCR
114139
run: |
115140
printf '%s' "$GH_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin

‎cwl-workflow/test-custom-types.cwl‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,6 @@ $graph:
4848
- ndvi
4949
- ndwi
5050
- none
51-
- type: record
52-
name: https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml#DateTime
53-
fields:
54-
- name: value
55-
type: string
5651
inputs:
5752
- id: bbox
5853
type: https://raw.githubusercontent.com/eoap/schemas/main/ogc.yaml#BBox
@@ -146,11 +141,6 @@ $graph:
146141
- ndvi
147142
- ndwi
148143
- none
149-
- type: record
150-
name: https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml#DateTime
151-
fields:
152-
- name: value
153-
type: string
154144
baseCommand:
155145
- echo
156146
arguments:

‎docs/helpers.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ def restore_imports(value):
174174
result = []
175175
imported = set()
176176
for schema in value["types"]:
177+
existing_import = schema.get("$import") if isinstance(schema, dict) else None
178+
if existing_import in approved:
179+
if existing_import not in imported:
180+
result.append(schema)
181+
imported.add(existing_import)
182+
continue
177183
name = schema.get("name", "") if isinstance(schema, dict) else ""
178184
url = next((url for url in approved if name.startswith(url + "#")), None)
179185
if url:

‎patterns/graph.py‎

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
SCHEMA_ALIASES = {
1818
"https://raw.githubusercontent.com/eoap/schemas/main/ogc.yaml#": "#_shared_ogc_",
19-
"https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml#URI": "#URI",
19+
"https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml#": "#_shared_string_format_",
2020
"https://raw.githubusercontent.com/eoap/schemas/main/geojson.yaml#": "#_shared_geojson_",
2121
}
2222

@@ -61,32 +61,34 @@ def load_document(path: Path) -> Process | list[Process]:
6161
if not isinstance(scoped, dict):
6262
raise ValueError("CWL graph metadata must be a mapping")
6363
result = load_cwl_from_yaml(scoped, uri=path.resolve().as_uri())
64-
_restore_uri_ports(result if isinstance(result, list) else [result])
64+
_restore_shared_ports(result if isinstance(result, list) else [result])
6565
return result
6666

6767

68-
def _restore_uri_ports(processes: list[Process]) -> None:
69-
"""Restore the published URI identifier required by the wrapping SDK."""
70-
uri_type = "https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml#URI"
68+
def _published_type(reference: str) -> str:
69+
"""Expose the original published identifier after temporary SDK scoping."""
70+
fragment = "#" + reference.rsplit("#", 1)[-1] if "#" in reference else reference
71+
for namespace, alias in SCHEMA_ALIASES.items():
72+
if fragment.startswith(alias):
73+
return namespace + fragment[len(alias) :]
74+
return reference
75+
76+
77+
def _restore_shared_ports(processes: list[Process]) -> None:
78+
"""Restore published type identifiers for wrapping and lesson displays."""
7179
for process in processes:
7280
for port in [*process.inputs, *process.outputs]:
73-
if isinstance(port.type_, str) and port.type_.endswith("#URI"):
74-
port.type_ = uri_type
81+
if isinstance(port.type_, str):
82+
port.type_ = _published_type(port.type_)
7583
elif isinstance(port.type_, list):
76-
port.type_ = [
77-
uri_type if isinstance(t, str) and t.endswith("#URI") else t for t in port.type_
78-
]
79-
elif (
80-
isinstance(
81-
port.type_,
82-
(
83-
InputArraySchema,
84-
OutputArraySchema,
85-
CommandInputArraySchema,
86-
CommandOutputArraySchema,
87-
),
88-
)
89-
and isinstance(port.type_.items, str)
90-
and port.type_.items.endswith("#URI")
91-
):
92-
port.type_.items = uri_type
84+
port.type_ = [_published_type(t) if isinstance(t, str) else t for t in port.type_]
85+
elif isinstance(
86+
port.type_,
87+
(
88+
InputArraySchema,
89+
OutputArraySchema,
90+
CommandInputArraySchema,
91+
CommandOutputArraySchema,
92+
),
93+
) and isinstance(port.type_.items, str):
94+
port.type_.items = _published_type(port.type_.items)

‎patterns/release.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Select a processing runtime for acceptance without rewriting shared staging."""
2+
3+
from pathlib import Path
4+
5+
import yaml
6+
7+
RUNNER_IMAGE = "ghcr.io/eoap/application-package-patterns/runner"
8+
9+
10+
def _replace(value: object, image: str) -> int:
11+
count = 0
12+
if isinstance(value, dict):
13+
pull = value.get("dockerPull")
14+
if isinstance(pull, str) and pull.startswith((RUNNER_IMAGE + ":", RUNNER_IMAGE + "@")):
15+
value["dockerPull"] = image
16+
count += 1
17+
for child in value.values():
18+
count += _replace(child, image)
19+
elif isinstance(value, list):
20+
for child in value:
21+
count += _replace(child, image)
22+
return count
23+
24+
25+
def select_processing_image(path: Path, image: str) -> int:
26+
"""Override this course's runner only; retain Mastering images and imports."""
27+
if not image.strip():
28+
raise ValueError("Processing image must not be empty")
29+
document = yaml.safe_load(path.read_text())
30+
count = _replace(document, image)
31+
if not count:
32+
raise ValueError("Workflow does not reference this course's processing image")
33+
path.write_text(yaml.safe_dump(document, sort_keys=False))
34+
return count
35+
36+
37+
def main() -> None:
38+
"""Select the accepted runtime in release assets without rebuilding it."""
39+
import argparse
40+
41+
parser = argparse.ArgumentParser(description=__doc__)
42+
parser.add_argument("--image", required=True)
43+
parser.add_argument("--directory", type=Path, required=True)
44+
args = parser.parse_args()
45+
for path in args.directory.rglob("*.cwl"):
46+
if RUNNER_IMAGE in path.read_text():
47+
select_processing_image(path, args.image)
48+
print(f"Selected accepted processing runtime in {path}")
49+
50+
51+
if __name__ == "__main__":
52+
main()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Candidate testing must never substitute the shared staging runtime."""
2+
3+
import pytest
4+
import yaml
5+
6+
from patterns.release import RUNNER_IMAGE, select_processing_image
7+
8+
9+
def test_only_owned_runner_is_changed(tmp_path):
10+
path = tmp_path / "workflow.cwl"
11+
path.write_text(
12+
yaml.safe_dump(
13+
{
14+
"$graph": [
15+
{
16+
"requirements": {
17+
"DockerRequirement": {"dockerPull": RUNNER_IMAGE + "@sha256:old"}
18+
}
19+
},
20+
{
21+
"requirements": [
22+
{
23+
"class": "DockerRequirement",
24+
"dockerPull": "ghcr.io/eoap/mastering-app-package/stage-out:2.0.1",
25+
}
26+
]
27+
},
28+
{
29+
"types": [
30+
{
31+
"$import": "https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml"
32+
}
33+
]
34+
},
35+
]
36+
}
37+
)
38+
)
39+
assert select_processing_image(path, "runner:accepted") == 1
40+
result = yaml.safe_load(path.read_text())["$graph"]
41+
assert result[0]["requirements"]["DockerRequirement"]["dockerPull"] == "runner:accepted"
42+
assert (
43+
result[1]["requirements"][0]["dockerPull"]
44+
== "ghcr.io/eoap/mastering-app-package/stage-out:2.0.1"
45+
)
46+
assert (
47+
result[2]["types"][0]["$import"]
48+
== "https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml"
49+
)
50+
51+
52+
def test_missing_runner_is_an_error(tmp_path):
53+
path = tmp_path / "workflow.cwl"
54+
path.write_text("class: Workflow\n")
55+
with pytest.raises(ValueError, match="processing image"):
56+
select_processing_image(path, "runner:accepted")

‎runner/tests/test_schema_imports.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,22 @@ def visit(value):
4242
for value in records
4343
)
4444
assert not any(str(value.get("$import", "")).startswith("eoap-types") for value in records)
45+
46+
47+
def test_custom_type_ports_retain_published_identifiers():
48+
from patterns.graph import load_document
49+
50+
root = Path(__file__).resolve().parents[2]
51+
processes = load_document(root / "cwl-workflow/test-custom-types.cwl")
52+
assert isinstance(processes, list)
53+
workflow = next(process for process in processes if process.id.endswith("test-custom-types"))
54+
types = {port.id.rsplit("/", 1)[-1]: port.type_ for port in workflow.inputs}
55+
assert (
56+
types["start_time"]
57+
== "https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml#DateTime"
58+
)
59+
assert (
60+
types["product_uri"]
61+
== "https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml#URI"
62+
)
63+
assert types["bbox"] == "https://raw.githubusercontent.com/eoap/schemas/main/ogc.yaml#BBox"

‎scripts/cwl-docs.py‎

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22
import argparse
33
from pathlib import Path
44
import yaml
5+
from urllib.parse import urlsplit
6+
from schema_salad.fetcher import DefaultFetcher
7+
from cwltool.context import LoadingContext
8+
9+
APPROVED_SCHEMAS = (
10+
"https://raw.githubusercontent.com/eoap/schemas/main/ogc.yaml",
11+
"https://raw.githubusercontent.com/eoap/schemas/main/geojson.yaml",
12+
"https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml",
13+
)
14+
15+
16+
class PublishedSchemaFetcher(DefaultFetcher):
17+
"""Fetch the approved published contracts through the schema SDK."""
18+
19+
def fetch_text(self, url: str, content_types: list[str] | None = None) -> str:
20+
if urlsplit(url).scheme in {"http", "https"} and url.split("#")[0] not in APPROVED_SCHEMAS:
21+
raise RuntimeError("Only approved published schemas may be fetched")
22+
return super().fetch_text(url, content_types)
23+
524

625

726
def render(path):
@@ -14,7 +33,7 @@ def check(value):
1433
if isinstance(value, dict):
1534
assert '$include' not in value, f'{path}: external include'
1635
if '$import' in value:
17-
assert value['$import'] in ('https://raw.githubusercontent.com/eoap/schemas/main/ogc.yaml', 'https://raw.githubusercontent.com/eoap/schemas/main/geojson.yaml'), f'{path}: unapproved schema import'
36+
assert value['$import'] in ('https://raw.githubusercontent.com/eoap/schemas/main/ogc.yaml', 'https://raw.githubusercontent.com/eoap/schemas/main/geojson.yaml', 'https://raw.githubusercontent.com/eoap/schemas/main/string_format.yaml'), f'{path}: unapproved schema import'
1837
for child in value.values(): check(child)
1938
elif isinstance(value, list):
2039
for child in value: check(child)
@@ -53,29 +72,28 @@ def check(value):
5372
parser = argparse.ArgumentParser(description=__doc__)
5473
parser.add_argument('--validate', action='store_true', help='Also validate isolated CWL, allowing the approved OGC schema import')
5574
parser.add_argument('--output', type=Path, default=Path('/tmp/stac-cwl-docs'))
75+
parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parents[1])
5676
args = parser.parse_args()
57-
root = Path(__file__).resolve().parents[1]
77+
root = args.source
5878
for path in sorted(root.rglob('*.cwl')):
5979
if path.name.startswith('.') or not path.stat().st_size or any(part.startswith('.') for part in path.relative_to(root).parts): continue
6080
markdown = render(path)
6181
if args.validate:
62-
import requests
6382
import shutil
6483
import tempfile
84+
from cwltool.context import LoadingContext
6585
from cwltool.main import main
66-
original_request = requests.sessions.Session.request
67-
def blocked_request(session, method, url, **kwargs):
68-
if url.split('#')[0] in ('https://raw.githubusercontent.com/eoap/schemas/main/ogc.yaml', 'https://raw.githubusercontent.com/eoap/schemas/main/geojson.yaml'):
69-
return original_request(session, method, url, **kwargs)
70-
raise RuntimeError('Only the approved OGC schema may be fetched')
71-
requests.sessions.Session.request = blocked_request
72-
try:
73-
with tempfile.TemporaryDirectory() as isolated:
74-
target_cwl = Path(isolated) / path.name
75-
shutil.copy(path, target_cwl)
76-
assert main(['--validate', str(target_cwl)]) == 0, path
77-
finally:
78-
requests.sessions.Session.request = original_request
86+
document = yaml.safe_load(path.read_text())
87+
graph = document.get("$graph")
88+
entrypoints = [process["id"].rsplit("#", 1)[-1] for process in graph] if graph else [None]
89+
with tempfile.TemporaryDirectory() as isolated:
90+
target_cwl = Path(isolated) / path.name
91+
shutil.copy(path, target_cwl)
92+
for entrypoint in entrypoints:
93+
context = LoadingContext()
94+
context.fetcher_constructor = PublishedSchemaFetcher
95+
target = str(target_cwl) + ("#" + entrypoint if entrypoint else "")
96+
assert main(["--validate", target], loadingContext=context) == 0, (path, entrypoint)
7997
target = args.output / path.relative_to(root).with_suffix('.md')
8098
target.parent.mkdir(parents=True, exist_ok=True)
8199
target.write_text(markdown)

0 commit comments

Comments
 (0)