From d1f298a0c6336ee54dc90d4c3855ba416e1fc86c Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Sun, 31 Dec 2023 00:12:17 -0500 Subject: [PATCH] hackety hack --- .gitignore | 2 + Cargo.toml | 12 ++ README.md | 12 ++ src/action.rs | 137 ++++++++++++++ src/dependabot/mod.rs | 0 src/lib.rs | 3 + src/workflow.rs | 177 ++++++++++++++++++ tests/sample-actions/gh-action-pip-audit.yml | 88 +++++++++ .../sample-actions/gh-action-pypi-publish.yml | 98 ++++++++++ tests/sample-actions/setup-python.yml | 44 +++++ tests/sample-workflows/pip-audit-ci.yml | 33 ++++ tests/test_action.rs | 45 +++++ tests/test_workflow.rs | 22 +++ 13 files changed, 673 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/action.rs create mode 100644 src/dependabot/mod.rs create mode 100644 src/lib.rs create mode 100644 src/workflow.rs create mode 100644 tests/sample-actions/gh-action-pip-audit.yml create mode 100644 tests/sample-actions/gh-action-pypi-publish.yml create mode 100644 tests/sample-actions/setup-python.yml create mode 100644 tests/sample-workflows/pip-audit-ci.yml create mode 100644 tests/test_action.rs create mode 100644 tests/test_workflow.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3275779 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "glomar-models" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1.0.193", features = ["derive"] } + +[dev-dependencies] +serde_yaml = "0.9.29" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0bff18 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +glomar-models +============= + +"Low level" data models for GitHub Actions workflows, actions, and related components. + +## Why? + +I need these for another tool, and generating them automatically from +[their JSON Schemas] wasn't working both for expressiveness and tool deficiency +reasons. + +[their JSON Schemas]: https://www.schemastore.org/json/ diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..a785275 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,137 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A GitHub Actions action definition. +/// +/// See: +/// and +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Action { + pub name: String, + pub author: Option, + pub description: Option, + #[serde(default)] + pub inputs: HashMap, + #[serde(default)] + pub outputs: HashMap, + pub runs: Runs, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Input { + pub description: String, + pub required: Option, + pub default: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Output { + pub description: String, + // NOTE: not optional for composite actions, but this is not worth modeling. + pub value: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum Runs { + JavaScript(JavaScript), + Composite(Composite), + Docker(Docker), +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct JavaScript { + // "node12" | "node16" | "node20" + pub using: String, + pub main: String, + pub pre: Option, + // Defaults to `always()` + pub pre_if: Option, + pub post: Option, + // Defaults to `always()` + pub post_if: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Composite { + // "composite" + pub using: String, + pub steps: Vec, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum Step { + /// A step that runs a command in a shell. + RunShell(RunShell), + /// A step that uses another GitHub Action. + UseAction(UseAction), +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct RunShell { + pub run: String, + pub shell: String, + pub name: Option, + pub id: Option, + pub r#if: Option, + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub continue_on_error: bool, + pub working_directory: Option, +} + +/// Environment variable values are always strings, but GitHub Actions +/// allows users to configure them as various native YAML types before +/// internal stringification. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum EnvValue { + String(String), + Number(f64), + Boolean(bool), +} + +impl ToString for EnvValue { + fn to_string(&self) -> String { + match self { + Self::String(s) => s.clone(), + Self::Number(n) => n.to_string(), + Self::Boolean(b) => b.to_string(), + } + } +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct UseAction { + pub uses: String, + #[serde(default)] + pub with: HashMap, + pub r#if: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Docker { + // "docker" + pub using: String, + pub image: String, + #[serde(default)] + pub env: HashMap, + pub entrypoint: Option, + pub pre_entrypoint: Option, + // Defaults to `always()` + pub pre_if: Option, + pub post_entrypoint: Option, + // Defaults to `always()` + pub post_if: Option, +} diff --git a/src/dependabot/mod.rs b/src/dependabot/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8394cdc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod action; +pub mod dependabot; +pub mod workflow; diff --git a/src/workflow.rs b/src/workflow.rs new file mode 100644 index 0000000..495697b --- /dev/null +++ b/src/workflow.rs @@ -0,0 +1,177 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A single GitHub Actions workflow. +/// +/// See: +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Workflow { + pub name: Option, + pub run_name: Option, + pub on: Trigger, + #[serde(default)] + pub permissions: Permissions, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum Trigger { + // A single "bare" event, like `on: push`. + BareEvent(BareEvent), + // Multiple "bare" events, like `on: [push, pull_request]` + BareEvents(Vec), + // `schedule:` events. + Schedule { schedule: Vec }, + WorkflowCall { workflow_call: Option }, + // "Rich" events, i.e. each event with its optional filters. + Events(HashMap>), +} + +#[derive(Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum BareEvent { + BranchProtectionRule, + CheckRun, + CheckSuite, + Create, + Delete, + Deployment, + DeploymentStatus, + Discussion, + DiscussionComment, + Fork, + Gollum, + IssueComment, + Issues, + Label, + MergeGroup, + Milestone, + PageBuild, + Project, + ProjectCard, + ProjectColumn, + Public, + PullRequest, + PullRequestComment, + PullRequestReview, + PullRequestReviewComment, + PullRequestTarget, + Push, + RegistryPackage, + Release, + RepositoryDispatch, + // NOTE: `schedule` is omitted, since it's never bare. + Status, + Watch, + WorkflowCall, + WorkflowDispatch, + WorkflowRun, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Cron { + cron: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct WorkflowCall { + inputs: HashMap, + outputs: HashMap, + secrets: HashMap, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct WorkflowCallInput { + description: Option, + // TODO: model `default`? + #[serde(default)] + required: bool, + r#type: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct WorkflowCallOutput { + description: Option, + value: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct WorkflowCallSecret { + description: Option, + required: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct RichEvent { + #[serde(default)] + types: Vec, + + // `push | pull_request | pull_request_target` only. + #[serde(default)] + branches: Vec, + + // `push | pull_request | pull_request_target` only. + #[serde(default)] + branches_ignore: Vec, + + // `push` only. + #[serde(default)] + tags: Vec, + + // `push` only. + #[serde(default)] + tags_ignore: Vec, + + // `push | pull_request | pull_request_target` only. + #[serde(default)] + paths: Vec, + + // `push | pull_request | pull_request_target` only. + #[serde(default)] + paths_ignore: Vec, +} + +#[derive(Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum Permissions { + /// Whatever default permissions come from the workflow's `GITHUB_TOKEN`. + #[default] + Token, + ReadAll, + WriteAll, + Explicit(ExplicitPermissions), +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct ExplicitPermissions { + pub actions: Permission, + pub checks: Permission, + pub contents: Permission, + pub deployments: Permission, + pub id_token: Permission, + pub issues: Permission, + pub discussions: Permission, + pub packages: Permission, + pub pages: Permission, + pub pull_requests: Permission, + pub repository_projects: Permission, + pub security_events: Permission, + pub statuses: Permission, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum Permission { + Read, + Write, + None, +} diff --git a/tests/sample-actions/gh-action-pip-audit.yml b/tests/sample-actions/gh-action-pip-audit.yml new file mode 100644 index 0000000..ef7444b --- /dev/null +++ b/tests/sample-actions/gh-action-pip-audit.yml @@ -0,0 +1,88 @@ +# https://github.com/pypa/gh-action-pip-audit/blob/530374b67a3e8b3972d2caae7ee9a1d3dd486329/action.yml +name: "gh-action-pip-audit" +author: "William Woodruff " +description: "Use pip-audit to scan Python dependencies for known vulnerabilities" +inputs: + summary: + description: "render a Markdown summary of the audit (default true)" + required: false + default: true + no-deps: + description: "don't do any dependency resolution (requires fully pinned requirements) (default false)" + required: false + default: false + require-hashes: + description: "enforce hashes (requirements-style inputs only) (default false)" + required: false + default: false + vulnerability-service: + description: "the vulnerability service to use (PyPI or OSV, defaults to PyPI)" + required: false + default: "PyPI" + inputs: + description: "the inputs to audit, whitespace separated (defaults to current path)" + required: false + default: "" + virtual-environment: + description: "the virtual environment to audit within (default none)" + required: false + default: "" + local: + description: "for environmental audits, consider only packages marked local (default false)" + required: false + default: false + index-url: + description: "the base URL for the PEP 503-compatible package index to use" + required: false + default: "" + extra-index-urls: + description: "extra PEP 503-compatible indexes to use, whitespace separated" + required: false + default: "" + ignore-vulns: + description: "vulnerabilities to explicitly exclude, if present (whitespace separated)" + required: false + default: "" + internal-be-careful-allow-failure: + description: "don't fail the job if the audit fails (default false)" + required: false + default: false + internal-be-careful-extra-flags: + description: "extra flags to be passed in to pip-audit" + required: false + default: "" +outputs: + internal-be-careful-output: + description: "the column-formatted output from pip-audit, wrapped as base64" + value: "${{ steps.pip-audit.outputs.output }}" +runs: + using: "composite" + steps: + - name: Set up pip-audit + run: | + # NOTE: Sourced, not executed as a script. + source "${{ github.action_path }}/setup/setup.bash" + env: + GHA_PIP_AUDIT_VIRTUAL_ENVIRONMENT: "${{ inputs.virtual-environment }}" + shell: bash + + - name: Run pip-audit + id: pip-audit + run: | + # NOTE: Sourced, not executed as a script. + source "${{ github.action_path }}/setup/venv.bash" + + python "${{ github.action_path }}/action.py" "${{ inputs.inputs }}" + env: + GHA_PIP_AUDIT_SUMMARY: "${{ inputs.summary }}" + GHA_PIP_AUDIT_NO_DEPS: "${{ inputs.no-deps }}" + GHA_PIP_AUDIT_REQUIRE_HASHES: "${{ inputs.require-hashes }}" + GHA_PIP_AUDIT_VULNERABILITY_SERVICE: "${{ inputs.vulnerability-service }}" + GHA_PIP_AUDIT_VIRTUAL_ENVIRONMENT: "${{ inputs.virtual-environment }}" + GHA_PIP_AUDIT_LOCAL: "${{ inputs.local }}" + GHA_PIP_AUDIT_INDEX_URL: "${{ inputs.index-url }}" + GHA_PIP_AUDIT_EXTRA_INDEX_URLS: "${{ inputs.extra-index-urls }}" + GHA_PIP_AUDIT_IGNORE_VULNS: "${{ inputs.ignore-vulns }}" + GHA_PIP_AUDIT_INTERNAL_BE_CAREFUL_ALLOW_FAILURE: "${{ inputs.internal-be-careful-allow-failure }}" + GHA_PIP_AUDIT_INTERNAL_BE_CAREFUL_EXTRA_FLAGS: "${{ inputs.internal-be-careful-extra-flags }}" + shell: bash diff --git a/tests/sample-actions/gh-action-pypi-publish.yml b/tests/sample-actions/gh-action-pypi-publish.yml new file mode 100644 index 0000000..89c2ad2 --- /dev/null +++ b/tests/sample-actions/gh-action-pypi-publish.yml @@ -0,0 +1,98 @@ +# https://github.com/pypa/gh-action-pypi-publish/blob/2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf/action.yml +--- +name: pypi-publish +description: Upload Python distribution packages to PyPI +inputs: + user: + description: PyPI user + required: false + default: __token__ + password: + description: Password for your PyPI user or an access token + required: false + repository-url: # Canonical alias for `repository_url` + description: The repository URL to use + required: false + repository_url: # DEPRECATED ALIAS; TODO: Remove in v3+ + description: >- + [DEPRECATED] + The repository URL to use + deprecationMessage: >- + The inputs have been normalized to use kebab-case. + Use `repository-url` instead. + required: false + default: https://upload.pypi.org/legacy/ + packages-dir: # Canonical alias for `packages_dir` + description: The target directory for distribution + required: false + # default: dist # TODO: uncomment once alias removed + packages_dir: # DEPRECATED ALIAS; TODO: Remove in v3+ + description: >- + [DEPRECATED] + The target directory for distribution + deprecationMessage: >- + The inputs have been normalized to use kebab-case. + Use `packages-dir` instead. + required: false + default: dist + verify-metadata: # Canonical alias for `verify_metadata` + description: Check metadata before uploading + required: false + # default: 'true' # TODO: uncomment once alias removed + verify_metadata: # DEPRECATED ALIAS; TODO: Remove in v3+ + description: >- + [DEPRECATED] + Check metadata before uploading + deprecationMessage: >- + The inputs have been normalized to use kebab-case. + Use `verify-metadata` instead. + required: false + default: "true" + skip-existing: # Canonical alias for `skip_existing` + description: >- + Do not fail if a Python package distribution + exists in the target package index + required: false + # default: 'false' # TODO: uncomment once alias removed + skip_existing: # DEPRECATED ALIAS; TODO: Remove in v3+ + description: >- + [DEPRECATED] + Do not fail if a Python package distribution + exists in the target package index + deprecationMessage: >- + The inputs have been normalized to use kebab-case. + Use `skip-existing` instead. + required: false + default: "false" + verbose: + description: Show verbose output. + required: false + default: "false" + print-hash: # Canonical alias for `print_hash` + description: Show hash values of files to be uploaded + required: false + # default: 'false' # TODO: uncomment once alias removed + print_hash: # DEPRECATED ALIAS; TODO: Remove in v3+ + description: >- + [DEPRECATED] + Show hash values of files to be uploaded + deprecationMessage: >- + The inputs have been normalized to use kebab-case. + Use `print-hash` instead. + required: false + default: "false" +branding: + color: yellow + icon: upload-cloud +runs: + using: docker + image: Dockerfile + args: + - ${{ inputs.user }} + - ${{ inputs.password }} + - ${{ inputs.repository-url }} + - ${{ inputs.packages-dir }} + - ${{ inputs.verify-metadata }} + - ${{ inputs.skip-existing }} + - ${{ inputs.verbose }} + - ${{ inputs.print-hash }} diff --git a/tests/sample-actions/setup-python.yml b/tests/sample-actions/setup-python.yml new file mode 100644 index 0000000..193aea2 --- /dev/null +++ b/tests/sample-actions/setup-python.yml @@ -0,0 +1,44 @@ +# https://github.com/actions/setup-python/blob/e9d6f990972a57673cdb72ec29e19d42ba28880f/action.yml +--- +name: "Setup Python" +description: "Set up a specific version of Python and add the command-line tools to the PATH." +author: "GitHub" +inputs: + python-version: + description: "Version range or exact version of Python or PyPy to use, using SemVer's version range syntax. Reads from .python-version if unset." + python-version-file: + description: "File containing the Python version to use. Example: .python-version" + cache: + description: "Used to specify a package manager for caching in the default directory. Supported values: pip, pipenv, poetry." + required: false + architecture: + description: "The target architecture (x86, x64) of the Python or PyPy interpreter." + check-latest: + description: "Set this option if you want the action to check for the latest available version that satisfies the version spec." + default: false + token: + description: "The token used to authenticate when fetching Python distributions from https://github.com/actions/python-versions. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting." + default: ${{ github.server_url == 'https://github.com' && github.token || '' }} + cache-dependency-path: + description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies." + update-environment: + description: "Set this option if you want the action to update environment variables." + default: true + allow-prereleases: + description: "When 'true', a version range passed to 'python-version' input will match prerelease versions if no GA versions are found. Only 'x.y' version range is supported for CPython." + default: false +outputs: + python-version: + description: "The installed Python or PyPy version. Useful when given a version range as input." + cache-hit: + description: "A boolean value to indicate a cache entry was found" + python-path: + description: "The absolute path to the Python or PyPy executable." +runs: + using: "node20" + main: "dist/setup/index.js" + post: "dist/cache-save/index.js" + post-if: success() +branding: + icon: "code" + color: "yellow" diff --git a/tests/sample-workflows/pip-audit-ci.yml b/tests/sample-workflows/pip-audit-ci.yml new file mode 100644 index 0000000..77f1692 --- /dev/null +++ b/tests/sample-workflows/pip-audit-ci.yml @@ -0,0 +1,33 @@ +# https://github.com/pypa/pip-audit/blob/1fd67af0653a8e66b9470adab2e408a435632f19/.github/workflows/ci.yml +name: CI + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: "0 12 * * *" + +jobs: + test: + strategy: + matrix: + python: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.1.1 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: "pip" + cache-dependency-path: pyproject.toml + + - name: test + run: make test PIP_AUDIT_EXTRA=test diff --git a/tests/test_action.rs b/tests/test_action.rs new file mode 100644 index 0000000..5319fd8 --- /dev/null +++ b/tests/test_action.rs @@ -0,0 +1,45 @@ +use std::{env, path::Path}; + +use glomar_models::action::{Action, Runs}; + +fn load_action(name: &str) -> Action { + let action_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/sample-actions") + .join(name); + let action_contents = std::fs::read_to_string(action_path).unwrap(); + serde_yaml::from_str(&action_contents).unwrap() +} + +#[test] +fn test_load_all() { + let sample_actions = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/sample-actions"); + + for sample_action in std::fs::read_dir(&sample_actions).unwrap() { + let sample_action = sample_action.unwrap().path(); + let action_contents = std::fs::read_to_string(sample_action).unwrap(); + serde_yaml::from_str::(&action_contents).unwrap(); + } +} + +#[test] +fn test_setup_python() { + let setup_python = load_action("setup-python.yml"); + + assert_eq!(setup_python.name, "Setup Python"); + assert_eq!( + setup_python.description.unwrap(), + "Set up a specific version of Python and add the command-line tools to the PATH." + ); + assert_eq!(setup_python.author.unwrap(), "GitHub"); + + assert_eq!(setup_python.inputs.len(), 9); + assert_eq!(setup_python.outputs.len(), 3); + + let Runs::JavaScript(runs) = setup_python.runs else { + unreachable!(); + }; + assert_eq!(runs.using, "node20"); + assert_eq!(runs.main, "dist/setup/index.js"); + assert_eq!(runs.post.unwrap(), "dist/cache-save/index.js"); + assert_eq!(runs.post_if.unwrap(), "success()"); +} diff --git a/tests/test_workflow.rs b/tests/test_workflow.rs new file mode 100644 index 0000000..455a69f --- /dev/null +++ b/tests/test_workflow.rs @@ -0,0 +1,22 @@ +use std::{env, path::Path}; + +use glomar_models::workflow::Workflow; + +fn load_workflow(name: &str) -> Workflow { + let workflow_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/sample-workflows") + .join(name); + let workflow_contents = std::fs::read_to_string(workflow_path).unwrap(); + serde_yaml::from_str(&workflow_contents).unwrap() +} + +#[test] +fn test_load_all() { + let sample_workflows = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/sample-workflows"); + + for sample_action in std::fs::read_dir(&sample_workflows).unwrap() { + let sample_workflow = sample_action.unwrap().path(); + let workflow_contents = std::fs::read_to_string(sample_workflow).unwrap(); + serde_yaml::from_str::(&workflow_contents).unwrap(); + } +}