From 21745153d68277a898509490d7369cec033af705 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Thu, 30 Jul 2026 10:54:13 -0700 Subject: [PATCH 1/2] fix: repair invalid action.yml and harden the action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit action.yml was not valid YAML on any ref, including the published v1.0.0 and v1 tags: the `Lint PR commits` step embedded a Python script at column 0 inside an 8-space block scalar, so the dedent ended the scalar and YAML read `if msg.startswith("Merge "): continue` as a mapping. GitHub could not load the action at all — it was broken for every consumer. Rather than re-indent the heredoc, drop it. commitlint already has `--range`, which reads the PR's commits from git and skips merge commits itself, so the API call, the token, the base64 round-trip, and the pagination all disappear. That removes the construct that broke the parse and three defects with it: - per_page=250 was silently capped at 100 by the API, so PRs with more than 100 commits were partially linted with no warning - `|| status=1` was unreachable in warn mode, since --mode warn exits 0 - the ALLOW_REVERT_PREFIX comment described merge commits, but that flag only accepts `Revert "..."` subjects Shallow checkouts do not contain the PR base, and HEAD on a pull_request event is GitHub's synthetic merge commit, so fetch both endpoints and lint to the branch tip. Guard for a missing checkout with an actionable error rather than a bare git failure. Pin actions/setup-go to a commit SHA. The README recommended SHA pinning while the action itself used a mutable tag. Add MIT LICENSE (the repo had none, leaving it legally unusable) and CI: action.yml is parsed, actionlint runs, unpinned `uses:` fail the build, and a self-test job runs the action against its own PR. The parse check and actionlint were both confirmed to catch the original defect at line 81. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VSrrciEDBTuFNMtKocScML --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++ README.md | 26 ++++++++++------ action.yml | 58 +++++++++++++++++----------------- 4 files changed, 135 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..642396d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + # action.yml shipped invalid YAML in v1.0.0 and v1: an embedded script was + # dedented out of its block scalar, so GitHub could not load the action at + # all. Parsing it on every change is the check that would have caught it. + - name: action.yml parses + run: python3 -c "import yaml,sys; yaml.safe_load(open('action.yml')); print('action.yml parses')" + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: stable + cache: false + + # Installed from source at a pinned version rather than pulled as a + # third-party action or image, matching this repo's "no third-party + # actions" posture. + - name: actionlint + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + actionlint -color + + # This action's own README recommends SHA pinning; practice it here. + - name: actions are pinned to SHAs + run: | + all_uses="$(grep -rhoE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*[^[:space:]]+' action.yml .github/workflows/ \ + | sed -E 's/.*uses:[[:space:]]*//' | sort -u)" + unpinned="$(printf '%s\n' "$all_uses" | grep -vE '@(sha256:)?[0-9a-f]{40,}$' || true)" + if [ -n "$unpinned" ]; then + echo "these must be pinned to a full commit SHA (or image digest):" + printf '%s\n' "$unpinned" + exit 1 + fi + + # Run the action against this very PR. A composite action can pass every + # static check and still fail at runtime, so exercise it for real. + self-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: run this action (advisory) + uses: ./ + with: + # Never fail this repo's CI on a contributor's commit style; the point + # is to prove the action executes, not to gate on its verdict. + pr-title-mode: warn + commits-mode: warn + + - name: run with commits-mode off + uses: ./ + with: + pr-title-mode: warn + commits-mode: off diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a543404 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DivergentCodes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 927b9a7..d9b3a09 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,22 @@ on: pull_request: types: [opened, edited, synchronize, reopened] permissions: - pull-requests: read + contents: read jobs: commitlint: runs-on: ubuntu-latest steps: - - uses: DivergentCodes/commitlint-action@ # v1.0.0 + - uses: actions/checkout@ # required for commits-mode + - uses: DivergentCodes/commitlint-action@ # v1.0.1 with: pr-title-mode: block # squash-merge title is load-bearing commits-mode: warn # advisory; intermediate commits vanish at squash ``` +`actions/checkout` is required whenever `commits-mode` is not `off`, because +the action reads the PR's commits from git. With `commits-mode: off` the title +comes from the event payload and no checkout is needed. + Tag pinning (`@v1`) also works and is fine for internal repos, but SHA pinning is recommended for anything security-sensitive. @@ -55,20 +60,19 @@ merge with rebase or merge commits instead, set `commits-mode: block`. | `scopes` | any | comma-separated allowed scopes | | `require-scope` | `false` | require a `(scope)` | | `max-subject-length` | `72` | subject length limit | -| `github-token` | `github.token` | reads PR commits via the API | ## Permissions -`permissions: pull-requests: read` is sufficient — the action reads the PR -title from the event payload and PR commits via the API with the default -`github.token`. It does not need `contents` access and never writes anything. +`permissions: contents: read` is sufficient — enough for `actions/checkout` +to fetch the commits. The action makes no API calls, needs no token, and never +writes anything. With `commits-mode: off` it reads only the event payload and +needs no permissions beyond the workflow default. ## Runner requirements Runs on `ubuntu-latest` (and other GitHub-hosted runners) out of the box: it -uses `actions/setup-go` for the linter and `python3` + `base64` (both -preinstalled on hosted runners) to decode commit messages from the API. On -**self-hosted runners**, ensure Go, `python3`, and `base64` are available. +uses `actions/setup-go` to install the linter and reads commits with `git`. On +**self-hosted runners**, ensure Go and `git` are available. ## Troubleshooting @@ -84,3 +88,7 @@ preinstalled on hosted runners) to decode commit messages from the API. On Published as **GitHub tagged releases** with changelogs in the release notes. Reference a release by SHA (preferred) or tag; there is no committed changelog file. + +## License + +[MIT](LICENSE) diff --git a/action.yml b/action.yml index 4d03f84..0c4db7b 100644 --- a/action.yml +++ b/action.yml @@ -5,9 +5,13 @@ description: > source. No third-party actions beyond GitHub's own. author: DivergentCodes +branding: + icon: check-square + color: green + inputs: version: - description: commitlint version to install (a tag like v1.0.0, or latest) + description: commitlint version to install (a tag like v1.1.0, or latest) default: latest pr-title-mode: description: "Lint the PR title: block | warn | off" @@ -27,58 +31,56 @@ inputs: max-subject-length: description: Maximum subject line length default: "72" - github-token: - description: Token for reading PR commits via the API - default: ${{ github.token }} runs: using: composite steps: - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: stable cache: false - name: Install commitlint shell: bash - run: go install github.com/DivergentCodes/commitlint@${{ inputs.version }} + env: + VERSION: ${{ inputs.version }} + run: go install "github.com/DivergentCodes/commitlint@${VERSION}" - name: Lint PR title - if: inputs.pr-title-mode != 'off' && github.event_name == 'pull_request' + if: ${{ inputs.pr-title-mode != 'off' && github.event_name == 'pull_request' }} shell: bash env: + MODE: ${{ inputs.pr-title-mode }} COMMITLINT_TYPES: ${{ inputs.types }} COMMITLINT_SCOPES: ${{ inputs.scopes }} COMMITLINT_REQUIRE_SCOPE: ${{ inputs.require-scope }} COMMITLINT_MAX_SUBJECT_LENGTH: ${{ inputs.max-subject-length }} - run: commitlint lint --github-pr-title --mode "${{ inputs.pr-title-mode }}" + run: commitlint lint --github-pr-title --mode "$MODE" + # Lints the PR's own commits with `--range`, which reads them from git and + # skips merge commits itself. That needs no API call, so there is no token, + # no pagination limit, and no partial-coverage failure mode. + # + # actions/checkout leaves a shallow clone that does not contain the base + # commit, so fetch both endpoints before resolving the range. HEAD on a + # pull_request event is GitHub's synthetic merge commit; a range endpoint is + # always included, so lint up to the PR's real branch tip instead. - name: Lint PR commits - if: inputs.commits-mode != 'off' && github.event_name == 'pull_request' + if: ${{ inputs.commits-mode != 'off' && github.event_name == 'pull_request' }} shell: bash env: - GH_TOKEN: ${{ inputs.github-token }} + MODE: ${{ inputs.commits-mode }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} COMMITLINT_TYPES: ${{ inputs.types }} COMMITLINT_SCOPES: ${{ inputs.scopes }} COMMITLINT_REQUIRE_SCOPE: ${{ inputs.require-scope }} COMMITLINT_MAX_SUBJECT_LENGTH: ${{ inputs.max-subject-length }} - # Merge commits from base-branch syncs aren't squash material. - COMMITLINT_ALLOW_REVERT_PREFIX: "true" run: | set -euo pipefail - api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits?per_page=250" - status=0 - count=0 - while IFS= read -r encoded; do - [ -z "$encoded" ] && continue - count=$((count + 1)) - printf '%s' "$encoded" | base64 -d > /tmp/commit-msg.txt - commitlint lint --file /tmp/commit-msg.txt --mode "${{ inputs.commits-mode }}" || status=1 - done < <(curl -fsSL -H "Authorization: Bearer ${GH_TOKEN}" "$api" | - python3 -c 'import json,sys,base64 -for c in json.load(sys.stdin): - msg = c["commit"]["message"] - if msg.startswith("Merge "): continue - print(base64.b64encode(msg.encode()).decode())') - echo "linted $count commit message(s)" - exit $status + if [ ! -d .git ]; then + echo "::error::commits-mode needs the repository checked out; add actions/checkout before this action, or set commits-mode: off" + exit 1 + fi + git fetch --no-tags --depth=50 origin "$BASE_SHA" "$HEAD_SHA" + commitlint lint --range "${BASE_SHA}..${HEAD_SHA}" --mode "$MODE" From c64f3fd4ea85f41b602b213a92db0a823e8b0a1f Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Thu, 30 Jul 2026 10:56:27 -0700 Subject: [PATCH 2/2] fix: exclude local uses from pin guard; gate self-test on public module CI caught two problems with its own new checks. The pin guard flagged `uses: ./`. A local path refers to the commit already checked out, so there is no external ref to pin; exclude `./`-prefixed uses rather than weaken the SHA pattern. The self-test could not run the action: it installs the linter with `go install`, and DivergentCodes/commitlint is still private, so the module proxy returns 404 on a hosted runner. Confirmed by fetching both the repo and proxy.golang.org unauthenticated. Gate the job on a COMMITLINT_PUBLIC repo variable so it is skipped for a known reason instead of failing for one unrelated to this code; drop the condition once commitlint is public. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VSrrciEDBTuFNMtKocScML --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 642396d..1244ed2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,10 @@ jobs: # This action's own README recommends SHA pinning; practice it here. - name: actions are pinned to SHAs run: | + # `uses: ./` and `./path` reference this repo at the commit already + # checked out; there is no external ref to pin, so exclude them. all_uses="$(grep -rhoE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*[^[:space:]]+' action.yml .github/workflows/ \ - | sed -E 's/.*uses:[[:space:]]*//' | sort -u)" + | sed -E 's/.*uses:[[:space:]]*//' | grep -v '^\./' | sort -u)" unpinned="$(printf '%s\n' "$all_uses" | grep -vE '@(sha256:)?[0-9a-f]{40,}$' || true)" if [ -n "$unpinned" ]; then echo "these must be pinned to a full commit SHA (or image digest):" @@ -47,7 +49,14 @@ jobs: # Run the action against this very PR. A composite action can pass every # static check and still fail at runtime, so exercise it for real. + # + # The action installs the linter with `go install`, which needs the + # DivergentCodes/commitlint module to be fetchable. While that repo is + # private the module proxy returns 404 on a hosted runner, so this job is + # skipped rather than left failing for a reason unrelated to this code. + # Once commitlint is public, delete the `if:` and this job runs everywhere. self-test: + if: ${{ github.repository_owner == 'DivergentCodes' && vars.COMMITLINT_PUBLIC == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0