Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .ai/prompts/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ necessary for release-please to pick up our squash merge commits to main.
```
4. Implement the changes with clean, logical commits.
5. Run the full quality suite:
- mix ci
- mix precommit
6. Fix any failures before proceeding.
7. Push the branch and create a PR:
```
Expand Down
92 changes: 80 additions & 12 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,35 @@ on: # yamllint disable-line rule:truthy
workflow_dispatch:
workflow_call:
inputs:
skip_commit_validation:
base_ref:
description: >-
Skip the commit-subject check - it only makes sense pre-merge
(should this PR be mergeable), not as a post-merge gate.
Commit comparison base: an exact 40-character SHA (for push events
and closed-PR validation), a branch name (for workflow_dispatch with
an explicit base), or empty (falls back to origin/main). The Test job
exports this as BASE_REF so ci/validate_commit_range.sh uses the
correct range instead of re-resolving it from scratch.
required: false
type: string
default: ""
pull_request_title_required:
description: >-
Set to true only for pull_request events. ci/validate_pull_request_title.sh
treats any other value as "skip" and exits 0. Pass "false" for push,
closed-PR, and workflow_dispatch events where there is no PR title to enforce.
required: false
type: boolean
default: false
pull_request_title:
description: >-
The pull request title to validate. Only meaningful when
pull_request_title_required is true. Passed as an environment variable
so the title is never interpolated as shell code — quotes, backticks,
dollar signs, and Unicode are all safe.
required: false
type: string
default: ""
pull_request:
types: [opened, synchronize, reopened, edited]

permissions:
contents: read
Expand All @@ -28,6 +49,17 @@ jobs:
steps:
-
uses: actions/checkout@v7
with:
# mix precommit calls ci/validate_commit_range.sh, which validates
# every commit since the merge base. A full-history checkout lets the
# script resolve that base locally without a second network fetch.
fetch-depth: 0
# pull_request otherwise checks out GitHub's synthetic test-merge
# commit (subject "Merge <sha> into <sha>"). Validate the
# contributor's actual branch tip instead so that commit never
# appears in the validated range. Falls back to github.sha for
# non-PR triggers (workflow_dispatch, workflow_call from push/closed).
ref: ${{ github.event.pull_request.head.sha || github.sha }}
-
uses: erlef/setup-beam@v1
with:
Expand All @@ -48,19 +80,44 @@ jobs:
app/_build
key: ${{ runner.os }}-mix-${{ hashFiles('app/mix.lock') }}
-
# mix ci covers: deps.get, hex.audit, deps.audit (Elixir security
# advisories), format --check-formatted, credo (static analysis),
# usage_rules.sync --check (catches dep-bump drift, see #79),
# and mix test. Runs from the repo root; cd: app/ is handled
# inside the task itself.
run: mix ci
# mix precommit runs the full 11-step gate:
# 1. ci/validate_pull_request_title.sh — PR title (when required)
# 2. ci/validate_commit_range.sh — all commit subjects in the range
# 3-4. Root format + test (the repo-management tooling itself)
# 5-11. App deps.get, hex.audit, deps.audit, format, credo,
# usage_rules.sync, and test.
# BASE_REF carries the exact comparison base SHA so the commit-range
# validator never has to guess. For pull_request events it is the exact
# base SHA; for push events it is github.event.before; for
# workflow_dispatch and unverified closes it is empty, and the validator
# falls back to origin/main.
# PULL_REQUEST_TITLE is set via env (never ${{ }} in run:) so PR titles
# containing quotes, backticks, dollar signs, or Unicode are treated as
# inert data, not executable shell code.
name: Run full quality gate
env:
BASE_REF: >-
${{ inputs.base_ref != '' && inputs.base_ref
|| github.event.pull_request.base.sha }}
PULL_REQUEST_TITLE_REQUIRED: >-
${{ inputs.pull_request_title_required
|| github.event_name == 'pull_request' }}
PULL_REQUEST_TITLE: >-
${{ inputs.pull_request_title != '' && inputs.pull_request_title
|| github.event.pull_request.title }}
run: mix precommit
working-directory: .

burrito_changes:
# Building Burrito is deliberately reserved for changes that affect its
# dependency graph or packaging path. The release workflow still builds
# every target before publishing.
if: github.event_name == 'pull_request' && github.event.action != 'closed'
# Exclude 'edited' events — a title-only change never alters file content,
# so there are no Burrito-impacting diffs to check.
if: >-
github.event_name == 'pull_request'
&& github.event.action != 'closed'
&& github.event.action != 'edited'
name: Detect Burrito-impacting changes
runs-on: ubuntu-latest
outputs:
Expand Down Expand Up @@ -142,7 +199,16 @@ jobs:
run: ../ci/test_burrito_shared_loader.sh ./burrito_out/lc_linux_x86_64

conventional_commits:
if: inputs.skip_commit_validation != true
# Temporary compatibility job retained while branch protection still lists
# "Validate Commit Subjects" as a required check. The Test job above runs
# the same ci/validate_commit_range.sh via mix precommit and is the
# authoritative gate; this job will be removed in EXT-33 after the required-
# check set is migrated to "Test" only.
# Run for all direct triggers (pull_request, workflow_dispatch); skip only
# when main.yaml calls this workflow for a non-PR event and explicitly sets
# pull_request_title_required to false — those are post-merge runs where
# there is no open PR to gate on Validate Commit Subjects.
if: github.event_name != 'workflow_call' || inputs.pull_request_title_required
name: Validate Commit Subjects
runs-on: ubuntu-latest
steps:
Expand All @@ -158,5 +224,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
-
env:
FETCH_BASE_REF: "true"
BASE_REF: >-
${{ inputs.base_ref != '' && inputs.base_ref
|| github.event.pull_request.base.sha }}
run: ./ci/validate_commit_range.sh
18 changes: 17 additions & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,23 @@ jobs:
name: Validations
uses: ./.github/workflows/ci.yaml
with:
skip_commit_validation: true
# push: validate every commit introduced after github.event.before.
# The exact SHA lets ci/validate_commit_range.sh check the squash/merge
# integration commit itself rather than comparing main to main (empty).
# pull_request closed: validate the merged result when a PR was actually
# merged; the base SHA gives the correct start of the new-commits range.
# For unmerged closes the range would be empty, but passing the base SHA
# is harmless — git log will find no commits to validate.
# workflow_dispatch: no PR title and no exact base; the validator falls
# back to origin/main as the comparison base.
# PR title validation only applies to direct pull_request events
# (handled by ci.yaml's own trigger), not to any workflow_call here.
base_ref: >-
${{ github.event_name == 'push' && github.event.before
|| github.event_name == 'pull_request' && github.event.pull_request.base.sha
|| '' }}
pull_request_title_required: false
pull_request_title: ""

manage-release-pr:
needs: [validate]
Expand Down
9 changes: 8 additions & 1 deletion Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,14 @@ $ mix lc whoami
$ mix lc issue list --output json
----

The project uses ExUnit and `mix format`. Run tests with:
The project uses ExUnit and `mix format`. Run the full quality gate with:

[source,sh]
----
$ mix precommit
----

To run only the app test suite or format check directly:

[source,sh]
----
Expand Down
77 changes: 38 additions & 39 deletions ci/validate_commit_range.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ usage() {
$0

Environment:
BASE_REF Base branch or ref. Defaults to GITHUB_BASE_REF, then origin/main.
FETCH_BASE_REF When "true", fetch BASE_REF from origin before validating.
BASE_REF Base branch or ref. Defaults to GITHUB_BASE_REF, then main.
EOT
}

Expand Down Expand Up @@ -37,9 +36,7 @@ validator="$repo_top/ci/validate_conventional_subject.sh"

[ -x "$validator" ] || die "validator is not executable: $validator"

base_input=${BASE_REF:-${GITHUB_BASE_REF:-}}
base_name=${base_input#refs/heads/}
base_name=${base_name#origin/}
base_input=${BASE_REF:-${GITHUB_BASE_REF:-main}}

base_ref=

Expand All @@ -49,20 +46,22 @@ then
# already contains it, and using the immutable SHA ensures the newly
# pushed main commit is validated instead of comparing main to itself.
base_ref_candidates="$base_input"
elif [ -n "$base_input" ]
then
if [ "${FETCH_BASE_REF:-}" = "true" ]

if ! git rev-parse --verify --quiet "$base_input" >/dev/null
then
git_or_die fetch --no-tags origin "$base_name:refs/remotes/origin/$base_name" >/dev/null
# A shallow GitHub checkout may contain only HEAD. Fetch the precise
# pre-push SHA here rather than requiring workflow-specific setup.
git_or_die fetch --no-tags origin "$base_input" >/dev/null
fi
else
base_name=${base_input#refs/heads/}
base_name=${base_name#origin/}

# Prefer the remote-tracking ref. In particular, a developer pushing
# directly from local main must compare against origin/main, not against
# local main (HEAD), or the range would be empty and a bypassed commit-msg
# hook could slip through pre-push validation.
base_ref_candidates="origin/$base_name $base_input $base_name"
else
base_ref_candidates="origin/main main"
fi

for candidate in $base_ref_candidates
Expand All @@ -74,47 +73,47 @@ do
fi
done

[ -n "$base_ref" ] || {
if [[ "$base_input" =~ ^[0-9a-fA-F]{40}$ ]]
then
# `git fetch origin <sha>` normally makes the object directly
# addressable. Keep FETCH_HEAD as a fallback for Git servers that do
# not install an anonymous remote-tracking ref for a SHA request.
base_ref_candidates="$base_input FETCH_HEAD"
else
# Local clones normally already have origin/main (or their configured
# BASE_REF), so this branch is not taken locally. GitHub Actions'
# default shallow checkout does not; fetch the missing base from here
# so callers never need a workflow-level FETCH_BASE_REF switch.
git_or_die fetch --no-tags origin "$base_name:refs/remotes/origin/$base_name" >/dev/null
base_ref_candidates="origin/$base_name $base_input $base_name"
fi

for candidate in $base_ref_candidates
do
if git rev-parse --verify --quiet "$candidate" >/dev/null
then
base_ref=$candidate
break
fi
done
}

[ -n "$base_ref" ] || die "unable to resolve commit comparison base"

base_sha=$(git_or_die merge-base HEAD "$base_ref")

validation_status=0

# A commit is exempt from subject validation only when ALL three conditions hold:
# 1. It has exactly two parents (is a merge commit).
# 2. Its committer is GitHub <noreply@github.com> (the trusted bot identity).
# 3. Its subject matches the canonical "Update branch" pattern.
# Ordinary contributor-created merge commits (different committer, or a
# subject that doesn't match the pattern) still go through subject validation.
github_merge_pattern="^Merge branch '[^']+' into .+"

while IFS= read -r -d '' entry
while IFS= read -r -d '' subject
do
IFS=$'\x01' read -r parents committer_name committer_email subject <<< "$entry"

if [ -n "$parents" ]
then
IFS=' ' read -ra parents_array <<< "$parents"
parent_count=${#parents_array[@]}
else
parent_count=0
fi

if [ "$parent_count" -eq 2 ] \
&& [ "$committer_name" = "GitHub" ] \
&& [ "$committer_email" = "noreply@github.com" ] \
&& [[ "$subject" =~ $github_merge_pattern ]]
then
continue
fi

"$validator" --subject "$subject"
status=$?

if [ "$status" -ne 0 ]
then
validation_status=$status
fi
done < <(git log -z --format='%P%x01%cn%x01%ce%x01%s' "$base_sha..HEAD")
done < <(git log -z --format='%s' "$base_sha..HEAD")

exit "$validation_status"
36 changes: 8 additions & 28 deletions lib/mix/tasks/ci.ex
Original file line number Diff line number Diff line change
@@ -1,47 +1,27 @@
defmodule Mix.Tasks.Ci do
@shortdoc "Runs the complete quality gate against app/"
@shortdoc "Compatibility alias for mix precommit"

@moduledoc """
#{@shortdoc}.

mix ci

Runs every check `.github/workflows/ci.yaml`'s `test` job runs on a pull
request, in the same order, so a green `mix ci` locally predicts a green
CI run — and CI itself calls this task, so there's one place to fix if
either ever breaks:
Delegates to `Mix.Tasks.Precommit`, which is the canonical full-repository
quality gate. Kept for backwards compatibility with scripts and CI
configurations that call `mix ci` directly.

1. `mix deps.get` — ensure deps are present
2. `mix hex.audit` — reject retired or vulnerable Hex packages
3. `mix deps.audit` — scan dependencies for known security advisories
4. `mix format --check-formatted` — code is formatted
5. `mix credo --strict` — static analysis (style, complexity, common bugs)
6. `mix usage_rules.sync --check` — usage rules are in sync with deps
(catches drift introduced by a dep bump without re-running the sync;
see #79)
7. `mix test` — all tests pass

All steps run inside `app/`.
See `mix help precommit` for the complete step list.
"""

use Mix.Task

alias RepoTasks.Shell

@impl Mix.Task
def run(argv) do
run(argv, &Shell.run!/3)
Mix.Tasks.Precommit.run(argv)
end

@doc false
def run(_argv, shell) do
shell.("mix", ["deps.get"], cd: "app")
shell.("mix", ["hex.audit"], cd: "app")
shell.("mix", ["deps.audit"], cd: "app")
shell.("mix", ["format", "--check-formatted"], cd: "app")
shell.("mix", ["credo", "--strict"], cd: "app")
shell.("mix", ["usage_rules.sync", "--check"], cd: "app")
shell.("mix", ["test"], cd: "app")
:ok
def run(argv, shell) do
Mix.Tasks.Precommit.run(argv, shell)
end
end
Loading