diff --git a/.github/workflows/nightly-schedule.yml b/.github/workflows/nightly-schedule.yml
index b58a96c114..ee7f37d0d1 100644
--- a/.github/workflows/nightly-schedule.yml
+++ b/.github/workflows/nightly-schedule.yml
@@ -8,6 +8,8 @@ jobs:
develop:
permissions:
packages: write
+ pages: write
+ id-token: write
contents: write
uses: ./.github/workflows/release.yml
secrets:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f16620ad7f..f1eed123c7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -49,6 +49,8 @@ jobs:
outputs:
api_image: ${{steps.set_image.outputs.api_image}}
migration_image: ${{steps.migration-publish.outputs.image}}
+ source_sha: ${{steps.source.outputs.sha}}
+ cda_version: ${{steps.version.outputs.version}}
steps:
- name: Clean up disk space, so we don't run out.
if: matrix.platform == 'ubuntu-latest'
@@ -60,6 +62,9 @@ jobs:
uses: actions/checkout@v5.0.0
with:
ref: ${{inputs.branch}}
+ - name: Record source revision
+ id: source
+ run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: setup java
uses: actions/setup-java@v5.2.0
with:
@@ -165,3 +170,15 @@ jobs:
if: ${{ always() }}
run: |
docker logout ${{ steps.login-ghcr.outputs.registry }}
+
+ sdk-docs:
+ needs: release
+ permissions:
+ contents: write
+ pages: write
+ id-token: write
+ uses: ./.github/workflows/sdk-docs.yml
+ with:
+ ref: ${{ needs.release.outputs.source_sha }}
+ cda-version: ${{ needs.release.outputs.cda_version }}
+ release: ${{ !inputs.nightly }}
diff --git a/.github/workflows/sdk-docs.yml b/.github/workflows/sdk-docs.yml
new file mode 100644
index 0000000000..c5140989c5
--- /dev/null
+++ b/.github/workflows/sdk-docs.yml
@@ -0,0 +1,148 @@
+name: SDK documentation
+
+on:
+ pull_request:
+ branches: [develop]
+ paths:
+ - 'clients/**'
+ - 'cwms-data-api/src/**'
+ - 'scripts/sdk-docs/**'
+ - '*.gradle'
+ - '.github/workflows/sdk-docs.yml'
+ - '.github/workflows/release.yml'
+ push:
+ branches: [develop]
+ paths:
+ - 'clients/**'
+ - 'cwms-data-api/src/**'
+ - 'scripts/sdk-docs/**'
+ - '*.gradle'
+ - '.github/workflows/sdk-docs.yml'
+ workflow_dispatch:
+ workflow_call:
+ inputs:
+ ref:
+ type: string
+ required: true
+ cda-version:
+ type: string
+ required: true
+ release:
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.value }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref || github.ref }}
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '11'
+ cache: gradle
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.13'
+ - name: Select documentation version
+ id: version
+ env:
+ CDA_VERSION: ${{ inputs.cda-version }}
+ run: |
+ if [ -z "$CDA_VERSION" ]; then
+ CDA_VERSION="$(date -u +%Y.%m.%d)-snapshot"
+ fi
+ echo "value=$CDA_VERSION" >> "$GITHUB_OUTPUT"
+ - name: Test site assembly
+ run: python -m unittest discover -s scripts/sdk-docs/tests -v
+ - name: Build and test JavaScript SDK documentation
+ env:
+ CDA_VERSION: ${{ steps.version.outputs.value }}
+ run: ./gradlew :clients:typescript:build --init-script init.gradle "-PversionOverride=$CDA_VERSION"
+ - name: Build and test Python SDK documentation when present
+ env:
+ CDA_VERSION: ${{ steps.version.outputs.value }}
+ run: |
+ if [ -f clients/python/build.gradle ]; then
+ ./gradlew :clients:python:build --init-script init.gradle "-PversionOverride=$CDA_VERSION"
+ fi
+ - name: Assemble SDK documentation
+ env:
+ CDA_VERSION: ${{ steps.version.outputs.value }}
+ run: python scripts/sdk-docs/site.py stage . build/sdk-docs "$CDA_VERSION"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: sdk-docs-${{ github.run_id }}-${{ github.run_attempt }}
+ path: build/sdk-docs
+ if-no-files-found: error
+
+ publish:
+ needs: build
+ if: github.event_name != 'pull_request' && github.repository == 'USACE/cwms-data-api'
+ runs-on: ubuntu-latest
+ concurrency:
+ group: sdk-pages
+ cancel-in-progress: false
+ permissions:
+ contents: write
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref || github.ref }}
+ - uses: actions/download-artifact@v4
+ with:
+ name: sdk-docs-${{ github.run_id }}-${{ github.run_attempt }}
+ path: build/sdk-docs
+ - name: Restore documentation history
+ run: |
+ if git ls-remote --exit-code --heads origin sdk-pages; then
+ git fetch origin sdk-pages --depth=1
+ git worktree add site FETCH_HEAD
+ else
+ mkdir site
+ git -C site init
+ git -C site switch --orphan sdk-pages
+ git -C site config include.path "$GITHUB_WORKSPACE/.git/config"
+ fi
+ - name: Update documentation site
+ env:
+ CDA_VERSION: ${{ needs.build.outputs.version }}
+ IS_RELEASE: ${{ inputs.release }}
+ run: |
+ args=()
+ if [ "$IS_RELEASE" = true ]; then args+=(--release); fi
+ python scripts/sdk-docs/site.py publish build/sdk-docs site "$CDA_VERSION" "${args[@]}"
+ - name: Save documentation history
+ run: |
+ git -C site config user.name github-actions[bot]
+ git -C site config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ git -C site add .
+ if ! git -C site diff --cached --quiet; then
+ git -C site commit -m "Update SDK documentation"
+ git -C site push origin HEAD:refs/heads/sdk-pages
+ fi
+ - uses: actions/configure-pages@v5
+ - name: Prepare Pages artifact
+ run: |
+ mkdir build/pages
+ rsync -a --exclude=.git site/ build/pages/
+ - uses: actions/upload-pages-artifact@v4
+ with:
+ path: build/pages
+ - uses: actions/deploy-pages@v4
+ id: deployment
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
index 7f86ed6f1e..8e0de0e661 100644
--- a/.github/workflows/tagged-release.yml
+++ b/.github/workflows/tagged-release.yml
@@ -10,6 +10,11 @@ on:
- '[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]'
jobs:
release:
+ permissions:
+ contents: write
+ packages: write
+ pages: write
+ id-token: write
uses: ./.github/workflows/release.yml
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/clients/typescript/README.md b/clients/typescript/README.md
index 8321cc20aa..030566ade8 100644
--- a/clients/typescript/README.md
+++ b/clients/typescript/README.md
@@ -76,3 +76,17 @@ The Gradle build passes the CDA project version into the client package step. Wh
`./gradlew :clients:typescript:build`
All generated files (source, library, and docs) will be in `[repo]/clients/typescript/cwmsjs`
+
+## Hosted documentation
+
+The [CDA SDK site](https://usace.github.io/cwms-data-api/sdk/javascript/) hosts
+TypeDoc and the custom examples extracted from passing tests. CDA release builds
+publish matching versioned docs, while `develop` publishes separately under
+[/development/sdk/javascript/](https://usace.github.io/cwms-data-api/development/sdk/javascript/).
+These URLs become available after the SDK Pages workflow is deployed.
+
+The Gradle build runs the documentation examples before generating their pages.
+Run `:clients:typescript:testTypeScriptExamples` to test them directly. The
+private-server authorization template is excluded from public examples.
+See [the SDK Pages workflow guide](../../scripts/sdk-docs/README.md) for setup,
+release retention, and local validation.
diff --git a/clients/typescript/build.gradle b/clients/typescript/build.gradle
index 245340aa66..1c447a2344 100644
--- a/clients/typescript/build.gradle
+++ b/clients/typescript/build.gradle
@@ -1,5 +1,4 @@
import com.github.gradle.node.npm.task.NpmTask
-import com.github.gradle.node.npm.task.NpxTask
import org.openapitools.generator.gradle.plugin.tasks.GenerateTask
import org.openapitools.generator.gradle.plugin.tasks.ValidateTask
@@ -13,19 +12,25 @@ def npmCacheDir = layout.buildDirectory.dir("npm-cache")
def rawSpecFile = layout.projectDirectory.file("cwms-swagger-raw.json")
def modifiedSpecFile = layout.projectDirectory.file("cwms-swagger-mod.json")
def generatedClientDir = layout.projectDirectory.dir("cwmsjs")
+def specOverride = providers.gradleProperty('typescriptOpenApiSpec')
+def sourceSpec = specOverride.isPresent() ? file(specOverride.get()) : rootProject.file('cwms-data-api/build/openapi.json')
node {
nodeProjectDir = projectDir
}
-tasks.withType(NpmTask).configureEach {
- environment.put("npm_config_cache", npmCacheDir.get().asFile.absolutePath)
+tasks.named('npmInstall') {
+ // The generator uses the JS scripts directly; legacy node-jq install hooks
+ // are unnecessary here. Generated package installation still runs scripts.
+ args = ['--ignore-scripts', '--no-audit']
}
-tasks.withType(NpxTask).configureEach {
+tasks.withType(NpmTask).configureEach {
environment.put("npm_config_cache", npmCacheDir.get().asFile.absolutePath)
+ environment.put("npm_config_audit", "false")
}
+
tasks.register('cleanGeneration', Delete) {
delete project.layout.projectDirectory.dir('build')
delete project.layout.projectDirectory.dir('dist')
@@ -40,23 +45,25 @@ clean.dependsOn cleanGeneration
tasks.register('copyOpenApiSpec') {
group 'openapi'
description 'Copy the locally generated CDA OpenAPI spec into the TypeScript client workspace.'
- dependsOn ':cwms-data-api:executeOpenAPIConversion'
+ if (!specOverride.isPresent()) {
+ dependsOn ':cwms-data-api:executeOpenAPIConversion'
+ }
+ inputs.file sourceSpec
outputs.file rawSpecFile
doLast {
copy {
- from "$rootDir/cwms-data-api/build/openapi.json"
+ from sourceSpec
into projectDir
rename { 'cwms-swagger-raw.json' }
}
}
}
-tasks.register('modSpec', NpxTask) {
+tasks.register('modSpec', NpmTask) {
group 'openapi'
description 'Apply TypeScript client adjustments to the CDA OpenAPI spec.'
dependsOn npmInstall
dependsOn copyOpenApiSpec
- command = 'npm'
args = ['run', 'modSpec']
inputs.file rawSpecFile
inputs.files fileTree('scripts/spec-updates')
@@ -90,12 +97,11 @@ tasks.register('generateTypeScriptClient', GenerateTask) {
ignoreFileOverride = layout.projectDirectory.file('.openapi-generator-ignore').asFile.absolutePath
}
-tasks.register('modPackage', NpxTask) {
+tasks.register('modPackage', NpmTask) {
group 'openapi'
description 'Apply package metadata updates to the generated cwmsjs client.'
dependsOn npmInstall
dependsOn generateTypeScriptClient
- command = 'npm'
args = ['run', 'modPackage']
environment.put('CDA_CLIENT_VERSION_SUFFIX', project.version.toString())
inputs.file 'package.json'
@@ -104,34 +110,31 @@ tasks.register('modPackage', NpxTask) {
outputs.file generatedClientDir.file('package.json')
}
-tasks.register('postGenerate', NpxTask) {
+tasks.register('postGenerate', NpmTask) {
group 'openapi'
description 'Apply source patches required after OpenAPI generation.'
dependsOn modPackage
- command = 'npm'
args = ['run', 'postGenerate']
inputs.dir generatedClientDir
inputs.files fileTree('scripts').include('postGenerate.js')
outputs.dir generatedClientDir
}
-tasks.register('installGeneratedClient', NpxTask) {
+tasks.register('installGeneratedClient', NpmTask) {
group 'build'
description 'Install dependencies for the generated cwmsjs client.'
dependsOn postGenerate
workingDir = generatedClientDir.asFile
- command = 'npm'
args = ['install']
inputs.file generatedClientDir.file('package.json')
outputs.dir generatedClientDir.dir('node_modules')
}
-tasks.register('buildTypeScriptClient', NpxTask) {
+tasks.register('buildTypeScriptClient', NpmTask) {
group 'build'
description 'Compile the generated cwmsjs TypeScript client.'
dependsOn installGeneratedClient
workingDir = generatedClientDir.asFile
- command = 'npm'
args = ['run', 'build']
inputs.dir generatedClientDir.dir('src')
inputs.file generatedClientDir.file('package.json')
@@ -139,18 +142,30 @@ tasks.register('buildTypeScriptClient', NpxTask) {
outputs.dir generatedClientDir.dir('dist')
}
-tasks.register('buildTypeScriptDocs', NpxTask) {
+tasks.register('buildTypeScriptDocs', NpmTask) {
group 'documentation'
description 'Build TypeDoc and example documentation for the generated cwmsjs client.'
- dependsOn buildTypeScriptClient
- command = 'npm'
+ dependsOn 'testTypeScriptExamples'
args = ['run', 'buildDocs']
inputs.dir generatedClientDir.dir('src')
inputs.dir 'tests'
- inputs.files fileTree('scripts').include('buildTypedoc.js', 'tests2exampledocs.js', 'exampletemplate.html')
+ inputs.files fileTree('scripts').include('buildTypedoc.js', 'tests2exampledocs.js', 'exampleFiles.js', 'exampletemplate.html')
outputs.dir generatedClientDir.dir('docs')
}
+tasks.register('installExampleDependencies', NpmTask) {
+ workingDir = file('tests')
+ args = ['ci']
+}
+
+tasks.register('testTypeScriptExamples', NpmTask) {
+ group 'verification'
+ description 'Run the examples before publishing their source as documentation.'
+ dependsOn buildTypeScriptClient, installExampleDependencies
+ workingDir = file('tests')
+ args = ['run', 'test:docs']
+}
+
tasks.named('build') {
dependsOn buildTypeScriptClient
dependsOn buildTypeScriptDocs
diff --git a/clients/typescript/scripts/exampleFiles.js b/clients/typescript/scripts/exampleFiles.js
new file mode 100644
index 0000000000..6ba995375b
--- /dev/null
+++ b/clients/typescript/scripts/exampleFiles.js
@@ -0,0 +1,16 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+
+const root = path.resolve(__dirname, '..');
+function exampleFiles() {
+ return ['endpoints', 'generator'].flatMap(directory =>
+ fs.readdirSync(path.join(root, 'tests', directory))
+ .filter(name => name.endsWith('.test.js'))
+ .map(name => path.join('tests', directory, name))
+ ).filter(file => !fs.readFileSync(path.join(root, file), 'utf8').includes('//!ignore')).sort();
+}
+function digest(file) {
+ return crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex');
+}
+module.exports = { root, exampleFiles, digest };
diff --git a/clients/typescript/scripts/spec-updates/modSpec.js b/clients/typescript/scripts/spec-updates/modSpec.js
index 15e8c4a67d..bf4b166a86 100644
--- a/clients/typescript/scripts/spec-updates/modSpec.js
+++ b/clients/typescript/scripts/spec-updates/modSpec.js
@@ -72,10 +72,6 @@ function stripCwmsDataPrefix(spec) {
paths[normalizedRoute] = routeConfig;
}
spec.paths = paths;
- spec.servers = (spec.servers || []).map((server) => ({
- ...server,
- url: server.url.replace(/\/cwms-data\/?$/, ""),
- }));
}
function normalizeOperationIds(spec) {
@@ -161,6 +157,13 @@ function main() {
const cleaned = removeUniqueItems(spec);
const normalized = normalizeStrings(cleaned);
+ // Operation names use TimeSeries, but CDA routes remain /timeseries.
+ normalized.paths = Object.fromEntries(
+ Object.entries(cleaned.paths).map(([route, item]) => [
+ route.replace(/\{([^}]+)\}/g, (_, parameter) => `{${replaceInString(parameter)}}`),
+ normalizeStrings(item),
+ ]),
+ );
normalizeOperationIds(normalized);
stripCwmsDataPrefix(normalized);
diff --git a/clients/typescript/scripts/testExamples.js b/clients/typescript/scripts/testExamples.js
new file mode 100644
index 0000000000..c219a4dafb
--- /dev/null
+++ b/clients/typescript/scripts/testExamples.js
@@ -0,0 +1,16 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+const { root, exampleFiles, digest } = require('./exampleFiles');
+const files = exampleFiles();
+const manifest = path.join(root, 'build', 'tested-examples.json');
+fs.mkdirSync(path.dirname(manifest), { recursive: true });
+fs.rmSync(manifest, { force: true });
+if (!files.length) throw new Error('No documentation examples were found');
+const result = spawnSync(process.execPath, [
+ '--experimental-vm-modules', path.join(root, 'tests/node_modules/jest/bin/jest.js'),
+ '--runInBand', '--ci', '--runTestsByPath', ...files.map(file => path.join(root, file)),
+], { cwd: path.join(root, 'tests'), stdio: 'inherit' });
+if (result.error) throw result.error;
+if (result.status !== 0) process.exit(result.status || 1);
+fs.writeFileSync(manifest, JSON.stringify(Object.fromEntries(files.map(file => [file.replaceAll('\\', '/'), digest(file)])), null, 2) + '\n');
diff --git a/clients/typescript/scripts/tests2exampledocs.js b/clients/typescript/scripts/tests2exampledocs.js
index 557eca170b..96c26fc855 100644
--- a/clients/typescript/scripts/tests2exampledocs.js
+++ b/clients/typescript/scripts/tests2exampledocs.js
@@ -6,6 +6,8 @@
const fs = require("fs");
const path = require("path");
const prettier = require("prettier");
+const { exampleFiles, digest, root } = require('./exampleFiles');
+const testedExamples = JSON.parse(fs.readFileSync(path.join(root, 'build/tested-examples.json'), 'utf8'));
const prettierOptions = {
parser: "babel",
@@ -27,16 +29,12 @@ if (!fs.existsSync(outputDirectory)) {
fs.mkdirSync(outputDirectory, { recursive: true });
}
function getFiles(directories) {
- // Combine all the test files across directories we care about
- let all_files = [];
- directories.forEach((d) => {
- all_files = fs
- .readdirSync(d)
- .filter((fileName) => fileName.endsWith(".test.js"))
- .map((fileName) => path.join(d, fileName))
- .concat(all_files);
+ return exampleFiles().map(file => {
+ if (testedExamples[file.replaceAll('\\', '/')] !== digest(file)) {
+ throw new Error(`Example changed since testing: ${file}. Run the documentation tests first.`);
+ }
+ return file;
});
- return all_files.sort();
}
// Read the HTML template
fs.readFile(templatePath, "utf8", (err, template) => {
@@ -59,8 +57,7 @@ fs.readFile(templatePath, "utf8", (err, template) => {
console.log(extractBlock[0]);
// Strip out the bits we do not need from the file
let block = extractBlock[1]
- .replaceAll(/expect\((.*?)\)\.toBeDefined\(\)/g, "console.log($1)") // Convert expects to logs
- .replaceAll("await ", "") // Let users decide when to add await
+ .replaceAll(/expect\((.*?)\)\.toBe(?:Defined\(\)|\(true\))/g, "console.log($1)")
.trim();
// Adjust indentation (simple left trim here, more sophisticated methods might be needed)
@@ -95,7 +92,7 @@ fs.readFile(templatePath, "utf8", (err, template) => {
To Install:
npm install cwmsjs --save
-${combinedImports}\n\n${formattedBlock}+${escapeHtml(combinedImports + "\n\n" + formattedBlock)}