diff --git a/.github/actions/yaml.yamlfmt/action.yaml b/.github/actions/yaml.yamlfmt/action.yaml new file mode 100644 index 0000000..cb7f927 --- /dev/null +++ b/.github/actions/yaml.yamlfmt/action.yaml @@ -0,0 +1,104 @@ +name: YAML - yamlfmt +description: Check that YAML files are formatted with `yamlfmt`, without modifying them. On failure, the diff is added to the job summary. Requires the `yamlfmt` CLI on the `PATH`. + + +inputs: + target_dir: + required: false + default: ./ + description: "(Optional) The directory to check recursively for `.yaml` and `.yml` files. Uses standard path matching and honors the config's exclude rules. Defaults to `./`." + config_file: + required: false + description: "(Optional) The path to the `yamlfmt` config file, relative to the repository root. When omitted, `yamlfmt` discovers its config as usual." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate unformatted files in the pull request when `yamlfmt` fails. Defaults to `true`." + +outputs: + skipped: + value: ${{ steps.target.outputs.skipped }} + description: "`true` when `target_dir` is missing or contains no `.yaml` or `.yml` files and `yamlfmt` was not run. Empty otherwise." + stdout: + value: ${{ steps.fmt.outputs.stdout }} + description: "The STDOUT stream of the call to `yamlfmt`. Empty when skipped." + stderr: + value: ${{ steps.fmt.outputs.stderr }} + description: "The STDERR stream of the call to `yamlfmt`. Empty when skipped." + exitcode: + value: ${{ steps.fmt.outputs.exitcode }} + description: "The exit code of the call to `yamlfmt`. Empty when skipped. The action still fails on a non-zero exit code, so use `continue-on-error: true` to inspect it." + + +runs: + using: composite + + steps: + - name: Resolve Target + id: target + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + run: | + target_dir="${TARGET_DIR%/}" + target_dir="${target_dir:-.}" + echo "target_dir=$target_dir" >> "$GITHUB_OUTPUT" + + files="" + if [ -d "$target_dir" ]; then + files=$(find "$target_dir" -type f \( -name '*.yaml' -o -name '*.yml' \) -print -quit) + fi + if [ -z "$files" ]; then + echo "::notice::Skipping yamlfmt: no YAML files in $target_dir." + echo "skipped=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + yamlfmt -version + + - name: Check YAML Format + id: fmt + if: steps.target.outputs.skipped != 'true' + uses: tedilabs/github-actions/.github/actions/shell.run@main + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + CONFIG_FILE: ${{ inputs.config_file }} + with: + run: | + args=(-lint -output_format default -match_type standard -extensions "yaml,yml") + if [ -n "$CONFIG_FILE" ]; then + args+=(-conf "$CONFIG_FILE") + fi + + yamlfmt "${args[@]}" -- "$TARGET_DIR" + + # The line output names each unformatted file but provides no line numbers. + - name: Annotate Findings + id: fmt-annotate + if: always() && inputs.annotations_enabled == 'true' && steps.fmt.outcome == 'failure' + shell: bash + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + CONFIG_FILE: ${{ inputs.config_file }} + run: | + args=(-dry -output_format line -match_type standard -extensions "yaml,yml") + if [ -n "$CONFIG_FILE" ]; then + args+=(-conf "$CONFIG_FILE") + fi + + yamlfmt "${args[@]}" -- "$TARGET_DIR" 2>/dev/null | awk ' + /: formatting difference found$/ { + sub(/: formatting difference found$/, "") + gsub(/%/, "%25"); gsub(/\r/, "%0D"); gsub(/:/, "%3A"); gsub(/,/, "%2C") + printf "::error file=%s,title=yamlfmt · Not formatted::Run `yamlfmt` to format this file.\n", $0 + } + ' || true + + - name: Add Failure Details to Job Summary + id: fmt-summary + if: always() && steps.fmt.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ yamlfmt · ${{ steps.target.outputs.target_dir }}" + file: ${{ steps.fmt.outputs.log_file }} + lang: diff diff --git a/.github/actions/yaml.yamllint/action.yaml b/.github/actions/yaml.yamllint/action.yaml new file mode 100644 index 0000000..a637ce5 --- /dev/null +++ b/.github/actions/yaml.yamllint/action.yaml @@ -0,0 +1,92 @@ +name: YAML - yamllint +description: Lint YAML files with `yamllint`. On failure, the findings are added to the job summary. Requires the `yamllint` CLI on the `PATH`. + + +inputs: + target_dir: + required: false + default: ./ + description: "(Optional) The directory to lint recursively. Defaults to `./`." + config_file: + required: false + description: "(Optional) The path to the `yamllint` config file, relative to the repository root. When omitted, `yamllint` discovers its config as usual." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate findings in the pull request using the GitHub output format. Defaults to `true`." + +outputs: + skipped: + value: ${{ steps.target.outputs.skipped }} + description: "`true` when `target_dir` is missing or contains no files selected by `yamllint`, including its ignore rules. Empty otherwise." + stdout: + value: ${{ steps.lint.outputs.stdout }} + description: "The STDOUT stream of the call to `yamllint`. Empty when skipped." + stderr: + value: ${{ steps.lint.outputs.stderr }} + description: "The STDERR stream of the call to `yamllint`. Empty when skipped." + exitcode: + value: ${{ steps.lint.outputs.exitcode }} + description: "The exit code of the call to `yamllint`. Empty when skipped. The action still fails on a non-zero exit code, so use `continue-on-error: true` to inspect it." + + +runs: + using: composite + + steps: + - name: Resolve Target + id: target + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + CONFIG_FILE: ${{ inputs.config_file }} + run: | + target_dir="${TARGET_DIR%/}" + target_dir="${target_dir:-.}" + echo "target_dir=$target_dir" >> "$GITHUB_OUTPUT" + + args=() + if [ -n "$CONFIG_FILE" ]; then + args+=(--config-file "$CONFIG_FILE") + fi + + files="" + if [ -d "$target_dir" ]; then + files=$(yamllint "${args[@]}" --list-files -- "$target_dir") + fi + if [ -z "$files" ]; then + echo "::notice::Skipping yamllint: no YAML files in $target_dir." + echo "skipped=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + yamllint --version + + - name: Lint YAML Files + id: lint + if: steps.target.outputs.skipped != 'true' + uses: tedilabs/github-actions/.github/actions/shell.run@main + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + CONFIG_FILE: ${{ inputs.config_file }} + ANNOTATIONS_ENABLED: ${{ inputs.annotations_enabled }} + with: + run: | + args=(--format standard) + if [ "$ANNOTATIONS_ENABLED" = "true" ]; then + args=(--format github) + fi + if [ -n "$CONFIG_FILE" ]; then + args+=(--config-file "$CONFIG_FILE") + fi + + yamllint "${args[@]}" -- "$TARGET_DIR" + + - name: Add Failure Details to Job Summary + id: lint-summary + if: always() && steps.lint.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ yamllint · ${{ steps.target.outputs.target_dir }}" + file: ${{ steps.lint.outputs.log_file }} + lang: text diff --git a/.github/workflows/yaml.integration.yaml b/.github/workflows/yaml.integration.yaml index 6a9857a..2e9305e 100644 --- a/.github/workflows/yaml.integration.yaml +++ b/.github/workflows/yaml.integration.yaml @@ -1,35 +1,140 @@ -name: Integration - YAML +name: YAML - Integration on: - push: - branches: - - main - paths: - - "**.yaml" - - "**.yml" + workflow_call: + inputs: + runs_on: + description: > + JSON-encoded runs-on value. + Examples: + - '"ubuntu-latest"' + - '["self-hosted","linux","x64"]' + required: false + type: string + default: '"ubuntu-latest"' - pull_request: - paths: - - "**.yaml" - - "**.yml" + yamllint_version: + type: string + required: false + default: latest + description: "(Optional) The version of `yamllint` to install with mise. Defaults to `latest`." + yamlfmt_version: + type: string + required: false + default: latest + description: "(Optional) The version of `yamlfmt` to install with mise. Defaults to `latest`." - workflow_dispatch: {} + yamllint_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to lint YAML files with `yamllint`. Defaults to `true`." + yamllint_config_file: + type: string + required: false + description: "(Optional) The path to the `yamllint` config file, relative to the repository root. When omitted, `yamllint` discovers its config as usual." + yamllint_target_dir: + type: string + required: false + default: ./ + description: "(Optional) The directory to lint recursively with `yamllint`. Defaults to `./`." + yamlfmt_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to check YAML formatting with `yamlfmt`, without modifying files. Defaults to `true`." + yamlfmt_config_file: + type: string + required: false + description: "(Optional) The path to the `yamlfmt` config file, relative to the repository root. When omitted, `yamlfmt` discovers its config as usual." + yamlfmt_target_dir: + type: string + required: false + default: ./ + description: "(Optional) The directory to check recursively with `yamlfmt`. Defaults to `./`." - -concurrency: - group: integration-yaml-${{ github.ref }} - cancel-in-progress: true + pr_comment_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to post the integration report as a single sticky comment on pull requests. Requires the `pull-requests: write` permission on the caller. The report is always written to the job summary. Defaults to `true`." jobs: lint: - name: Lint (yamllint) - uses: tedilabs/github-actions/.github/workflows/yaml.yamllint.yaml@main - - with: - yamllint_version: latest - yamllint_config_file: .yamllint.yaml - yamllint_target_dir: ./ - secrets: - token: ${{ secrets.GITHUB_TOKEN }} + name: Lint + runs-on: ${{ fromJson(inputs.runs_on) }} + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + + - name: Set up tools + id: setup-tools + if: inputs.yamllint_enabled || inputs.yamlfmt_enabled + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + with: + mise_toml: | + [tools] + "pipx:yamllint" = "${{ inputs.yamllint_version }}" + yamlfmt = "${{ inputs.yamlfmt_version }}" + install_args: >- + ${{ inputs.yamllint_enabled && 'pipx:yamllint' || '' }} + ${{ inputs.yamlfmt_enabled && 'yamlfmt' || '' }} + + - name: Lint (yamllint) + id: yamllint + if: inputs.yamllint_enabled + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/yaml.yamllint@main + with: + config_file: ${{ inputs.yamllint_config_file }} + target_dir: ${{ inputs.yamllint_target_dir }} + + - name: Check Format (yamlfmt) + id: yamlfmt + if: inputs.yamlfmt_enabled + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/yaml.yamlfmt@main + with: + config_file: ${{ inputs.yamlfmt_config_file }} + target_dir: ${{ inputs.yamlfmt_target_dir }} + + - name: Collect Results + id: results + if: always() + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: collect + id: ${{ github.repository }} + id_label: Repository + artifact_prefix: yaml-integration + job_status: ${{ job.status }} + # Disabled checks are left out of the report; checks with no target files report `skipped`. + results: | + { + "yamllint": "${{ inputs.yamllint_enabled && (steps.yamllint.outputs.skipped == 'true' && 'skipped' || steps.yamllint.outcome) || '' }}", + "yamlfmt": "${{ inputs.yamlfmt_enabled && (steps.yamlfmt.outputs.skipped == 'true' && 'skipped' || steps.yamlfmt.outcome) || '' }}" + } + + + report: + name: Report + needs: + - lint + if: always() + runs-on: ${{ fromJson(inputs.runs_on) }} + + steps: + - name: Publish Report + id: report + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: publish + id_label: Repository + artifact_prefix: yaml-integration + title: YAML Integration + pr_comment_enabled: ${{ inputs.pr_comment_enabled }} + pr_comment_marker: yaml-integration diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml new file mode 100644 index 0000000..b7dec0a --- /dev/null +++ b/.github/workflows/yaml.yaml @@ -0,0 +1,32 @@ +name: Integration - YAML + + +on: + push: + branches: + - main + paths: + - "**.yaml" + - "**.yml" + + pull_request: + paths: + - "**.yaml" + - "**.yml" + + workflow_dispatch: {} + + +concurrency: + group: integration-yaml-${{ github.ref }} + cancel-in-progress: true + + +jobs: + integration: + name: Integration + uses: tedilabs/github-actions/.github/workflows/yaml.integration.yaml@main + + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/yaml.yamllint.yaml b/.github/workflows/yaml.yamllint.yaml index 0f355c6..a07bceb 100644 --- a/.github/workflows/yaml.yamllint.yaml +++ b/.github/workflows/yaml.yamllint.yaml @@ -56,8 +56,7 @@ jobs: - name: Lint YAML Files id: yaml-lint - run: | - yamllint \ - --format github \ - --config-file ${{ inputs.yamllint_config_file }} \ - ${{ inputs.yamllint_target_dir }} + uses: tedilabs/github-actions/.github/actions/yaml.yamllint@main + with: + config_file: ${{ inputs.yamllint_config_file }} + target_dir: ${{ inputs.yamllint_target_dir }} diff --git a/.yamlfmt.yaml b/.yamlfmt.yaml new file mode 100644 index 0000000..e6aa6fc --- /dev/null +++ b/.yamlfmt.yaml @@ -0,0 +1,6 @@ +formatter: + type: basic + retain_line_breaks: true + indentless_arrays: true + scan_folded_as_literal: true + pad_line_comments: 2