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_ ⚠️ -Screenshot 2025-08-05 at 1 44 36 PM - +Screenshot 2025-08-05 at 1 44 36 PM ## 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}`; + }); +}