Tune precious
Use when adding, migrating to, or auditing `precious.toml` in a Perl repo (or any repo with a `typos.toml`). Generates the canonical config (perltidy + perlvars + omegasort + optional perlcritic + optional typos), consolidates `.perltidyrc`, edits `dist.ini` to drop Code::TidyAll, wires a CI lint job, and adds a self-installing `scripts/pre-commit` shell hook so `precious lint --staged` runs locally on commit. Idempotent across re-runs.From its SKILL.md
npx -y skills add oalders/kitchen-sink --skill tune-preciousAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
57.5 KB, as published. Nobody here has run it
Tune Precious
Overview
Six idempotent transforms that land precious as the canonical tidy/lint driver for a Perl repo (or any repo that has opted into typos):
- Generate or refresh
precious.tomlwithperltidy,perlvars,omegasort-gitignore,omegasort-stopwords, optionallyperlcritic, and optionallytypos. - Consolidate the
perltidyprofile to the hidden.perltidyrcand strip-b(precious manages backup mode). - Delete
Code::TidyAllconfig (.tidyallrc,tidyall.ini,.tidyall.d/ignore line). - Edit
dist.inito remove the tidyall plugin + prereqs via the bundle'sPluginRemoverand a trailing[RemovePrereqs]block. - Add a
.github/workflows/lint.ymljob that installsprecious+omegasort(+typoswhen wired) via ubi and runsprecious lint— incrementally (--git-diff-fromthe PR base) onpull_requestevents,--allon every other event. - Add a self-installing
scripts/pre-commitshell hook that runsprecious lint --stagedand blocks direct commits to the default branch, so contributors catch tidy/lint drift before pushing.
Core principle: apply the canonical recipe without changing the user's tidy/lint intent. Each transform is its own commit so any single change is revertable. Re-running on an already-tuned repo is a no-op.
Four modes the skill handles transparently:
| Mode | Trigger | What changes |
|---|---|---|
| Migrate | Repo has .tidyallrc, tidyall.ini, or [Test::TidyAll] in dist.ini | All six transforms apply |
| Greenfield | Perl repo, no tidyall config, no precious.toml | T1 + T2 (consolidate any visible perltidyrc) + T5 + T6 |
| Tune | precious.toml already exists | All transforms run as no-ops; surfaces drift between current config and canonical recipe |
| Typos-only | No Perl files, but typos.toml / _typos.toml / .typos.toml is present | T1 (typos block only) + T5; T2/T3/T4 are no-ops |
Worked example PR: libwww-perl/WWW-Mechanize-Cached#35 — the recipe was extracted from this PR.
Dispatch this skill to a subagent
When this skill is invoked, dispatch the work to a general-purpose subagent via the Agent tool. Do not run the six transforms inline in the caller's context.
Why:
- Each transform reads several files, computes a diff, writes the files, verifies with
precious config list/ TOML parse / YAML parse /dzil build --no-tgz, then stages and commits. Across six transforms and several files, that is a lot ofRead/Edit/Bashtool traffic — none of it useful to the caller's session. - The caller only needs the final summary line (
Applied N transforms across M files in K commits) and the list of commit SHAs. Everything else is intermediate state.
How to dispatch:
- Brief the subagent with this SKILL.md as its working spec — pass the path to the file or invoke the skill from inside the subagent.
- Tell the subagent the working directory.
- Require the subagent to report back, in under 200 words: the summary line, the per-transform commit SHAs, and any skipped transforms with reason.
- If a transform's verification fails, the subagent must stop and surface the failure rather than continuing or auto-reverting.
If the user explicitly asks to run inline (e.g. "do it here so I can watch"), honour that — the subagent dispatch is the default, not a hard requirement.
When to Use
- User asks to migrate from
Code::TidyAlltoprecious - User asks to set up
preciousfrom scratch in a Perl repo - User asks to audit / refresh an existing
precious.tomlagainst the canonical recipe - Repo contains
.tidyallrcortidyall.ini dist.iniincludesTest::TidyAll,[@Author::*]with a tidyall prereqs block, or[PerlCritic]you want to migrate behind precious
Skip when:
- Not a Perl repo (no
*.pm,*.pl,*.t,*.psgi, ordist.ini) and no typos config (typos.toml,_typos.toml,.typos.toml) at repo root - The user has deliberately customised
precious.tomlfor tools outside the canonical set (e.g. they runprettierover Mojolicious templates) — idempotency preserves their commands; only report drift in the six canonical commands.
Workflow
digraph tune_precious {
"Detect mode" [shape=box];
"Migrate, Greenfield, Tune, or Typos-only?" [shape=diamond];
"T1: precious.toml" [shape=box];
"Verify + commit T1" [shape=box];
"T2: consolidate .perltidyrc" [shape=box];
"Verify + commit T2" [shape=box];
"T3: delete tidyall config" [shape=box];
"Verify + commit T3" [shape=box];
"T4: edit dist.ini" [shape=box];
"Verify + commit T4" [shape=box];
"T5: add CI lint job" [shape=box];
"Verify + commit T5" [shape=box];
"T6: add pre-commit hook" [shape=box];
"Verify + commit T6" [shape=box];
"Report summary" [shape=box];
"Detect mode" -> "Migrate, Greenfield, Tune, or Typos-only?";
"Migrate, Greenfield, Tune, or Typos-only?" -> "T1: precious.toml";
"T1: precious.toml" -> "Verify + commit T1";
"Verify + commit T1" -> "T2: consolidate .perltidyrc";
"T2: consolidate .perltidyrc" -> "Verify + commit T2";
"Verify + commit T2" -> "T3: delete tidyall config";
"T3: delete tidyall config" -> "Verify + commit T3";
"Verify + commit T3" -> "T4: edit dist.ini";
"T4: edit dist.ini" -> "Verify + commit T4";
"Verify + commit T4" -> "T5: add CI lint job";
"T5: add CI lint job" -> "Verify + commit T5";
"Verify + commit T5" -> "T6: add pre-commit hook";
"T6: add pre-commit hook" -> "Verify + commit T6";
"Verify + commit T6" -> "Report summary";
}
Scope Detection
Before running, fingerprint the repo:
| Probe | Implication |
|---|---|
.tidyallrc or tidyall.ini present | Mode = migrate; T3 will delete |
dist.ini mentions Test::TidyAll, tidyall, or has a [Prereqs / *] block named for tidyall | Mode = migrate; T4 will rewrite |
precious.toml present | Mode = tune; T1 audits-only and reports drift |
Neither tidyall nor precious config present, but *.pm files exist | Mode = greenfield; T3 + T4 are no-ops |
typos.toml, _typos.toml, or .typos.toml present at repo root | T1 wires up typos; T5 adds crate-ci/typos to the ubi projects: list |
| No Perl files AND no typos config | Exit cleanly with "no Perl or typos config found" |
perlcritic detection (drives whether T1 wires up a perlcritic command):
.tidyallrc/tidyall.inihas[PerlCritic]→ auto-enforced; wire up.dist.inihas[Test::Perl::Critic]→ auto-enforced; wire up.dist.inihas[PerlCritic](Dist::Zilla plugin) → auto-enforced; wire up..perlcriticrcexists but no auto-enforcement → leave for manual use; do NOT wire up.
Rule: don't widen the scan set. If perlcritic wasn't enforced before, precious shouldn't enforce it after.
typos detection (drives whether T1 wires up a typos command):
typos.toml,_typos.toml, or.typos.tomlexists at repo root → wire up.- None of the above → do NOT wire up.
Rule: only wire typos when the user has already opted in by writing a typos config. Don't introduce a new lint dimension on a previously-clean tree.
The Six Transforms
1. Generate or refresh precious.toml
What: write precious.toml at repo root with the canonical commands. Skip blocks whose source file/binary isn't present (no .stopwords → skip omegasort-stopwords).
Canonical template:
[commands.perltidy]
type = "both"
include = ["**/*.{pl,pm,t,psgi}"]
cmd = ["perltidy", "--profile=$PRECIOUS_ROOT/.perltidyrc"]
lint-flags = ["--assert-tidy", "--no-standard-output", "--outfile=/dev/null"]
tidy-flags = ["--backup-and-modify-in-place", "--backup-file-extension=/"]
ok-exit-codes = [0]
lint-failure-exit-codes = [2]
[commands.perlvars]
type = "lint"
include = ["**/*.pm"]
cmd = ["perlvars"]
ok-exit-codes = [0]
lint-failure-exit-codes = [1]
[commands.omegasort-gitignore]
type = "both"
include = [".gitignore"]
cmd = ["omegasort", "--sort", "path", "--unique"]
lint-flags = ["--check"]
ok-exit-codes = [0]
lint-failure-exit-codes = [1]
[commands.omegasort-stopwords]
type = "both"
include = [".stopwords"]
cmd = ["omegasort", "--sort", "text", "--unique"]
lint-flags = ["--check"]
ok-exit-codes = [0]
lint-failure-exit-codes = [1]
# Wired only when perlcritic was already auto-enforced (see scope detection).
[commands.perlcritic]
type = "lint"
include = ["**/*.{pl,pm,t,psgi}"]
cmd = ["perlcritic", "--profile=$PRECIOUS_ROOT/.perlcriticrc"]
ok-exit-codes = [0]
lint-failure-exit-codes = [2]
# Wired only when a typos config exists at repo root (see scope detection).
[commands.typos]
type = "both"
include = ["**/*"]
invoke = "once"
path-args = "none"
cmd = ["typos"]
tidy-flags = ["--write-changes"]
ok-exit-codes = [0]
lint-failure-exit-codes = [2]
Key naming: precious uses kebab-case for all config keys (lint-flags, tidy-flags, ok-exit-codes, lint-failure-exit-codes, path-args, working-dir, ignore-stderr, …). Do NOT use snake_case (lint_flags, ok_exit_codes) — precious's serde deserializer does not register snake_case aliases and precious config list will fail with a parse error.
Why each block:
perltidyruns as both tidy and lint.--assert-tidymakes lint mode return non-zero when the file would change;--outfile=/dev/nullkeeps lint mode from rewriting. The--backup-file-extension=/trick deletes perltidy's.bakfile by writing it to/(a directory perltidy can't open), so tidy mode leaves a clean tree.perlvars(fromApp::perlvars) is the lint-time replacement forCode::TidyAll::Plugin::Test::Vars. Wire it up even when Test::Vars wasn't previously enforced — it's cheap, catches unused lexicals, and is the canonical successor.omegasort-gitignorekeeps.gitignoresorted and de-duplicated. Sort modepathunderstands directory hierarchy.omegasort-stopwordskeeps.stopwords(the Pod::Wordlist source forTest::Spelling) sorted and de-duplicated. Sort modetext.perlcriticis only emitted when previously enforced — see Scope Detection above.typos(crate-ci/typos) is a fast, content-agnostic spell checker.invoke = "once"+path-args = "none"lets typos walk the tree itself, applying its own.gitignore+extend-excludelogic from the user'stypos.toml.--write-changesis typos' in-place fixer (no path arg needed; works at the tree level). typos exits0clean and2on findings — matches the exit-code contract used by perltidy. Only emitted when a typos config exists at repo root — see Scope Detection above.
Idempotency rule (tune mode): if precious.toml already exists, parse it; for each canonical block, if the user's version matches the template exactly, leave it alone; if it differs, report drift (per-key diff) but do not auto-rewrite. Only insert blocks that are missing.
Verify: precious config list exits 0. If precious isn't on PATH, fall back to python3 -c 'import tomllib; tomllib.loads(open("precious.toml","rb").read().decode())' for a syntax check.
dist.ini exclusion (dzil repos only):
If dist.ini exists at repo root, the precious.toml you just wrote is a developer-only config and must not ship in the CPAN tarball — exactly the same problem T6 handles for scripts/pre-commit. precious.toml is a root config file that never belongs in the dist, so prefer exclude_filename; fall back to [PruneFiles] when [Git::GatherDir] is bundle-owned (the common @Author::* case, where you can't pass exclude_filename from dist.ini).
- If
dist.inihas an explicit, configurable[Git::GatherDir](or[GatherDir]) block, addexclude_filename = precious.tomlto it. - Otherwise (the
[Git::GatherDir]is owned by an[@Author::*]bundle), appendfilename = precious.tomlto the[PruneFiles]block. If no[PruneFiles]block exists, add one; do NOT introduce a second[PruneFiles]section if one already exists. - Idempotency: if
precious.tomlis already excluded — via anexclude_filename = precious.toml, a[PruneFiles] filename = precious.toml, or a coveringmatch =regex (e.g.match = \.toml$) — NO-OP on the dist.ini edit. - Skip this sub-step entirely if
dist.inidoes not exist (greenfield / typos-only / non-dzil Perl repo). - Bundle the dist.ini edit into the same T1 commit as
precious.toml— they're a logical unit.
After editing dist.ini, re-run dzil build --no-tgz and confirm precious.toml is absent from the build output (find <DistName>-*/ -name precious.toml returns nothing). Then revert regenerated build artefacts the way T4 does (git checkout -- META.json Makefile.PL README.md Changes; rm -rf <DistName>-*/ <DistName>-*.tar.gz).
See the working-with-dist-zilla skill's §7 for the canonical exclusion list and the exclude_filename vs [PruneFiles] decision.
2. Consolidate .perltidyrc
What: ensure the perltidy profile lives at the hidden .perltidyrc (what perltidy itself defaults to). Three sub-cases:
| State | Action |
|---|---|
Only .perltidyrc exists | Strip -b if present (precious manages backups via --backup-file-extension=/). Otherwise no-op. |
Only perltidyrc exists | git mv perltidyrc .perltidyrc. Strip -b. |
| Both exist | Diff them. The visible perltidyrc is usually the tidyall-managed copy, which typically lacks -b (tidyall handles backups itself); the hidden .perltidyrc is what humans use for manual perltidy runs and is more likely to carry -b. Keep .perltidyrc (after stripping -b if present); git rm perltidyrc. If the diff shows non-trivial divergence beyond -b, stop and surface the diff for human resolution. |
| Neither exists | Greenfield: write a minimal .perltidyrc (-pbp, -nst, -se is a common starting point) — but only if the user explicitly asks for greenfield setup. Otherwise skip and warn. |
Why strip -b: in a tidyall world -b told perltidy to write a backup before in-place modification, and tidyall cleaned it up. Under precious, the --backup-file-extension=/ trick in T1's tidy-flags does the cleanup. Leaving -b in the profile creates duplicate-backup confusion.
Verify: perltidy --version exits 0 with the resolved profile loaded (run perltidy -DEBUG -dump-options and grep for the profile path).
dist.ini exclusion (dzil repos only): .perltidyrc (and any visible perltidyrc) is likewise a developer-only config that must not ship to CPAN. In a dzil repo, exclude it the same way T1 excludes precious.toml — prefer exclude_filename on an explicit [Git::GatherDir], else append to [PruneFiles]; NO-OP if it's already excluded. See T1's dist.ini-exclusion sub-step and the working-with-dist-zilla skill's §7 for the mechanism and canonical list. Bundle the dist.ini edit into the same T2 commit.
3. Delete Code::TidyAll config files
What:
git rm .tidyallrc tidyall.ini(only the ones present).- Edit
.gitignore: remove the.tidyall.d/line (andtidyall.d/if present without the leading dot). The cache directory is gone with the tidyall plugin. - If
.tidyall.d/itself exists in the working tree as a cached directory,git rm -r .tidyall.d/.
Verify: git ls-files | grep -E '(^|/)tidyall' || true returns nothing.
Critical: stage the deletions (git add -u) before running dzil build in T4. Dist::Zilla::Plugin::Git::GatherDir reads git ls-files; unstaged deletions mean ghost files still appear in the list, and dzil errors with file does not exist.
4. Edit dist.ini
Skip this transform entirely if there is no dist.ini.
4a.i. Read the bundle source for plugin/prereqs-block names.
dist.ini usually begins with a single [@Author::Foo] line. Find the bundle source:
perldoc -lm Dist::Zilla::PluginBundle::Author::Foo
Read the configure / bundle_config sub. You're looking for:
- The plugin instance that adds the tidyall test — typically
Test::TidyAll. - The named
[ 'Prereqs' => 'NAME' => { ... } ]block that lists tidyall modules — in@Author::OALDERSthis is'Modules for use with tidyall'. Other bundles pick different names.
4a.ii. Cross-check the moniker against META.json before editing.
Bundle-author monikers are the highest-risk free-text part of this transform. Capture the pre-edit baseline:
dzil build --no-tgz
grep -A2 '"develop"' <DistName>-*/META.json | head -30
grep -E '(Code::TidyAll|Test::TidyAll|Test::Vars)' <DistName>-*/META.json
If the prereqs you see in the develop section don't match the moniker you guessed from the bundle source, fix the moniker before proceeding. After T4 lands, re-running the grep should return nothing.
4b. Use the bundle's PluginRemover:
[@Author::Foo]
-remove = Test::TidyAll
-remove = Modules for use with tidyall
The first form (Test::TidyAll) is a class-name remove and is reliable — it matches Dist::Zilla::Plugin::Test::TidyAll. The second form is an instance-name remove against the named Prereqs block; the moniker is bundle-specific — if your bundle calls it something else, use that exact string.
4c. Append [RemovePrereqs] after the bundle:
[RemovePrereqs]
remove = Code::TidyAll
remove = Code::TidyAll::Plugin::SortLines::Naturally
remove = Code::TidyAll::Plugin::Test::Vars
remove = Code::TidyAll::Plugin::UniqueLines
remove = Test::Code::TidyAll
remove = Test::Vars
remove = Pod::Wordlist
remove = Parallel::ForkManager
Two things to remember about [RemovePrereqs]:
- Plugin order matters.
[RemovePrereqs]only sees prereqs added by plugins listed earlier indist.ini. The bundle line goes first,[RemovePrereqs]second. remove =not-remove =. The leading-dash form is for PluginBundlePluginRemover; inside[RemovePrereqs], Config::MVP raisesmultiple values given for property -removeon the dash form.
4d. Add develop-phase prereq for App::perlvars:
[Prereqs / DevelopRequires]
App::perlvars = 0
Append to an existing [Prereqs / DevelopRequires] block if one already exists. Skip this sub-step entirely if App::perlvars already appears under prereqs.develop.requires in the pre-edit META.json (some bundles inject it themselves; a duplicate is harmless but noisy).
4e. Run dzil build --no-tgz and verify.
dzil build --no-tgz
diff -u cpanfile <DistName>-*/cpanfile # should be empty after edits
grep -E '(Code::TidyAll|Test::TidyAll|Test::Vars|Pod::Wordlist|Parallel::ForkManager)' <DistName>-*/META.json
# above grep should return nothing
4f. Clean up build artefacts before committing.
dzil build regenerates META.json, Makefile.PL, README.md, and friends via [CopyFilesFromBuild]. For non-release commits, these are noise — revert them:
git checkout -- META.json Makefile.PL README.md Changes
rm -rf <DistName>-*/ <DistName>-*.tar.gz
Commit only dist.ini and cpanfile. The next release commit regenerates the rest cleanly.
(See the working-with-dist-zilla skill for the broader rationale.)
5. Add CI lint job
What: add (or update) .github/workflows/lint.yml:
Before emitting the file, resolve the default branch:
git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@'
Substitute the resolved name (or fall back to main) into the branches: list below. Repos still on master need master here, or the push trigger silently never fires.
Before emitting the file, verify every action version. The @-refs in the template below were correct when this skill was written, but action tags move and not every action ever reaches v1. For each uses: line, resolve a tag that actually exists before writing it:
gh api repos/<owner>/<repo>/releases/latest --jq .tag_name # newest release, if the repo cuts releases
gh api repos/<owner>/<repo>/tags --jq '.[].name' # all tags, for repos with no "latest" release
Never assume @v1. oalders/install-ubi-action, for example, has never tagged a v1 — its latest is in the v0.0.x line, which is why the template pins @v0.0.6. When an action only publishes v0.0.x tags, pin the exact latest v0.0.x; when it publishes a moving major tag (actions/checkout → v6), that major tag is fine.
name: lint
on:
push:
branches:
- main # ← replace with resolved default branch
pull_request:
merge_group:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
precious:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history so `precious lint --git-diff-from <base>` can resolve the merge base
- uses: shogo82148/actions-setup-perl@v1
with:
perl-version: "5.42"
- uses: oalders/[email protected] # ← verify the latest tag before writing (see above)
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
projects: |
houseabsolute/precious
houseabsolute/omegasort
crate-ci/typos
- uses: perl-actions/install-with-cpm@v2
with:
install: |
App::perlvars
Perl::Tidy
sudo: false
- name: precious lint
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
git fetch origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
precious lint -q --git-diff-from "origin/$BASE_REF"
else
precious lint -q --all
fi
Conditional lines:
- Add the
crate-ci/typosline toprojects:only when T1 wired the[commands.typos]block. Otherwise omit it so the install step doesn't pull a tool the lint run won't use. - Skip the
shogo82148/actions-setup-perlandperl-actions/install-with-cpmsteps entirely in typos-only mode (no Perl files in the repo means there are no CPAN tools to install).
Why the lint command is gated on github.event_name:
The step lints incrementally on pull requests (--git-diff-from the PR base) and --all on every other event (push, merge_group, workflow_dispatch, scheduled). Two failure modes this avoids:
--allon every PR is the wrong default. It re-lints the whole tree, so unrelated pre-existing lint debt fails an otherwise-clean PR, and it's slower than checking only what the PR changed.--allis right forpush/merge_group/scheduled runs — where you do want the whole tree green — not for PRs.- Gate on
github.event_name, never ongithub.ref.github.base_refis populated only forpull_requestevents. In amerge_groupevent the ref isrefs/heads/gh-readonly-queue/<branch>/pr-...andbase_refis empty, so a ref-based gate falls through to the incremental arm and runsprecious lint -q --git-diff-from origin/→git diff … origin/→fatal: ambiguous argument 'origin/': unknown revision, exit 128, blocking the merge queue. Gating ongithub.event_name == "pull_request"keeps the incremental arm scoped to the only event wherebase_refexists. - Fetch the base branch by
github.base_ref, not a hardcoded default branch. Fetching$BASE_REF:refs/remotes/origin/$BASE_REFmakes the diff target exist locally for a PR against any base (a release/maintenance branch, not just the default). Fetching a fixed default branch would leaveorigin/<base_ref>unresolved for PRs that don't target it, and the diff would fail the same way. - Pass the GHA context through
env:, never interpolate${{ … }}straight intorun:.github.base_refis a branch name, and although GitHub's ref-name validation rejects shell metacharacters today, a template that substitutes${{ github.base_ref }}as literal text into the script body is only safe because of that external validation — a"in the value would close the template's quotes and the rest would execute. Binding the context toEVENT_NAME/BASE_REFunderenv:and referencing"$EVENT_NAME"/"$BASE_REF"makes the step safe by construction: the shell receives the values from the environment and never re-parses them as script. This is GitHub's documented hardening guidance for untrusted${{ }}values inrun:steps. fetch-depth: 0onactions/checkout.--git-diff-from <base>needs the merge base between the PR head and the base branch. The default shallow clone (fetch-depth: 1) can omit that commit, making the diff fail or over-report; a full-history checkout resolves it regardless of how precious computes the diff. Theelse(--all) arm doesn't need history, but the single checkout serves both arms.merge_group:is in the trigger list so the merge-queue arm is real. When a repo enables a merge queue, GitHub firesmerge_groupevents; theelsearm lints--allthere (a full-tree green gate before the merge lands). The trigger is inert on repos without a merge queue, so it costs nothing to include and keeps theevent_namegate meaningful.schedule:is intentionally omitted — it needs a cron the maintainer must choose; theelsearm already handles it if they add one.
Why this shape:
preciousis the single entry point — no per-tool step.oalders/install-ubi-actioninstalls precompiled binaries (precious + omegasort + typos are Rust/Go); skips compile time. The input isprojects:(newline-delimited list ofowner/reposlugs passed toubi --project). Always passGITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}underwith:— ubi pulls binaries through the GitHub releases API, and without an authenticated token the unauthenticated rate limit fails the install step intermittently.install-with-cpm@v2installs the two CPAN tools precious shells out to.- Perl 5.42 (the current matrix max) is enough for the lint job; nothing in this job exercises older Perls.
branches: [<default>]+ workflow-levelconcurrency:block — same conventions astune-perl-ci. Resolve the default branch with thegit symbolic-refcommand shown above and substitute before writing the file.
Lint job and build jobs: the generated lint.yml is a standalone workflow with no build job, so the precious job declares no needs:. If you instead add a precious-lint job to a workflow that already contains a build job, that lint job must declare needs: <build-job> — running lint against a tree whose build is already failing wastes a runner and clutters the failure signal.
Idempotency: if .github/workflows/lint.yml already exists, parse it and classify the lint step:
- Already the gated form (an
event_name == "pull_request"branch running--git-diff-from, with an--allelse arm): leave it alone — no-op. - Plain unconditional
precious lint --all(the shape earlier versions of this skill emitted): this is now drift, not canonical. Upgrade it in place to the gatedgithub.event_namepattern above, preserving the rest of the workflow, and commit. If you cannot edit it safely without disturbing surrounding customisation, surface the drift line (T5: lint step runs unconditional 'precious lint --all'; upgrade to the event_name-gated incremental pattern) and skip rather than guess. - A ref-gated incremental pattern (
if [ "${{ github.ref }}" = … ]choosing between--alland--git-diff-from): flag as drift — this is the merge-queue-breaking shape (base_refis empty outsidepull_request). Upgrade it to gate ongithub.event_name, or surface the drift line and skip if it's entangled with other customisation. - Any other shape (e.g. installs precious from source, per-tool steps): surface the drift and skip — do not overwrite a hand-rolled workflow.
Verify: python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/lint.yml"))' exits 0; the lint step gates on github.event_name (not github.ref), binds the GHA context to env: (EVENT_NAME / BASE_REF) rather than interpolating ${{ … }} into run:, and its incremental arm fetches origin/$BASE_REF before diffing against it; the checkout step sets fetch-depth: 0; every uses: ref in the file resolves to a tag that exists (gh api repos/<owner>/<repo>/git/refs/tags/<ref> returns the ref, not a 404).
6. Add scripts/pre-commit hook for precious lint
What: add a checked-in shell script at scripts/pre-commit that runs precious lint --staged and blocks direct commits to the default branch. The script self-installs via scripts/pre-commit --init, which symlinks the repo's pre-commit hook to it (resolved via git rev-parse --git-path hooks so it works in plain repos and linked worktrees). No external hook framework (no pre-commit Python package, no lefthook).
Canonical script:
#!/bin/sh
# Pre-commit hook: enforce `precious lint` on staged files and
# block direct commits to the default branch.
#
# Install (run once per clone):
# scripts/pre-commit --init
set -eu
if [ "${1:-}" = "--init" ]; then
repo_root=$(git rev-parse --show-toplevel)
# Resolve the hooks dir via git so it works in plain repos, linked
# worktrees (where `.git` is a file pointing into the common git dir),
# and setups that override `core.hooksPath`. Run with `-C "$repo_root"`
# so the output is anchored under the worktree root regardless of
# which subdirectory the contributor invoked `--init` from.
hooks_dir=$(git -C "$repo_root" rev-parse --git-path hooks)
case "$hooks_dir" in
/*) ;;
*) hooks_dir="$repo_root/$hooks_dir" ;;
esac
hook_path="$hooks_dir/pre-commit"
target="$repo_root/scripts/pre-commit"
if [ -e "$hook_path" ] && [ ! -L "$hook_path" ]; then
echo "ERROR: $hook_path exists and is not a symlink." >&2
echo "Move or remove it, then re-run scripts/pre-commit --init." >&2
exit 1
fi
chmod +x "$target"
ln -sf "$target" "$hook_path"
echo "Installed pre-commit hook: $hook_path -> $target"
exit 0
fi
# Anchor to the working-tree root before doing anything else. Git exports
# GIT_DIR into the hook environment, so `--show-toplevel` resolves the right
# tree even when a wrapper invokes `git commit` from a directory outside (or
# beside) the repo. Without this, `precious lint --staged` runs from that
# foreign cwd, fails to find precious.toml, and the hook breaks.
cd "$(git rev-parse --show-toplevel)"
# Block direct commits to the default branch.
default_branch="main" # ← skill substitutes the resolved default branch here
branch=$(git symbolic-ref --short HEAD 2>/dev/null || true)
if [ "$branch" = "$default_branch" ]; then
echo "ERROR: Direct commits to '$default_branch' branch are not allowed." >&2
echo "Please create a feature branch instead:" >&2
echo " git checkout -b feature/your-feature-name" >&2
exit 1
fi
# Run precious lint on staged files
if ! precious lint -q --staged; then
echo "pre-commit hook failed: precious lint found issues with staged files" >&2
echo "Please run 'precious tidy -q --staged' and try again" >&2
exit 1
fi
Resolve the default branch before writing the script. Use the same recipe as T5:
git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@'
Substitute the resolved name (or fall back to main) into default_branch="...". A repo on master with default_branch="main" would silently never trigger the guard.
Why this shape:
- Native POSIX
sh, no framework. Contributors don't need to install thepre-commitPython package, lefthook, or husky. The only requirement ispreciousonPATH— same as CI. scripts/pre-commit --initself-installs. One command per clone. The skill mentions it in the commit body; no extra setup script, Makefile target, or README surgery required (leave docs to the maintainer's judgment).- Symlink, not copy. Edits to
scripts/pre-commitpropagate to the installed hook immediately. A copy would let the two drift silently. git -C "$repo_root" rev-parse --git-path hooksfor the install path. Resolves the correct hooks dir in plain repos, linked worktrees (where.gitis a file pointing into the common git dir), and setups that overridecore.hooksPath. Hardcoding$repo_root/.git/hooksbreaks in worktrees. The-C "$repo_root"is load-bearing: without it,--git-pathreturns a path relative to the contributor's cwd, which lands the symlink in the wrong place when--initis run from a subdirectory. The symlink target is the absolute path toscripts/pre-commitso it resolves the same regardless of where the hooks dir actually lives.- Default-branch guard. Mirrors the same "no direct commits to main" policy that CI's branch-protection rules enforce server-side. Catches it before the push, with a clearer error message.
precious lint -q --staged, not--all. Fast on every commit; matches what's about to land.--allis CI's job (T5).- Lint, not tidy. A hook that rewrites files behind the contributor is surprising. Lint fails loudly; the contributor runs
precious tidythemselves and re-stages — same UX as CI failures. || trueongit symbolic-ref.set -euwould otherwise abort the script on a detached HEAD (rebase, bisect). The branch guard then seesbranch="", which never matchesdefault_branch, so the lint check still runs.cd "$(git rev-parse --show-toplevel)"before the checks. Git runs hooks from the worktree root in the common case, but wrapper scripts that invokegit commitfrom a directory outside (or beside) the repo — relying onGIT_DIR/GIT_WORK_TREEin the environment — leave the hook's cwd outside the tree.precious lint --stagedthen walks up from a foreign cwd, never findsprecious.toml, and the hook fails. Thecdanchors every subsequent command to the repo root;--show-toplevelresolves correctly because git exportsGIT_DIRinto the hook environment.
Activation: writing scripts/pre-commit does NOT enable the hook. Each contributor runs scripts/pre-commit --init once after clone. Mention this in the commit body.
Idempotency:
- If
scripts/pre-commitalready exists andgrep -q 'precious lint' scripts/pre-commitreturns true, NO-OP (assume the user's version is canonical-equivalent; do not auto-rewrite). - If
scripts/pre-commitexists but does NOT mentionprecious lint, surface a drift line (T6 skipped: scripts/pre-commit exists but does not invoke precious lint; review manually) and skip — don't overwrite a hand-rolled hook. - If
scripts/pre-commitdoes not exist, write it.
Other-framework / other-location detection — skip T6 with a drift line if any of these is present:
.pre-commit-config.yaml(Pythonpre-commitframework)lefthook.ymlor.lefthook.ymlat repo root.husky/directory at repo root.githooks/pre-commit(canonical forcore.hooksPath-style setups — same intent, different home)core.hooksPathgit config set to a non-default value (git config --get core.hooksPath) where that path contains an existingpre-commitscript
Surface a drift entry naming the conflicting framework or path; do NOT write scripts/pre-commit. Two competing hook setups is worse than none.
dist.ini exclusion (dzil repos only):
If dist.ini exists at repo root, scripts/pre-commit is a developer-only file and must not ship in the CPAN tarball. Add (or extend) a [PruneFiles] block in dist.ini so dzil drops it from the manifest:
[PruneFiles]
filename = scripts/pre-commit
- If
[PruneFiles]already exists, appendfilename = scripts/pre-committo it. Do NOT introduce a second[PruneFiles]section —Config::MVPaccepts it but the duplicate-section noise is confusing. - If an existing
[PruneFiles]block already listsscripts/pre-commit(exact match) or amatch =regex that covers it (e.g.match = ^scripts/), NO-OP on the dist.ini edit. - Skip this sub-step entirely if
dist.inidoes not exist (greenfield / typos-only / non-dzil Perl repo). - Bundle the dist.ini edit into the same T6 commit as
scripts/pre-commit— they're a logical unit, and the exclusion is meaningless without the file it excludes.
After editing dist.ini, re-run dzil build --no-tgz and confirm scripts/pre-commit is absent from the build output (find <DistName>-*/ -path '*/scripts/pre-commit' returns nothing). Then revert regenerated build artefacts the same way T4 does (git checkout -- META.json Makefile.PL README.md Changes; rm -rf <DistName>-*/ <DistName>-*.tar.gz) — commit only dist.ini and scripts/pre-commit.
Why prune, not gitignore: dzil's Git::GatherDir reads git ls-files, and the script is intentionally tracked (contributors need it on clone). Gitignoring it would untrack it and break --init. [PruneFiles] is the right lever: tracked in git, omitted from the dist.
Verify:
sh -n scripts/pre-commitexits 0 (POSIX syntax check).[ -x scripts/pre-commit ]— executable bit set (the skill mustchmod +xbefore staging; otherwise--init's symlink target won't be runnable).grep -q 'default_branch="' scripts/pre-commit && ! grep -q 'default_branch="main" # ←' scripts/pre-commitif the resolved branch was anything other thanmain— ensures the placeholder substitution actually happened.grep -q 'precious lint' scripts/pre-commit— the precious invocation survived.- If
dist.iniexists:dzil build --no-tgzexits 0 ANDfind <DistName>-*/ -path '*/scripts/pre-commit'returns nothing — confirms the prune landed.
Algorithm
1. Detect mode (migrate / greenfield / tune / typos-only). Exit clean if no Perl files AND no typos config.
2. For each transform T1..T6 in order, classify into one of:
- NO-OP: change set is empty; skip silently.
- APPLY: change set is non-empty AND it is safe to write
(the transform's per-step rules don't say "report drift").
Apply, verify, stage, commit.
- DRIFT: change set is non-empty BUT the transform's per-step rules
say "surface drift and skip" (e.g. T1 in tune mode when a
block exists but differs from canonical; T5 when an
existing lint.yml uses a different shape). Record the
per-key diff for the final report; do NOT write or commit.
On verification failure during APPLY: STOP, leave files unstaged,
surface the error.
3. Report: "Applied N transforms across M files in K commits; surfaced D drift items."
Each transform produces at most one commit. NO-OP and DRIFT outcomes produce no commit; DRIFT outcomes are accumulated and printed at the end of the report.
Suggested commit subjects:
| # | Commit subject |
|---|---|
| 1 | precious: add canonical config (greenfield) / precious: refresh canonical config (migrate) |
| 2 | perltidy: consolidate profile to .perltidyrc and drop -b |
| 3 | tidyall: delete config files and ignore entries |
| 4 | dist.ini: drop Code::TidyAll plugin and prereqs |
| 5 | ci: add precious lint job |
| 6 | hooks: add scripts/pre-commit for precious lint |
Every commit ends with a blank line then the Co-authored-by trailer from docs/attribution.md
(display name = the model running at runtime).
Worked Example
Repo: a @Author::OALDERS-shaped Perl distribution mid-migration.
Before
dist.ini
perltidyrc # visible duplicate, tidyall-managed (no -b)
.perltidyrc # canonical, contains -b from manual perltidy use
.tidyallrc
.tidyall.d/ # cache, gitignored
.gitignore # includes ".tidyall.d/"
.github/workflows/dzil-build-and-test.yml
dist.ini:
name = Acme-Foo
author = Olaf Alders <[email protected]>
version = 1.23
[@Author::OALDERS]
.tidyallrc:
[PerlTidy]
select = **/*.{pl,pm,t}
argv = --profile=$ROOT/perltidyrc
[Test::Vars]
select = lib/**/*.pm
[SortLines::Naturally]
select = .gitignore
[UniqueLines]
select = .stopwords
After
Six commits land:
-
precious: add canonical config— newprecious.tomlat repo root with four canonical blocks (noperlcriticblock, since the tidyall config didn't have[PerlCritic]; notyposblock, since the repo has no typos config). Because this is a dzil repo and the[@Author::OALDERS]bundle owns[Git::GatherDir], the same commit also keepsprecious.tomlout of the dist by appendingfilename = precious.tomlto a[PruneFiles]block —exclude_filenameisn't reachable fromdist.iniwhen the bundle owns the gather step (seeworking-with-dist-zilla§7). Its commit message ends with a blank line then the trailer fromdocs/attribution.md:precious: add canonical config Co-authored-by: Claude Opus 4.8 <[email protected]> -
perltidy: consolidate profile to .perltidyrc and drop -b—perltidyrcremoved (it was the tidyall-managed copy and matched.perltidyrcmodulo formatting);.perltidyrckept with-bstripped, and kept out of the dist the same way —filename = .perltidyrcappended to the same[PruneFiles]block. -
tidyall: delete config files and ignore entries—.tidyallrcremoved;.gitignoreno longer mentions.tidyall.d/. -
dist.ini: drop Code::TidyAll plugin and prereqs— bundle line +[RemovePrereqs]+[Prereqs / DevelopRequires]:[@Author::OALDERS] -remove = Test::TidyAll -remove = Modules for use with tidyall [RemovePrereqs] remove = Code::TidyAll remove = Code::TidyAll::Plugin::SortLines::Naturally remove = Code::TidyAll::Plugin::Test::Vars remove = Code::TidyAll::Plugin::UniqueLines remove = Test::Code::TidyAll remove = Test::Vars remove = Pod::Wordlist remove = Parallel::ForkManager [Prereqs / DevelopRequires] App::perlvars = 0 -
ci: add precious lint job— new.github/workflows/lint.ymlas specified above. -
hooks: add scripts/pre-commit for precious lint— newscripts/pre-commit(executable bit set) withdefault_branch="main"substituted from the resolved default branch, plusfilename = scripts/pre-commitappended to the same[PruneFiles]block that T1/T2 started, so the hook script doesn't ship in the CPAN tarball. By now the block lists all three dev-only files:[PruneFiles] filename = precious.toml filename = .perltidyrc filename = scripts/pre-commitCommit body notes that contributors run
scripts/pre-commit --initonce per clone.
cpanfile is regenerated by T4's dzil build and is the only build-output file committed alongside dist.ini; META.json, Makefile.PL, README.md are reverted.
Verification
After each transform, before committing:
| # | Verification |
|---|---|
| 1 | precious config list exits 0; otherwise python3 -c 'import tomllib; tomllib.loads(open("precious.toml","rb").read().decode())' |
| 2 | grep -E '(^|\s)(-b|--backup-and-modify-in-place)(\s|$)' .perltidyrc returns nothing; git ls-files perltidyrc returns nothing |
| 3 | git ls-files | grep -E '(^|/)tidyall' returns nothing; .gitignore has no tidyall line |
| 4 | dzil build --no-tgz exits 0; grep -E '(Code::TidyAll|Test::Vars|Pod::Wordlist|Parallel::ForkManager)' <DistName>-*/META.json returns nothing |
| 5 | python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/lint.yml"))' exits 0; the lint step gates on github.event_name (not github.ref), binds EVENT_NAME/BASE_REF via env: (no ${{ … }} in run:), has a --git-diff-from origin/$BASE_REF PR arm and an --all else arm, and checkout uses fetch-depth: 0; every uses: ref resolves to an existing tag |
| 6 | sh -n scripts/pre-commit exits 0; [ -x scripts/pre-commit ]; grep -q 'precious lint' scripts/pre-commit; if dist.ini exists, dzil build --no-tgz exits 0 and find <DistName>-*/ -path '*/scripts/pre-commit' returns nothing |
Do not auto-revert on failure — that hides bugs in the skill. Stop and surface the failure for human inspection.
Common Mistakes
| Mistake | Why it's wrong | Fix |
|---|---|---|
dzil build errors with file does not exist after T3 | Git::GatherDir reads git ls-files; unstaged deletions still show up | git add -u (or git rm) the tidyall files before running dzil build in T4 |
[RemovePrereqs] placed before the bundle | [RemovePrereqs] only sees prereqs from plugins listed earlier | Move [RemovePrereqs] after the [@Author::*] line |
[RemovePrereqs] uses -remove = | Dash-form is PluginBundle syntax; Config::MVP raises multiple values given for property -remove | Use remove = (no dash) inside [RemovePrereqs] |
Committing regenerated META.json / Makefile.PL / README.md after T4 | These are build-output noise; the next release commit regenerates cleanly | Revert them; commit only dist.ini and cpanfile |
Leaving -b in .perltidyrc | precious manages backups via --backup-file-extension=/; -b produces stray backup files | Strip -b in T2 |
Keeping both perltidyrc and .perltidyrc | perltidy reads .perltidyrc by default; the visible duplicate is the tidyall-managed copy | Delete the visible duplicate; keep .perltidyrc |
Wiring up perlcritic in precious.toml when it wasn't enforced before | Widens the scan set; suddenly fails a previously-clean tree on style nits | Only add the perlcritic block when tidyall/dist.ini already auto-enforced it (see Scope Detection for the four distinct cases — .perlcriticrc alone is NOT enforcement) |
Wiring up typos in precious.toml without a typos config | Introduces a new lint dimension on a previously-clean tree; users get noisy false positives | Only emit the typos block when typos.toml / _typos.toml / .typos.toml exists at repo root |
Adding crate-ci/typos to the ubi projects: list without wiring the typos command | CI installs a tool the lint run won't use; wastes a download and confuses readers | Add crate-ci/typos to T5 only when T1 wired the typos block |
Using tools: for the ubi-action input | oalders/install-ubi-action reads inputs.projects, not inputs.tools — unknown inputs are silently dropped, so no binaries get installed and precious lint --all fails on missing commands | Use projects: (the documented input name) |
Pinning an action to @v1 (or any tag) without checking it exists | Not every action reaches v1 — oalders/install-ubi-action's latest is v0.0.6. A non-existent ref fails the workflow on every run | Resolve a real tag with gh api repos/<owner>/<repo>/releases/latest (or /tags) before writing each uses: line |
Omitting GITHUB_TOKEN from the install-ubi-action step | ubi hits the GitHub releases API unauthenticated; the lower rate limit fails the install step intermittently | Pass GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} under the action's with: |
Adding a precious-lint job to a workflow that has a build job, with no needs: | Lint burns a runner while the build is already broken; the failure signal is muddied | Give the lint job needs: <build-job> whenever it shares a workflow with a build job |
Running precious lint --all unconditionally on every event | Re-lints the whole tree on every PR, so unrelated pre-existing lint debt fails an otherwise-clean PR, and it's slower than checking only the diff | Gate on github.event_name == "pull_request" (bind it to EVENT_NAME via env:): incremental --git-diff-from "origin/$BASE_REF" on PRs, --all otherwise |
Gating the incremental arm on github.ref instead of github.event_name | github.base_ref is empty outside pull_request, so in a merge_group event the diff arm runs --git-diff-from origin/ → fatal: ambiguous argument 'origin/', exit 128, blocking the merge queue | Gate on github.event_name; only pull_request events populate base_ref |
Fetching a hardcoded default branch instead of github.base_ref before the incremental diff | A PR targeting a non-default base (release/maintenance branch) leaves origin/<base_ref> unresolved, and --git-diff-from fails | Fetch "$BASE_REF:refs/remotes/origin/$BASE_REF" — the actual PR base |
Interpolating ${{ github.base_ref }} directly into the lint step's run: block | Untrusted context substituted as literal script text is only safe because GitHub's ref-name validation happens to reject shell metacharacters — a template that relies on that external check is fragile | Bind BASE_REF/EVENT_NAME under env: and reference "$BASE_REF" / "$EVENT_NAME"; safe by construction (GitHub's documented hardening guidance) |
Leaving actions/checkout at the default shallow depth | --git-diff-from <base> needs the merge base; a fetch-depth: 1 clone can omit it, so the diff fails or over-reports on long-lived branches | Set fetch-depth: 0 on the checkout step |
Adding App::perlvars to runtime prereqs | It's a develop-only tool | Use [Prereqs / DevelopRequires] |
| Bundling all six transforms into one commit | Can't revert one transform without the others | One commit per transform |
Overwriting an existing .github/workflows/lint.yml | The user may have a custom lint shape | Detect drift, surface it, skip — don't overwrite hand-rolled workflows |
| Guessing the tidyall prereqs-block moniker | Bundle authors pick these ad-hoc; 'Modules for use with tidyall' is @Author::OALDERS-specific | perldoc -lm Dist::Zilla::PluginBundle::Author::Foo and read the source |
Hardcoding default_branch="main" in scripts/pre-commit on a master repo | The branch guard silently never fires; direct commits to master slip through | Resolve via git symbolic-ref --short refs/remotes/origin/HEAD before writing T6 — same recipe as T5 |
Forgetting chmod +x scripts/pre-commit before staging | --init creates a symlink to a non-executable file; git commit skips the hook silently | chmod +x scripts/pre-commit before git add in T6; verify with [ -x scripts/pre-commit ] |
Writing scripts/pre-commit when .pre-commit-config.yaml or lefthook.yml already exists | Two competing hook frameworks; contributors don't know which one is authoritative | Detect competing frameworks in T6's pre-write check; surface drift and skip |
Adding scripts/pre-commit to a dzil repo without pruning it | Git::GatherDir picks it up and ships a dev-only hook in the CPAN tarball; CPAN users get a useless script in their installed share dir | Append filename = scripts/pre-commit to [PruneFiles] in dist.ini as part of T6, then re-run dzil build --no-tgz and confirm the file is absent from the build output |
Writing precious.toml / .perltidyrc to a dzil repo without excluding them | Git::GatherDir sweeps the generated dev configs into the CPAN tarball, where they're dead weight | Exclude in T1/T2: exclude_filename on an explicit [Git::GatherDir], else append to [PruneFiles]; rebuild and confirm absence (see working-with-dist-zilla §7) |
Gitignoring scripts/pre-commit instead of pruning it | --init symlinks to the file; untracking it means contributors don't get it on clone and the hook never installs | Keep it tracked; use [PruneFiles] to drop it from the dist only |
Hardcoding $repo_root/.git/hooks/pre-commit as the install path in --init | In a linked worktree .git is a file, not a directory, so the path doesn't exist; the symlink fails or lands in the wrong place. Also ignores core.hooksPath | Use $(git rev-parse --git-path hooks)/pre-commit and an absolute symlink target |
| Runtime hook assumes cwd is the repo root | A wrapper that runs git commit from a directory outside/beside the repo (via GIT_DIR/GIT_WORK_TREE) leaves the hook's cwd outside the tree; precious lint --staged can't find precious.toml and the hook breaks | cd "$(git rev-parse --show-toplevel)" at the top of the runtime path, after the --init block |
Red Flags
multiple values given for property -remove→-remove =used inside[RemovePrereqs]; drop the dash.dzil buildsays "file does not exist" → tidyall files deleted from disk but not staged;git add -u.- PR diff includes ~100 lines of regenerated
META.json/Makefile.PL→ T4's cleanup step skipped; revert those files. precious lint --allpasses locally but the new CI job fails onomegasort(or any other binary) →oalders/install-ubi-actionstep missing, usingtools:instead ofprojects:(silently dropped → nothing installed), or wrong slug inprojects:; check the input key isprojects:and the slug ishouseabsolute/omegasort.- The merge queue fails with
fatal: ambiguous argument 'origin/': unknown revision or path not in the working tree→ the lint step's incremental arm is gated ongithub.ref(or runs unconditionally) instead ofgithub.event_name.github.base_refis empty inmerge_group/push, so--git-diff-from origin/has no ref. Gate ongithub.event_name == "pull_request"so the diff arm only runs wherebase_refis populated. --git-diff-from origin/<base>fails on a PR against a non-default branch → the step fetched a hardcoded default branch instead of the PR base, soorigin/<base>was never created locally. BindBASE_REF: ${{ github.base_ref }}underenv:and fetch"$BASE_REF:refs/remotes/origin/$BASE_REF"before diffing.- A workflow run fails immediately with
Unable to resolve action owner/repo@vN, repository or version not found→ the pinned action tag does not exist (e.g.install-ubi-action@v1— there is nov1); resolve the real latest tag withgh apiand re-pin. - The
install-ubi-actionstep fails intermittently with a GitHub API 403 / rate-limit error →GITHUB_TOKENis missing from the step'swith:; addGITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}. precious config listerrors with TOML parse failure after T1 → quoting issue in the heredoc that generated the file; re-emitprecious.tomlfrom a real template, not string concatenation.Test::Varsstill appears indevelop_requiresofMETA.json→ the bundle's tidyall Prereqs block uses a moniker different from'Modules for use with tidyall'; read the bundle source and use the right name in-remove.precious lint --allfails because thetyposblock matches no files →path-args = "none"was dropped from the typos block, so precious is passing per-file paths and typos can't reconcile them with its own tree walk; restoreinvoke = "once"+path-args = "none".precious config listerrors with a TOML deserialization / unknown-field error → keys are snake_case (lint_flags,ok_exit_codes, …). precious uses kebab-case — rewrite with hyphens (lint-flags,ok-exit-codes).- CI installs
typosbut the lint run never invokes it → T1 didn't emit a[commands.typos]block; either removecrate-ci/typosfrom the ubiprojects:list or add the missing block. - Contributor commits directly to
mainand the branch guard never fires →scripts/pre-commit'sdefault_branch=was set tomainbut the repo is onmaster, ORscripts/pre-commit --initwas never run on that clone; check the symlink withls -l .git/hooks/pre-commit. - Hook fails with
precious.tomlnot found (or lints the wrong tree) only under a commit wrapper → the wrapper runsgit commitfrom outside the repo, so the hook's cwd is foreign; thecd "$(git rev-parse --show-toplevel)"line is missing from the runtime path. Add it after the--initblock. git commitsucceeds despite obvious tidy violations →.git/hooks/pre-commitsymlink targets a non-executablescripts/pre-commit;chmod +x scripts/pre-commitand try again.scripts/pre-commitshows up in<DistName>-*/MANIFESTor the released tarball → T6's[PruneFiles]step was skipped on a dzil repo; appendfilename = scripts/pre-committo[PruneFiles]indist.iniand rebuild.precious.tomlor.perltidyrcshows up in<DistName>-*/MANIFESTor the released tarball → T1/T2's dist.ini-exclusion step was skipped on a dzil repo; exclude viaexclude_filename(or[PruneFiles]when the bundle owns[Git::GatherDir]) and rebuild (seeworking-with-dist-zilla§7).
Related
kitchen-sink:working-with-dist-zilla—dzilpatterns this skill leans on (PluginRemover vs RemovePrereqs,CopyFilesFromBuildrule,dzil test --release --author, and §7's tooling-config exclusion — the canonicalexclude_filenamevs[PruneFiles]list this skill defers to forprecious.toml/.perltidyrc/scripts/pre-commit).kitchen-sink:tune-perl-ci— sister skill for the test workflow; the lint job in T5 follows its conventions (concurrency, default-branch push,install-with-cpm@v2). The lint job pins Perl 5.42, so the App::cpmversion:conditional fromtune-perl-citransform 6 (for Perls ≤ 5.22) is intentionally omitted here.- precious on GitHub — tool homepage.
- omegasort on GitHub — sorter used by
omegasort-gitignore/omegasort-stopwords. - App::perlvars on metacpan — lint-time replacement for
Test::Vars. - typos on GitHub — fast content-agnostic spell checker; wired into T1 when a typos config exists.
- ubi-action — universal binary installer used by the CI lint job.
- Reference PR: libwww-perl/WWW-Mechanize-Cached#35 — the migration this skill was extracted from.