hackety hack

This commit is contained in:
William Woodruff 2023-12-31 00:12:17 -05:00
commit d1f298a0c6
No known key found for this signature in database
13 changed files with 673 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/Cargo.lock

12
Cargo.toml Normal file
View file

@ -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"

12
README.md Normal file
View file

@ -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/

137
src/action.rs Normal file
View file

@ -0,0 +1,137 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// A GitHub Actions action definition.
///
/// See: <https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions>
/// and <https://json.schemastore.org/github-action.json>
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Action {
pub name: String,
pub author: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub inputs: HashMap<String, Input>,
#[serde(default)]
pub outputs: HashMap<String, Output>,
pub runs: Runs,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Input {
pub description: String,
pub required: Option<bool>,
pub default: Option<String>,
}
#[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<String>,
}
#[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<String>,
// Defaults to `always()`
pub pre_if: Option<String>,
pub post: Option<String>,
// Defaults to `always()`
pub post_if: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Composite {
// "composite"
pub using: String,
pub steps: Vec<Step>,
}
#[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<String>,
pub id: Option<String>,
pub r#if: Option<String>,
#[serde(default)]
pub env: HashMap<String, EnvValue>,
#[serde(default)]
pub continue_on_error: bool,
pub working_directory: Option<String>,
}
/// 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<String, String>,
pub r#if: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Docker {
// "docker"
pub using: String,
pub image: String,
#[serde(default)]
pub env: HashMap<String, EnvValue>,
pub entrypoint: Option<String>,
pub pre_entrypoint: Option<String>,
// Defaults to `always()`
pub pre_if: Option<String>,
pub post_entrypoint: Option<String>,
// Defaults to `always()`
pub post_if: Option<String>,
}

0
src/dependabot/mod.rs Normal file
View file

3
src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod action;
pub mod dependabot;
pub mod workflow;

177
src/workflow.rs Normal file
View file

@ -0,0 +1,177 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// A single GitHub Actions workflow.
///
/// See: <https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions>
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Workflow {
pub name: Option<String>,
pub run_name: Option<String>,
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<BareEvent>),
// `schedule:` events.
Schedule { schedule: Vec<Cron> },
WorkflowCall { workflow_call: Option<WorkflowCall> },
// "Rich" events, i.e. each event with its optional filters.
Events(HashMap<BareEvent, Option<RichEvent>>),
}
#[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<String, WorkflowCallInput>,
outputs: HashMap<String, WorkflowCallOutput>,
secrets: HashMap<String, WorkflowCallSecret>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct WorkflowCallInput {
description: Option<String>,
// TODO: model `default`?
#[serde(default)]
required: bool,
r#type: String,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct WorkflowCallOutput {
description: Option<String>,
value: String,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct WorkflowCallSecret {
description: Option<String>,
required: bool,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct RichEvent {
#[serde(default)]
types: Vec<String>,
// `push | pull_request | pull_request_target` only.
#[serde(default)]
branches: Vec<String>,
// `push | pull_request | pull_request_target` only.
#[serde(default)]
branches_ignore: Vec<String>,
// `push` only.
#[serde(default)]
tags: Vec<String>,
// `push` only.
#[serde(default)]
tags_ignore: Vec<String>,
// `push | pull_request | pull_request_target` only.
#[serde(default)]
paths: Vec<String>,
// `push | pull_request | pull_request_target` only.
#[serde(default)]
paths_ignore: Vec<String>,
}
#[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,
}

View file

@ -0,0 +1,88 @@
# https://github.com/pypa/gh-action-pip-audit/blob/530374b67a3e8b3972d2caae7ee9a1d3dd486329/action.yml
name: "gh-action-pip-audit"
author: "William Woodruff <william@trailofbits.com>"
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

View file

@ -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 }}

View file

@ -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"

View file

@ -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

45
tests/test_action.rs Normal file
View file

@ -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>(&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()");
}

22
tests/test_workflow.rs Normal file
View file

@ -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>(&workflow_contents).unwrap();
}
}