diff --git a/.github/workflows/updateCodeJSON.yml b/.github/workflows/updateCodeJSON.yml
index 6cafbc1f..8ceae3d0 100644
--- a/.github/workflows/updateCodeJSON.yml
+++ b/.github/workflows/updateCodeJSON.yml
@@ -1,6 +1,10 @@
name: Update Code.json
on:
workflow_dispatch:
+ pull_request:
+ types: [opened, synchronize]
+ paths:
+ - "code.json"
permissions:
contents: write
@@ -18,12 +22,12 @@ jobs:
- name: Update code.json
id: generator
- uses: DSACMS/automated-codejson-generator@main
+ uses: DSACMS/automated-codejson-generator@sachin/jsonValidationImplementation
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ADMIN_TOKEN: ${{ secrets.ADMIN_PAT }}
+ ADMIN_TOKEN: ${{ secrets.ADMIN_PAT }}
BRANCH: "main"
- SKIP_PR: "true"
+ SKIP_PR: "false"
- name: Post update information
run: |
@@ -33,4 +37,3 @@ jobs:
elif [ "${{ steps.generator.outputs.method_used }}" = "pull_request" ]; then
echo "Created pull request: ${{ steps.generator.outputs.pr_url }}"
fi
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c34ae375..4d68bed7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,6 +38,18 @@ npm run package
npm test
```
+## Validation
+
+The action uses [Zod](https://zod.dev/) for schema validation, automatically validating code.json in two scenarios:
+
+### 1. Before Generation
+
+Every time the action generates or updates code.json (via schedule or workflow_dispatch), it validates the output before creating a PR or pushing. If validation fails, no changes are made.
+
+### 2. On PR Edits
+
+When the `pull_request` trigger is configured, the action validates code.json whenever it's edited in a PR. This ensures users cannot accidentally merge invalid JSON.
+
### Workflow and Branching
We follow the [GitHub Flow Workflow](https://guides.github.com/introduction/flow/):
diff --git a/README.md b/README.md
index bf1ebdef..15022a87 100644
--- a/README.md
+++ b/README.md
@@ -33,10 +33,13 @@ ADMIN_TOKEN:
```yaml
updated:
description: "Boolean indicating whether code.json was updated"
+
pr_url:
description: "URL of the created pull request if changes were made via PR"
+
commit_sha:
description: "SHA of the commit if pushed directly to branch"
+
method_used:
description: "Method used for the update: 'direct_push' or 'pull_request'"
```
@@ -45,14 +48,18 @@ method_used:
### Option 1: Direct Push
-This approach tries to push directly to the branch using a Personal Access Token, but falls back to creating a pull request if the direct push fails.
+This approach tries to push directly to the branch using a Personal Access Token, but falls back to creating a pull request if the direct push fails. When users need to edit code.json, they should create a PR which will automatically validate their changes.
```yaml
-name: Update Code.json (Smart Mode)
+name: Update Code.json
on:
schedule:
- cron: 0 0 1 * * # First day of every month
workflow_dispatch:
+ pull_request:
+ types: [opened, synchronize]
+ paths:
+ - "code.json"
permissions:
contents: write
@@ -73,7 +80,7 @@ jobs:
uses: DSACMS/automated-codejson-generator@v1.2.0
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ADMIN_TOKEN: ${{ secrets.ADMIN_PAT }} # PAT with admin/push permissions
+ ADMIN_TOKEN: ${{ secrets.ADMIN_PAT }} # PAT with admin/push permissions
BRANCH: "main"
SKIP_PR: "true"
@@ -89,7 +96,7 @@ jobs:
### Option 2: Pull Request Only
-This approach always creates a pull request, ensuring code review for all changes.
+This approach always creates a pull request for both automatic generation and validation of manual edits, ensuring code review for all changes.
```yaml
name: Update Code.json
@@ -97,6 +104,10 @@ on:
schedule:
- cron: 0 0 1 * * # First day of every month
workflow_dispatch:
+ pull_request:
+ types: [opened, synchronize]
+ paths:
+ - "code.json"
permissions:
contents: write
@@ -113,13 +124,29 @@ jobs:
fetch-depth: 0
- name: Update code.json
- uses: DSACMS/automated-codejson-generator@latest
+ uses: DSACMS/automated-codejson-generator@v1.2.0
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: "main"
- SKIP_PR: "false"
+ SKIP_PR: "false"
```
+### How It Works
+
+**Automatic Generation**
+
+- The action calculates metadata, validates it, and creates a PR or pushes directly
+- Validation ensures only valid code.json is created
+- Users can then fill in manual fields by editing the PR
+
+**PR Validation**
+
+- When users edit code.json in a PR, validation runs automatically on every commit
+- The PR cannot be merged if validation fails (when branch protection is enabled)
+- Error messages help users fix issues quickly
+
+**Important:** For direct push mode, users should always create PRs when manually editing code.json to ensure validation runs. Direct edits to the main branch will not be validated by this action.
+
## Setting Up Personal Access Token (PAT)
To use the direct push functionality, you'll need to create a Personal Access Token:
@@ -128,14 +155,14 @@ To use the direct push functionality, you'll need to create a Personal Access To
1. **Go to GitHub Settings**: Navigate to your GitHub account settings
2. **Developer Settings**: Click on "Developer settings" in the left sidebar
-3. **Personal Access Tokens**: Choose "Tokens (classic)" or "Fine-grained tokens"
+3. **Personal Access Tokens**: Choose "Tokens (classic)"
4. **Generate New Token**: Click "Generate new token"
5. **Configure Token**:
- - **Name**: Give it a descriptive name like "Code.json Generator"
+ - **Name**: Give it a name like "code.json Generator"
- **Expiration**: Set appropriate expiration (recommend 90 days or 1 year)
- - **Scopes**:
- - For classic tokens: Select `repo` (full repository access)
- - For fine-grained tokens: Select `Contents` (write) and `Metadata` (read)
+ - **Scopes**:
+ - Select `repo` (full repository access)
+6. **Store Token**: Copy and paste your token and store it for the next part
### Adding PAT to Repository
@@ -148,8 +175,7 @@ To use the direct push functionality, you'll need to create a Personal Access To
5. **Save**: Click "Add secret"
⚠️ _Please make sure the following are enabled within your Repository Action Settings in order to work properly_ ⚠️
-
-
+
## Generation Context
@@ -173,7 +199,7 @@ The automated code.json generator calculates specific fields by analyzing your r
**dateLastModified**: This uses your repository's last update timestamp, reflecting the most recent changes. No configuration needed.
-**dateMetaDataLastUpdated**: The generator sets this to the current timestamp each time it runs, providing a record of when the metadata was last refreshed. No configuration needed.
+**dateMetadataLastUpdated**: The generator sets this to the current timestamp each time it runs, providing a record of when the metadata was last refreshed. No configuration needed.
**feedbackMechanism**: The repository's issues URL in the format of {repositoryURL}/issues. If you already have a code.json file with existing feedback mechanisms, the generator preserves those values. No configuration needed.
@@ -213,6 +239,7 @@ An up-to-date list of core team members can be found in [MAINTAINERS.md](MAINTAI
.
├── src/
│ ├── model.ts # TypeScript interfaces for code.json schema
+│ ├── validation.ts # Zod schema definitions and validation logic
│ ├── main.ts # Main action logic
│ ├── helper.ts # Helper functions for GitHub API interactions
│ └── index.ts # Action entrypoint
diff --git a/code.json b/code.json
index 6c5a3fa5..54197696 100644
--- a/code.json
+++ b/code.json
@@ -28,19 +28,10 @@
"forks": 2,
"clones": 0
},
- "platforms": [
- "web",
- "linux"
- ],
- "categories": [
- "developer-tools",
- "automation"
- ],
+ "platforms": ["web", "linux"],
+ "categories": ["developer-tools", "automation"],
"softwareType": "standalone/backend",
- "languages": [
- "TypeScript",
- "JavaScript"
- ],
+ "languages": ["TypeScript", "JavaScript"],
"maintenance": "internal",
"contractNumber": [],
"SBOM": "https://github.com/DSACMS/automated-codejson-generator/network/dependencies",
@@ -50,11 +41,9 @@
"date": {
"created": "2025-02-07T16:29:38Z",
"lastModified": "2025-09-17T22:40:02Z",
- "metaDataLastUpdated": "2025-09-26T15:08:24.592Z"
+ "metadataLastUpdated": "2025-09-26T15:08:24.592Z"
},
- "tags": [
- "cmsoss-tier2"
- ],
+ "tags": ["cmsoss-tier2"],
"contact": {
"email": "opensource@cms.hhs.gov",
"name": "CMS Open Source Team"
@@ -66,15 +55,9 @@
"userInput": false,
"fismaLevel": "Low",
"group": "CMS/OA/DSAC",
- "projects": [
- "SHARE IT Act"
- ],
+ "projects": ["SHARE IT Act"],
"systems": [],
- "subsetInHealthcare": [
- "Operational"
- ],
- "userType": [
- "Government"
- ],
+ "subsetInHealthcare": ["Operational"],
+ "userType": ["Government"],
"maturityModelTier": 3
-}
\ No newline at end of file
+}
diff --git a/package-lock.json b/package-lock.json
index e555dadf..94ef0bba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,8 +11,8 @@
"dependencies": {
"@actions/core": "^1.11.1",
"@octokit/action": "^7.0.0",
- "@rollup/rollup-linux-x64-gnu": "*",
- "octokit-plugin-create-pull-request": "^6.0.0"
+ "octokit-plugin-create-pull-request": "^6.0.0",
+ "zod": "^4.1.11"
},
"devDependencies": {
"@eslint/compat": "^1.2.6",
@@ -10659,6 +10659,15 @@
"engines": {
"node": ">= 14"
}
+ },
+ "node_modules/zod": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
+ "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index 17dfd582..ab1ef545 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,8 @@
"dependencies": {
"@actions/core": "^1.11.1",
"@octokit/action": "^7.0.0",
- "octokit-plugin-create-pull-request": "^6.0.0"
+ "octokit-plugin-create-pull-request": "^6.0.0",
+ "zod": "^4.1.11"
},
"devDependencies": {
"@eslint/compat": "^1.2.6",
diff --git a/src/helper.ts b/src/helper.ts
index b95a3521..a09d8b39 100644
--- a/src/helper.ts
+++ b/src/helper.ts
@@ -7,6 +7,7 @@ import { exec } from "child_process";
import { promisify } from "util";
import { CodeJSON, BasicRepoInfo } from "./model.js";
+import { validateCodeJSON } from "./validation.js";
const execAsync = promisify(exec);
@@ -66,7 +67,7 @@ export async function calculateMetaData(): Promise> {
date: {
created: basicInfo.date.created,
lastModified: basicInfo.date.lastModified,
- metaDataLastUpdated: basicInfo.date.metaDataLastUpdated,
+ metadataLastUpdated: basicInfo.date.metadataLastUpdated,
},
};
} catch (error) {
@@ -99,7 +100,7 @@ async function getBasicInfo(): Promise {
date: {
created: repoData.data.created_at,
lastModified: repoData.data.updated_at,
- metaDataLastUpdated: new Date().toISOString(),
+ metadataLastUpdated: new Date().toISOString(),
},
};
} catch (error) {
@@ -139,6 +140,37 @@ export async function getBaseBranch(): Promise {
}
}
+//===============================================
+// Validation
+//===============================================
+export async function validateOnly(): Promise {
+ try {
+ const codeJSON = await readJSON("/github/workspace/code.json");
+
+ if (!codeJSON) {
+ core.setFailed(
+ "code.json file not found, is empty, or contains invalid JSON syntax...",
+ );
+ return;
+ }
+
+ const validationErrors = validateCodeJSON(codeJSON);
+
+ if (validationErrors.length > 0) {
+ const errorMessage = `code.json validation failed with ${validationErrors.length} error(s):\n\n${validationErrors.map((err, idx) => `${idx + 1}. ${err}`).join("\n")}`;
+ core.setFailed(errorMessage);
+ return;
+ }
+
+ core.info("code.json is valid!");
+ core.setOutput("validated", true);
+ } catch (error) {
+ core.setFailed(`validation error: ${error}`);
+ }
+}
+
+export { validateCodeJSON };
+
//===============================================
// Data Handling
//===============================================
diff --git a/src/main.ts b/src/main.ts
index 34e5564d..41e48858 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -9,7 +9,7 @@ const baselineCodeJSON: CodeJSON = {
longDescription: "",
status: "",
permissions: {
- license: [
+ licenses: [
{
name: "",
URL: "",
@@ -45,7 +45,7 @@ const baselineCodeJSON: CodeJSON = {
date: {
created: "",
lastModified: "",
- metaDataLastUpdated: "",
+ metadataLastUpdated: "",
},
tags: [],
contact: {
@@ -110,8 +110,8 @@ async function getMetaData(
date: {
created: partialCodeJSON.date?.created ?? "",
lastModified: partialCodeJSON.date?.lastModified ?? "",
- metaDataLastUpdated:
- partialCodeJSON.date?.metaDataLastUpdated ?? new Date().toISOString(),
+ metadataLastUpdated:
+ partialCodeJSON.date?.metadataLastUpdated ?? new Date().toISOString(),
},
feedbackMechanism,
SBOM,
@@ -119,42 +119,67 @@ async function getMetaData(
}
export async function run(): Promise {
- const currentCodeJSON = await helpers.readJSON("/github/workspace/code.json");
- const metaData = await getMetaData(currentCodeJSON);
- let finalCodeJSON = {} as CodeJSON;
-
- if (currentCodeJSON) {
- finalCodeJSON = {
- ...baselineCodeJSON,
- ...currentCodeJSON,
- ...metaData,
- };
- } else {
- finalCodeJSON = {
- ...baselineCodeJSON,
- ...metaData,
- };
- }
+ try {
+ const eventName = process.env.GITHUB_EVENT_NAME;
- const baseBranchName = await helpers.getBaseBranch();
- const skipPR = core.getInput("SKIP_PR", { required: false }) === "true";
- const adminToken = core.getInput("ADMIN_TOKEN", { required: false });
+ if (eventName === "pull_request") {
+ core.info("Detected pull_request event - validating only!");
+ await helpers.validateOnly();
+ return;
+ }
- if (skipPR) {
- if (!adminToken) {
- core.warning("SKIP_PR is enabled but ADMIN_TOKEN is not provided.");
- core.warning(
- "Direct push requires a Personal Access Token with appropriate permissions.",
- );
+ const currentCodeJSON = await helpers.readJSON(
+ "/github/workspace/code.json",
+ );
+ const metaData = await getMetaData(currentCodeJSON);
+ let finalCodeJSON = {} as CodeJSON;
- core.info("Falling back to pull request creation");
- await helpers.sendPR(finalCodeJSON, baseBranchName);
+ if (currentCodeJSON) {
+ finalCodeJSON = {
+ ...baselineCodeJSON,
+ ...currentCodeJSON,
+ ...metaData,
+ };
} else {
- core.info("Attempting direct push");
- await helpers.pushDirectlyWithFallback(finalCodeJSON, baseBranchName);
+ finalCodeJSON = {
+ ...baselineCodeJSON,
+ ...metaData,
+ };
+ }
+
+ core.info("Validating generated code.json before output");
+ const validationErrors = helpers.validateCodeJSON(finalCodeJSON);
+
+ if (validationErrors.length > 0) {
+ const errorMessage = `Generated code.json is invalid:\n${validationErrors.map((err, idx) => `${idx + 1}. ${err}`).join("\n")}`;
+ core.setFailed(errorMessage);
+ return;
+ }
+
+ core.info("Generated code.json passed validation!");
+
+ const baseBranchName = await helpers.getBaseBranch();
+ const skipPR = core.getInput("SKIP_PR", { required: false }) === "true";
+ const adminToken = core.getInput("ADMIN_TOKEN", { required: false });
+
+ if (skipPR) {
+ if (!adminToken) {
+ core.warning("SKIP_PR is enabled but ADMIN_TOKEN is not provided.");
+ core.warning(
+ "Direct push requires a Personal Access Token with appropriate permissions.",
+ );
+
+ core.info("Falling back to pull request creation");
+ await helpers.sendPR(finalCodeJSON, baseBranchName);
+ } else {
+ core.info("Attempting direct push to branch");
+ await helpers.pushDirectlyWithFallback(finalCodeJSON, baseBranchName);
+ }
+ } else {
+ core.info("Creating pull request with updated code.json");
+ await helpers.sendPR(finalCodeJSON, baseBranchName);
}
- } else {
- core.info("Attempting pull request creation");
- await helpers.sendPR(finalCodeJSON, baseBranchName);
+ } catch (error) {
+ core.setFailed(`Action failed: ${error}`);
}
}
diff --git a/src/model.ts b/src/model.ts
index 5b3b8912..939d4dea 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -49,7 +49,7 @@ export interface ReuseFrequency {
}
export interface Permissions {
- license: License[];
+ licenses: License[];
usageType: string[];
exemptionText: string;
}
@@ -78,7 +78,7 @@ export interface Partner {
export interface Date {
created: string;
lastModified: string;
- metaDataLastUpdated: string;
+ metadataLastUpdated: string;
}
export interface Contact {
diff --git a/src/validation.ts b/src/validation.ts
new file mode 100644
index 00000000..5add7008
--- /dev/null
+++ b/src/validation.ts
@@ -0,0 +1,129 @@
+import { z } from "zod";
+
+const DateSchema = z.object({
+ created: z.string().min(1, "created date is required"),
+ lastModified: z.string().min(1, "lastModified date is required"),
+ metadataLastUpdated: z
+ .string()
+ .min(1, "metadataLastUpdated date is required"),
+});
+
+const ContactSchema = z.object({
+ email: z.email("must be a valid email").min(1, "email is required"),
+ name: z.string().min(1, "name is required"),
+});
+
+const LicenseSchema = z.object({
+ name: z.string().min(1, "license name is required"),
+ URL: z.url("license URL must be valid").min(1, "license URL is required"),
+});
+
+const PermissionsSchema = z
+ .object({
+ licenses: z.array(LicenseSchema).min(1, "at least one license is required"),
+ usageType: z.union([z.array(z.string()), z.string()]),
+ exemptionText: z.string(),
+ })
+ .refine(
+ (data) => {
+ const usageTypes = Array.isArray(data.usageType)
+ ? data.usageType
+ : [data.usageType];
+
+ const hasExemption = usageTypes.some(
+ (type) => typeof type === "string" && type.startsWith("exemptBy"),
+ );
+
+ if (hasExemption) {
+ return data.exemptionText && data.exemptionText.trim().length > 0;
+ }
+
+ return true;
+ },
+ {
+ message: "exemptionText is required when usageType contains an exemption",
+ path: ["exemptionText"],
+ },
+ );
+
+const ReuseFrequencySchema = z.object({
+ forks: z.number(),
+ clones: z.number().optional(),
+});
+
+const RelatedCodeSchema = z.object({
+ name: z.string(),
+ URL: z.url(),
+ isGovernmentRepo: z.boolean(),
+});
+
+const ReusedCodeSchema = z.object({
+ name: z.string(),
+ URL: z.url(),
+});
+
+const PartnerSchema = z.object({
+ name: z.string(),
+ email: z.email(),
+});
+
+export const CodeJSONSchema = z.object({
+ name: z.string().min(1, "name is required"),
+ version: z.string().optional(),
+ description: z.string().min(1, "description is required"),
+ longDescription: z.string(),
+ status: z.string().min(1, "status is required"),
+ permissions: PermissionsSchema,
+ organization: z.string().min(1, "organization is required"),
+ repositoryURL: z
+ .url("must be a valid URL")
+ .min(1, "repositoryURL is required"),
+ repositoryHost: z.string(),
+ repositoryVisibility: z.string().min(1, "repositoryVisibility is required"),
+ homepageURL: z.string().optional(),
+ downloadURL: z.string().optional(),
+ disclaimerURL: z.string().optional(),
+ disclaimerText: z.string().optional(),
+ vcs: z.string(),
+ laborHours: z.number(),
+ reuseFrequency: ReuseFrequencySchema,
+ platforms: z.array(z.string()),
+ categories: z.array(z.string()),
+ softwareType: z.string(),
+ languages: z.array(z.string()).min(1, "at least one language is required"),
+ maintenance: z.string(),
+ contractNumber: z.array(z.string()),
+ SBOM: z.string(),
+ relatedCode: z.array(RelatedCodeSchema).optional(),
+ reusedCode: z.array(ReusedCodeSchema).optional(),
+ partners: z.array(PartnerSchema).optional(),
+ date: DateSchema,
+ tags: z.array(z.string()),
+ contact: ContactSchema,
+ feedbackMechanism: z.string().min(1, "feedbackMechanism is required"),
+ AIUseCaseID: z.string(),
+ localisation: z.boolean(),
+ repositoryType: z.string(),
+ userInput: z.boolean(),
+ fismaLevel: z.string(),
+ group: z.string(),
+ projects: z.array(z.string()),
+ systems: z.array(z.string()),
+ subsetInHealthcare: z.array(z.string()),
+ userType: z.array(z.string()),
+ maturityModelTier: z.number(),
+});
+
+export function validateCodeJSON(codeJSON: any): string[] {
+ const result = CodeJSONSchema.safeParse(codeJSON);
+
+ if (result.success) {
+ return [];
+ }
+
+ return result.error.issues.map((err: z.ZodIssue) => {
+ const path = err.path.join(".");
+ const field = path || "root";
+ return `${field}: ${err.message}`;
+ });
+}