Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/actions/actionlint/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: actionlint
description: Lint GitHub Actions workflow files with actionlint.

inputs:
paths:
description: >-
Workflow files/dirs to lint, space-separated. Empty (default) lets
actionlint auto-discover .github/workflows/*.{yml,yaml}.
default: ''
config-file:
description: >-
Path to an actionlint.yaml config in the caller repo. Empty (default)
auto-discovers .github/actionlint.yaml if one exists.
default: ''
color:
description: Force colored output (true/false).
default: 'true'
version:
description: Exact actionlint version to install.
default: 1.7.12
sha256:
description: >-
SHA-256 of the linux_amd64 .tar.gz release asset for `version`. Change
together with `version`.
default: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8

runs:
using: composite
steps:
- name: Install actionlint
shell: bash
env:
VERSION: ${{ inputs.version }}
SHA256: ${{ inputs.sha256 }}
run: |
set -euo pipefail
url="https://github.com/rhysd/actionlint/releases/download/v${VERSION}/actionlint_${VERSION}_linux_amd64.tar.gz"
curl -fsSL "$url" -o actionlint.tar.gz
echo "${SHA256} actionlint.tar.gz" | sha256sum -c -
tar -xzf actionlint.tar.gz actionlint
sudo install actionlint /usr/local/bin/actionlint
rm -f actionlint actionlint.tar.gz
actionlint --version

- name: Lint workflows
shell: bash
env:
PATHS: ${{ inputs.paths }}
CONFIG_FILE: ${{ inputs.config-file }}
COLOR: ${{ inputs.color }}
run: |
set -euo pipefail
# shellcheck/pyflakes are auto-detected on PATH (ubuntu-latest ships
# shellcheck), so embedded run: scripts are linted too.
args=()
if [[ "$COLOR" == "true" ]]; then args+=(-color); fi
if [[ -n "${CONFIG_FILE// }" ]]; then args+=(-config-file "$CONFIG_FILE"); fi
# $PATHS unquoted so multiple targets word-split; empty appends nothing
# and actionlint auto-discovers .github/workflows.
actionlint "${args[@]}" $PATHS
69 changes: 69 additions & 0 deletions .github/actions/check-jsonschema/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: check-jsonschema
description: >-
Validate JSON/YAML files against one JSON Schema with check-jsonschema. One
invocation = one schema source + one-or-more files; call once per schema group.

inputs:
files:
description: Space-separated files to validate (relative to the caller repo).
required: true
builtin-schema:
description: >-
Name of a built-in vendored schema (e.g. vendor.dependabot,
vendor.github-workflows, vendor.github-issue-forms). Mutually exclusive
with schemafile; set exactly one.
default: ''
schemafile:
description: >-
Path or URL of a caller-supplied JSON Schema. Mutually exclusive with
builtin-schema; set exactly one.
default: ''
no-cache:
description: Disable schema/$ref caching (true/false).
default: 'false'
output-format:
description: 'Output format: TEXT or JSON. Empty uses the tool default (TEXT).'
default: ''
version:
description: Exact check-jsonschema version to run.
default: 0.37.3
python-version:
description: Python version uv provisions for the ephemeral tool environment.
default: '3.14'

runs:
using: composite
steps:
- name: Set up uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: ${{ inputs.python-version }}

- name: Validate
shell: bash
env:
FILES: ${{ inputs.files }}
BUILTIN: ${{ inputs.builtin-schema }}
SCHEMAFILE: ${{ inputs.schemafile }}
NO_CACHE: ${{ inputs.no-cache }}
OUTPUT_FORMAT: ${{ inputs.output-format }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ -z "${FILES// }" ]]; then
echo '::error::files is required.'; exit 1
fi
# Exactly one schema source (the tool treats them as mutually exclusive).
if [[ -n "${BUILTIN// }" && -n "${SCHEMAFILE// }" ]]; then
echo '::error::Set only one of builtin-schema or schemafile, not both.'; exit 1
fi
if [[ -z "${BUILTIN// }" && -z "${SCHEMAFILE// }" ]]; then
echo '::error::Set one of builtin-schema or schemafile.'; exit 1
fi
args=()
if [[ -n "${BUILTIN// }" ]]; then args+=(--builtin-schema "$BUILTIN"); fi
if [[ -n "${SCHEMAFILE// }" ]]; then args+=(--schemafile "$SCHEMAFILE"); fi
if [[ "$NO_CACHE" == "true" ]]; then args+=(--no-cache); fi
if [[ -n "${OUTPUT_FORMAT// }" ]]; then args+=(--output-format "$OUTPUT_FORMAT"); fi
# $FILES unquoted so multiple targets word-split.
uvx "check-jsonschema@${VERSION}" "${args[@]}" $FILES
37 changes: 34 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,17 +139,48 @@ jobs:
- name: Scan for secrets
uses: ./.github/actions/gitleaks

actionlint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Lint workflows
uses: ./.github/actions/actionlint

jsonschema:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Validate dependabot.yml
uses: ./.github/actions/check-jsonschema
with:
builtin-schema: vendor.dependabot
files: .github/dependabot.yml
- name: Validate workflows
uses: ./.github/actions/check-jsonschema
with:
builtin-schema: vendor.github-workflows
files: .github/workflows/ci.yml .github/workflows/link-check.yml

ci-status:
if: always()
needs: [markdown, powershell, links, ruff, pyright, biome, tsc, typos, editorconfig, gitleaks]
needs: [markdown, powershell, links, ruff, pyright, biome, tsc, typos, editorconfig, gitleaks, actionlint, jsonschema]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Aggregate lane results
env:
RESULTS: ${{ needs.markdown.result }} ${{ needs.powershell.result }} ${{ needs.links.result }} ${{ needs.ruff.result }} ${{ needs.pyright.result }} ${{ needs.biome.result }} ${{ needs.tsc.result }} ${{ needs.typos.result }} ${{ needs.editorconfig.result }} ${{ needs.gitleaks.result }}
RESULTS: ${{ needs.markdown.result }} ${{ needs.powershell.result }} ${{ needs.links.result }} ${{ needs.ruff.result }} ${{ needs.pyright.result }} ${{ needs.biome.result }} ${{ needs.tsc.result }} ${{ needs.typos.result }} ${{ needs.editorconfig.result }} ${{ needs.gitleaks.result }} ${{ needs.actionlint.result }} ${{ needs.jsonschema.result }}
run: |
for r in $RESULTS; do
read -ra results <<< "$RESULTS"
for r in "${results[@]}"; do
case "$r" in
success|skipped) ;;
*) echo "A lane did not pass (result: $r)."; exit 1 ;;
Expand Down
4 changes: 2 additions & 2 deletions docs/dedup-program/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ missing, so there is no config work and both standards and medley benefit.

## Phase 2 — Actions and security linting

- [ ] `actionlint` composite action
- [ ] `check-jsonschema` (YAML schema) composite action
- [x] `actionlint` composite action
- [x] `check-jsonschema` (YAML schema) composite action
- [ ] `zizmor` reusable workflow (Actions security lint)
- [ ] `osv-scanner` reusable workflow (dependency vuln scan)

Expand Down
67 changes: 67 additions & 0 deletions docs/dedup-program/research/actionlint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Research — actionlint

GitHub Actions workflow linter. Upstream: <https://github.com/rhysd/actionlint>.

## Version

- Latest stable: **v1.7.12**, published 2026-03-30 (GitHub releases API,
`repos/rhysd/actionlint/releases/latest`, verified 2026-06-23).
- `medley` already pins `1.7.12` in its inline lane, so the action default
matches and adoption is behavior-preserving.

## Install asset + checksum

- Linux x64 asset: `actionlint_1.7.12_linux_amd64.tar.gz`; the binary is
`actionlint` at the archive root (no subdir).
- SHA-256: `8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8`.
- Corroboration: matches the upstream-published `actionlint_1.7.12_checksums.txt`
release asset and a locally computed hash of the download (both agree). The
`taiki-e/install-action` manifest does not cover actionlint (it is not a
cargo/Rust tool), so that cross-check is N/A.

## Invocation

`medley` runs (lift-and-shift target):

```sh
actionlint -color
```

- With **no path arguments, actionlint auto-discovers** all workflow files under
`.github/workflows/*.{yml,yaml}` of the current repo. Explicit paths are an
optional override.
- `-color` forces colored output (the documented CI idiom).
- Exit code is non-zero when problems are found (CI-failing).

### shellcheck / pyflakes integration

actionlint shells out to `shellcheck` (for `run:` bash/sh blocks) and `pyflakes`
(for `shell: python` blocks) **when those binaries are on `PATH`**, and silently
skips the integration otherwise. GitHub-hosted `ubuntu-latest` ships shellcheck,
so the workflow-embedded `run:` scripts in a caller repo are shellchecked by
default — a desirable extra lint, but it means a caller's workflow scripts must
be shellcheck-clean. Disable explicitly with empty overrides (`-shellcheck=`,
`-pyflakes=`) if ever needed.

## Config (backfill)

actionlint auto-loads an optional `.github/actionlint.yaml` (or `.yml`) for
self-hosted-runner labels, declared `config-variables`, and path-specific config;
`-config-file <PATH>` points elsewhere. **Config-light:** `medley` ships no such
file and the lifted lane needs none, so no `standards` module is required for
this tool. The action exposes an optional `config-file` input (default empty =
auto-discover) for callers that later add one.

## Relevant flags (exposed as optional inputs / behind defaults)

`-color`, `-config-file <PATH>`, `-shellcheck <PATH>` / `-pyflakes <PATH>`
(empty disables), `-ignore <REGEX>` (repeatable message filter),
`-format <GO-TEMPLATE>`.

## Why curl + sha256 (not download-actionlint.bash or a third-party action)

`medley` installs via the upstream `download-actionlint.bash` bootstrap. This
repo's established idiom for single-binary GitHub-release tools is curl +
checksum-pin (see `shellcheck`, `editorconfig`, `lychee-offline`): self-contained,
no nested action, the checksum an explicit input. actionlint is the same shape, so
the action follows that idiom.
81 changes: 81 additions & 0 deletions docs/dedup-program/research/check-jsonschema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Research — check-jsonschema

JSON/YAML instance validator against JSON Schemas. PyPI package
`check-jsonschema`; upstream: <https://github.com/python-jsonschema/check-jsonschema>.

## Version

- Latest stable: **0.37.3**, published 2026-06-12 (agrees across the GitHub
releases API `repos/python-jsonschema/check-jsonschema/releases/latest` and the
PyPI JSON API `pypi.org/pypi/check-jsonschema/json`, verified 2026-06-23).
- `requires_python >=3.10`.

## Install + invocation

This repo runs Python tools through `uvx` (see the `ruff` and `pyright` actions),
not a checksum-pinned binary — check-jsonschema is a pure-Python wheel with no
single-binary release.

- The wheel's console-script entry point is `check-jsonschema = check_jsonschema:main`,
so `uvx check-jsonschema@0.37.3 ...` runs it with no `--from`.
- Pin the exact version: the **built-in/vendored schemas are bundled per release**
and drift across versions, so reproducibility requires an exact pin.

`medley` runs (lift-and-shift target) — four separate invocations in one job, one
schema per call:

```sh
check-jsonschema --builtin-schema vendor.dependabot .github/dependabot.yml
check-jsonschema --schemafile tools/schemas/lefthook.schema.json lefthook.yml
check-jsonschema --builtin-schema vendor.github-issue-forms <form files…>
check-jsonschema --builtin-schema vendor.github-issue-config .github/ISSUE_TEMPLATE/config.yml
```

## Schema selection model — one schema + N files per call

A single invocation takes **exactly one** schema source and one-or-more target
files: `check-jsonschema <schema-source> file1 [file2 …]`. The three schema
sources are mutually exclusive per call:

- `--builtin-schema <name>` — a vendored schema bundled with check-jsonschema.
- `--schemafile <path-or-url>` — a caller-supplied schema file.
- `--check-metaschema` — validate the files as schemas themselves.

Built-in schema names in 0.37.3 (verified against the wheel's
`builtin_schemas/vendor/` directory, not just docs) — 26 `vendor.*` schemas:
`azure-pipelines`, `bamboo-spec`, `bitbucket-pipelines`, `buildkite`, `changie`,
`circle-ci`, `citation-file-format`, `cloudbuild`, `codecov`, `compose-spec`,
`dependabot`, `drone-ci`, `github-actions`, `github-discussion`,
`github-issue-config`, `github-issue-forms`, `github-workflows`, `gitlab-ci`,
`meltano`, `mergify`, `readthedocs`, `renovate`, `snapcraft`, `taskfile`,
`travis`, `woodpecker-ci` (each prefixed `vendor.`), plus one custom schema
`github-workflows-require-timeout` (no `vendor.` prefix).

## No config file — design implication

**Definitively config-light.** check-jsonschema reads **no** project config of its
own — no dotfile, no `pyproject.toml [tool.check-jsonschema]` table, no central
schema↔files mapping (verified against the 0.37.3 wheel source: no config-reading
code in `check_jsonschema/cli/`). The only places a schema↔files mapping lives as
"config" are a pre-commit hook list or the caller's own workflow. The schema for a
custom `--schemafile` (e.g. `medley`'s vendored `lefthook.schema.json`) is a
caller-owned file, not a `standards` module.

Consequence for the action: it models **one invocation** (one schema source +
target files via inputs); a caller that validates several schema groups (as
`medley` does) calls the action once per group. No `standards` module is created
for this tool.

## Other relevant flags (optional inputs / future backfill)

`--no-cache` (disable `$ref`/schema caching — sensible in CI), `-o/--output-format`
(`TEXT` default | `JSON`), `--traceback-mode` (`short` | `full`),
`--default-filetype` (`json` | `yaml` | `toml` | `json5`), `--force-filetype`,
`--color` (`always` | `never` | `auto`), `--data-transform`, `--fill-defaults`.

## Why uvx (not pip-install or a third-party action)

`medley` pip-installs a version pinned in `requirements.txt`. This repo's idiom for
ephemeral Python tooling is `uvx <tool>@<version>` provisioned by
`astral-sh/setup-uv` (see `ruff`, `pyright`): no virtualenv to manage, exact
version pin, self-contained.
Loading