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
4 changes: 4 additions & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
}
],
"packages": {
"actions/actionlint": {
"component": "actionlint",
"initial-version": "0.0.1"
},
"actions/semconv/pull-request": {
"component": "semconv-pull-request",
"initial-version": "0.0.1"
Expand Down
1 change: 1 addition & 0 deletions .github/release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"actions/actionlint": "0.0.1",
"actions/semconv/pull-request": "0.0.1",
"actions/release-please": "0.1.0",
"actions/stale": "0.0.2",
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Workflow lint

# Its own workflow rather than a job in ci.yml: a workflow file that does not
# parse runs none of its jobs, so a lint job inside ci.yml could never report
# the one change most likely to break ci.yml.

on:
push:
branches: [main]
pull_request:

permissions: {}

concurrency:
group: workflow-lint-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

jobs:
actionlint:
name: Actionlint (action)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# The repository lints itself with the action it ships, so a change to
# that action is exercised by the pull request that makes the change.
- uses: ./actions/actionlint
143 changes: 143 additions & 0 deletions actions/actionlint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# actionlint

Lints GitHub Actions workflows with [rhysd/actionlint][actionlint] and annotates
the diff with what it finds. The binary is downloaded and checksum-verified at
run time rather than vendored, so a workflow always lints against the version
this action declares.

[actionlint]: https://github.com/rhysd/actionlint

## Usage

Put the lint job in a workflow of its own:

```yaml
# .github/workflows/workflow-lint.yml
name: Workflow lint

on:
pull_request:
push:
branches: [main]

permissions: {}

jobs:
actionlint:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- uses: TrogonStack/github-actions/actions/actionlint@<sha> # vX.Y.Z
```

It has to be its own workflow file, not a job folded into one of the workflows
it checks. A workflow file that fails to parse runs no jobs at all, so a lint
job defined inside the very file it is meant to guard never gets scheduled,
and the broken file reports no checks rather than a failing one. A separate
file keeps parsing and linting independent: `workflow-lint.yml` can fail
`ci.yml` even when `ci.yml` itself cannot be parsed.

The caller checks out the repository; this action does not, so it can run
against whatever ref the caller already has checked out. Pass
`persist-credentials: false` on that checkout, since nothing after it needs to
push or authenticate as the job.

## Inputs

| Input | Default | Description |
| --- | --- | --- |
| `version` | `1.7.12` | actionlint version to install, without the leading `v`. |
| `shellcheck` | `true` | Install shellcheck so actionlint also lints `run:` scripts. See [Shellcheck](#shellcheck). |
| `config` | empty | Path to an actionlint config file, passed as `-config-file`. Empty lets actionlint auto-discover `.github/actionlint.yaml`. |
| `args` | empty | Extra actionlint arguments, one per line. See [Extra arguments](#extra-arguments). |

## Shellcheck

actionlint lints the shell inside `run:` steps by shelling out to shellcheck,
and silently skips that check when shellcheck is not on `PATH`. A bare
self-hosted runner image does not ship shellcheck the way `ubuntu-latest`
does, so the same workflow would pass on one runner pool and fail on another
for no reason visible in the diff. This action installs a pinned shellcheck
by default so every runner pool sees the same checks; set `shellcheck: false`
only where that duplicates a shellcheck step the caller already runs.

The shellcheck version is not an input. It is pinned to the version this
action's checksums were last computed against, so a caller cannot ask for an
unverified build the way the `config` and `args` inputs would otherwise
allow. Picking up a newer shellcheck means picking up a newer release of this
action.

## Extra arguments

`args` takes one actionlint argument per line, such as:

```yaml
with:
args: |
-ignore
"label \".+\" is unknown"
```

It is newline-separated rather than space-separated because an argument can
itself contain spaces, and splitting on newlines only needs each line read as
one argument, with no word-splitting and no shell evaluation of the line's
contents. Nothing in `args` is interpolated into the step's script; it is
read from an environment variable, so a value with `$(...)`, backticks, or
quotes in it is passed to actionlint literally rather than executed.

## Problem matcher

This action registers actionlint's [problem matcher][problem-matchers] before
running it, so findings on a pull request's changed lines show up as inline
annotations on the diff, not just in the job log. The matcher file is
vendored at [`actionlint-matcher.json`](actionlint-matcher.json) from
[`rhysd/actionlint`'s `.github/actionlint-matcher.json`][matcher-source] at
`v1.7.12`, rather than fetched at run time, so registering it needs no
network call of its own.

[problem-matchers]: https://github.com/rhysd/actionlint/blob/main/docs/usage.md#problem-matchers
[matcher-source]: https://github.com/rhysd/actionlint/blob/v1.7.12/.github/actionlint-matcher.json

## Checksums

Both binaries are downloaded over HTTPS from their project's GitHub releases
and checksummed before anything on `PATH` points at them; a mismatch fails
the step rather than running an unverified binary.

- For the default `version`, the actionlint checksum is one this action
carries itself, taken from the `actionlint_<version>_checksums.txt` file
that release published. No extra network call is needed to verify it.
- For any other `version`, this action downloads that release's own
`actionlint_<version>_checksums.txt` and verifies against it instead,
because a version it was not pinned against has no local checksum to check
against.
- shellcheck has no `version` input. Its checksum is pinned the same way the
default actionlint checksum is, and the only way to move it is a new
release of this action.

## Supported platforms

Linux on `X64` and `ARM64`, and macOS on `X64` and `ARM64`, read from
`runner.os` and `runner.arch`. Any other combination fails the step with
that pairing named in the error, rather than attempting a download that
cannot exist.

## Fixed behaviour

These are not inputs, on purpose.

- actionlint always runs with no file arguments, from the caller's workspace.
It discovers `.github/workflows` itself, the same way it would from a
contributor's own checkout.
- A finding always fails the step. There is no warn-only mode, because a
lint job that cannot fail is a lint job nobody has to act on.
- This action does not check out the repository and does not depend on mise.
It installs exactly what it needs to run actionlint, so a caller that
already manages its own tool versions is not handed a second opinion about
what Node, Python, or anything else should be.
64 changes: 64 additions & 0 deletions actions/actionlint/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: actionlint
description: >-
Lints GitHub Actions workflows with rhysd/actionlint and annotates the diff
with what it finds.
author: TrogonStack

inputs:
version:
description: actionlint version to install, without the leading `v`.
required: false
default: "1.7.12"
shellcheck:
description: >-
Install koalaman/shellcheck so actionlint also lints `run:` scripts.
actionlint silently skips that check when shellcheck is not on `PATH`,
so leaving this off makes self-hosted runners without shellcheck
preinstalled disagree with ones that have it.
required: false
default: "true"
config:
description: >-
Path to an actionlint config file, passed as `-config-file`. Leave
empty to let actionlint auto-discover `.github/actionlint.yaml`.
required: false
default: ""
args:
description: >-
Extra arguments for actionlint, one per line (for example `-ignore
'...'` on its own line). Newline-separated rather than space-separated
because an argument can itself contain spaces, and each line becomes
exactly one argument with no further word-splitting or shell
evaluation.
required: false
default: ""

runs:
using: composite
steps:
- name: Install actionlint
shell: bash
env:
VERSION: ${{ inputs.version }}
RUNNER_OS: ${{ runner.os }}
RUNNER_ARCH: ${{ runner.arch }}
run: bash "${GITHUB_ACTION_PATH}/lib/install-actionlint.sh"

- name: Install shellcheck
if: inputs.shellcheck == 'true'
shell: bash
env:
RUNNER_OS: ${{ runner.os }}
RUNNER_ARCH: ${{ runner.arch }}
run: bash "${GITHUB_ACTION_PATH}/lib/install-shellcheck.sh"

- name: Register problem matcher
shell: bash
run: echo "::add-matcher::${GITHUB_ACTION_PATH}/actionlint-matcher.json"

- name: Run actionlint
shell: bash
env:
CONFIG: ${{ inputs.config }}
EXTRA_ARGS: ${{ inputs.args }}
run: bash "${GITHUB_ACTION_PATH}/lib/run.sh"
17 changes: 17 additions & 0 deletions actions/actionlint/actionlint-matcher.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"problemMatcher": [
{
"owner": "actionlint",
"pattern": [
{
"regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$",
"file": 1,
"line": 2,
"column": 3,
"message": 4,
"code": 5
}
]
}
]
}
33 changes: 33 additions & 0 deletions actions/actionlint/lib/common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# shellcheck shell=bash

sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d ' ' -f 1
else
shasum -a 256 "$1" | cut -d ' ' -f 1
fi
}

verify_sha256() {
local path="$1" want="$2" name="$3" got
got=$(sha256_of "$path")
if [ "$got" != "$want" ]; then
echo "::error::${name} checksum mismatch: expected ${want}, got ${got}" >&2
exit 1
fi
}

# Prints the release asset suffix the named tool publishes for this runner.
platform_asset() {
local tool="$1"
case "${RUNNER_OS}-${RUNNER_ARCH}" in
Linux-X64) [ "$tool" = actionlint ] && echo linux_amd64 || echo linux.x86_64 ;;
Linux-ARM64) [ "$tool" = actionlint ] && echo linux_arm64 || echo linux.aarch64 ;;
macOS-ARM64) [ "$tool" = actionlint ] && echo darwin_arm64 || echo darwin.aarch64 ;;
macOS-X64) [ "$tool" = actionlint ] && echo darwin_amd64 || echo darwin.x86_64 ;;
*)
echo "::error::Unsupported runner ${RUNNER_OS}/${RUNNER_ARCH}. This action supports Linux X64/ARM64 and macOS X64/ARM64." >&2
return 1
;;
esac
}
43 changes: 43 additions & 0 deletions actions/actionlint/lib/install-actionlint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail

# shellcheck source=SCRIPTDIR/common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"

: "${VERSION:?VERSION is required}"
asset=$(platform_asset actionlint)

# Checksums rhysd/actionlint published for the version this action defaults
# to, so the default path trusts nothing fetched alongside the archive.
checksums="
1.7.12 linux_amd64 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
1.7.12 linux_arm64 325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6
1.7.12 darwin_amd64 5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644
1.7.12 darwin_arm64 aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f
"

archive="actionlint_${VERSION}_${asset}.tar.gz"
release="https://github.com/rhysd/actionlint/releases/download/v${VERSION}"
dest="${RUNNER_TEMP}/actionlint/${VERSION}"
archive_path="${RUNNER_TEMP}/${archive}"
mkdir -p "$dest"

curl --fail --silent --show-error --location --output "$archive_path" "${release}/${archive}"

want=$(awk -v v="$VERSION" -v a="$asset" '$1==v && $2==a{print $3}' <<<"$checksums")
if [ -z "$want" ]; then
# A version this action was not pinned against: fall back to the checksums
# file that release publishes, rather than installing an unverified binary.
sums="actionlint_${VERSION}_checksums.txt"
curl --fail --silent --show-error --location --output "${RUNNER_TEMP}/${sums}" "${release}/${sums}"
want=$(awk -v f="$archive" '$2==f{print $1}' "${RUNNER_TEMP}/${sums}")
if [ -z "$want" ]; then
echo "::error::No checksum for ${archive} in ${release}/${sums}" >&2
exit 1
fi
fi

verify_sha256 "$archive_path" "$want" "$archive"

tar -xzf "$archive_path" -C "$dest" actionlint
echo "$dest" >>"$GITHUB_PATH"
33 changes: 33 additions & 0 deletions actions/actionlint/lib/install-shellcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail

# shellcheck source=SCRIPTDIR/common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"

asset=$(platform_asset shellcheck)

# Pinned rather than an input: accepting any version would mean installing a
# binary with no checksum to hold it to. Moving the pin is a release of this
# action.
version="0.11.0"
checksums="
linux.x86_64 b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6
linux.aarch64 68a8133197a50beb8803f8d42f9908d1af1c5540d4bb05fdfca8c1fa47decefc
darwin.x86_64 c2c15e08df0e8fbc374c335b230a7ee958c313fa5714817a59aa59f1aa594f51
darwin.aarch64 339b930feb1ea764467013cc1f72d09cd6b869ebf1013296ba9055ab2ffbd26f
"

archive="shellcheck-v${version}.${asset}.tar.gz"
dest="${RUNNER_TEMP}/shellcheck/${version}"
archive_path="${RUNNER_TEMP}/${archive}"
mkdir -p "$dest"

curl --fail --silent --show-error --location --output "$archive_path" \
"https://github.com/koalaman/shellcheck/releases/download/v${version}/${archive}"

want=$(awk -v a="$asset" '$1==a{print $2}' <<<"$checksums")
verify_sha256 "$archive_path" "$want" "$archive"

tar -xzf "$archive_path" -C "$dest" --strip-components=1 "shellcheck-v${version}/shellcheck"
chmod +x "${dest}/shellcheck"
echo "$dest" >>"$GITHUB_PATH"
Loading
Loading