ffmpeg-skill 1.4.4 → 1.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/SKILL.md +18 -27
- package/mcp/server.py +16 -2
- package/package.json +4 -2
- package/references/ci-platform-pitfalls.md +15 -0
- package/scripts/_common.py +130 -20
- package/scripts/audio.py +3 -3
- package/scripts/background.py +2 -2
- package/scripts/batch.py +5 -6
- package/scripts/broll.py +5 -4
- package/scripts/caption.py +2 -2
- package/scripts/color.py +2 -2
- package/scripts/crop.py +2 -2
- package/scripts/cut.py +18 -6
- package/scripts/deinterlace.py +2 -2
- package/scripts/denoise.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +4 -4
- package/scripts/freeze.py +2 -2
- package/scripts/graphics.py +2 -2
- package/scripts/grid.py +2 -2
- package/scripts/insert.py +2 -2
- package/scripts/join.py +5 -5
- package/scripts/loop.py +2 -2
- package/scripts/loudness.py +1 -1
- package/scripts/metadata.py +4 -4
- package/scripts/multicam.py +2 -2
- package/scripts/overlay.py +2 -2
- package/scripts/pad.py +2 -2
- package/scripts/redact.py +2 -2
- package/scripts/render.py +21 -14
- package/scripts/report.py +14 -9
- package/scripts/reverse.py +2 -2
- package/scripts/scenes.py +4 -15
- package/scripts/sequence.py +2 -2
- package/scripts/silence.py +15 -2
- package/scripts/speedramp.py +2 -2
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +3 -3
- package/scripts/straighten.py +2 -2
- package/scripts/sync.py +4 -19
- package/scripts/waveform.py +2 -2
- package/references/process-pitfalls.md +0 -229
package/scripts/silence.py
CHANGED
|
@@ -16,7 +16,7 @@ import re
|
|
|
16
16
|
import sys
|
|
17
17
|
from typing import List, Tuple
|
|
18
18
|
|
|
19
|
-
from _common import video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args
|
|
19
|
+
from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs
|
|
20
20
|
|
|
21
21
|
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
22
22
|
|
|
@@ -65,7 +65,7 @@ def main() -> int:
|
|
|
65
65
|
ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
|
|
66
66
|
ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
|
|
67
67
|
ap.add_argument("--crf", type=int, default=18)
|
|
68
|
-
ap.add_argument("--preset", default="medium")
|
|
68
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
69
69
|
add_common(ap)
|
|
70
70
|
args = ap.parse_args()
|
|
71
71
|
apply_common(args)
|
|
@@ -86,6 +86,19 @@ def main() -> int:
|
|
|
86
86
|
"removed_seconds": round(removed, 3),
|
|
87
87
|
}
|
|
88
88
|
info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
|
|
89
|
+
if not silences and not STATE.dry_run:
|
|
90
|
+
# Nothing under the threshold is a valid result, not a failure -- but an agent that only
|
|
91
|
+
# sees "0 silences" tends to reach for raw ffmpeg next. Say what the floor actually is and
|
|
92
|
+
# what threshold would bite, so the retry is a flag change, not a workaround.
|
|
93
|
+
level = measured_level_dbfs(args.input)
|
|
94
|
+
if level:
|
|
95
|
+
suggested = min(-5.0, round(level["mean_dbfs"] + 6.0))
|
|
96
|
+
summary["hint"] = (f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; the track's mean level is "
|
|
97
|
+
f"{level['mean_dbfs']:.1f} dBFS (peak {level['peak_dbfs']:.1f}). For a quiet-room recording try "
|
|
98
|
+
f"--threshold {suggested:g}, or a shorter --min-silence")
|
|
99
|
+
else:
|
|
100
|
+
summary["hint"] = f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; try a higher --threshold (e.g. -25) or a shorter --min-silence"
|
|
101
|
+
info("hint: " + summary["hint"])
|
|
89
102
|
|
|
90
103
|
if args.edl:
|
|
91
104
|
with open(args.edl, "w", encoding="utf-8") as fh:
|
package/scripts/speedramp.py
CHANGED
|
@@ -19,7 +19,7 @@ import argparse
|
|
|
19
19
|
import sys
|
|
20
20
|
from typing import List, Tuple
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
22
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
23
23
|
|
|
24
24
|
MAX_SPEED = 20.0
|
|
25
25
|
MIN_SPEED = 0.05
|
|
@@ -60,7 +60,7 @@ def main() -> int:
|
|
|
60
60
|
ap.add_argument("--segment", action="append", required=True, dest="segments",
|
|
61
61
|
help=f"START-END:FACTOR, repeatable; segments must cover 0..duration with no gaps or overlaps, in order. FACTOR is {MIN_SPEED}..{MAX_SPEED} (2.0 = twice as fast, 0.5 = half speed)")
|
|
62
62
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
63
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
63
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
64
64
|
add_common(ap)
|
|
65
65
|
args = ap.parse_args()
|
|
66
66
|
apply_common(args)
|
package/scripts/sphere.py
CHANGED
|
@@ -31,7 +31,7 @@ Examples:
|
|
|
31
31
|
import argparse
|
|
32
32
|
import sys
|
|
33
33
|
|
|
34
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
34
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
|
|
35
35
|
|
|
36
36
|
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
37
37
|
# produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
|
|
@@ -65,7 +65,7 @@ def main() -> int:
|
|
|
65
65
|
out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
66
66
|
out.add_argument("--interp", choices=INTERP_METHODS, default="lanczos", help="resampling method (default lanczos)")
|
|
67
67
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
68
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
68
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
69
69
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
70
70
|
add_common(ap)
|
|
71
71
|
args = ap.parse_args()
|
package/scripts/stabilize.py
CHANGED
|
@@ -27,7 +27,7 @@ import sys
|
|
|
27
27
|
import tempfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
-
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args
|
|
30
|
+
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def main() -> int:
|
|
@@ -40,7 +40,7 @@ def main() -> int:
|
|
|
40
40
|
ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
|
|
41
41
|
ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
|
|
42
42
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
43
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
43
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
44
44
|
add_common(ap)
|
|
45
45
|
args = ap.parse_args()
|
|
46
46
|
apply_common(args)
|
|
@@ -62,7 +62,7 @@ def main() -> int:
|
|
|
62
62
|
trf = str(Path(tmp) / "transforms.trf")
|
|
63
63
|
trf_arg = escape_filter_path(trf)
|
|
64
64
|
|
|
65
|
-
if not STATE
|
|
65
|
+
if not STATE.dry_run:
|
|
66
66
|
ffmpeg = require_tool("ffmpeg")
|
|
67
67
|
detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
|
|
68
68
|
if args.tripod:
|
package/scripts/straighten.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import math
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def main() -> int:
|
|
@@ -36,7 +36,7 @@ def main() -> int:
|
|
|
36
36
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
37
37
|
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
38
38
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
39
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
39
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
40
40
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
41
41
|
add_common(ap)
|
|
42
42
|
args = ap.parse_args()
|
package/scripts/sync.py
CHANGED
|
@@ -29,11 +29,10 @@ import cmath
|
|
|
29
29
|
import json
|
|
30
30
|
import math
|
|
31
31
|
import os
|
|
32
|
-
import struct
|
|
33
32
|
import sys
|
|
34
33
|
from typing import List
|
|
35
34
|
|
|
36
|
-
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args
|
|
35
|
+
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
|
|
37
36
|
|
|
38
37
|
SR = 8000 # decode sample rate
|
|
39
38
|
|
|
@@ -55,26 +54,12 @@ OVERLAP_WEIGHT_EXP = 0.5
|
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
|
|
58
|
-
|
|
59
|
-
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
|
|
60
|
-
"-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
|
|
61
|
-
proc = run_analysis(cmd, check=False, text=False)
|
|
62
|
-
if proc.returncode != 0 or not proc.stdout:
|
|
63
|
-
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
|
|
64
|
-
n = len(proc.stdout) // 2
|
|
65
|
-
return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
|
|
57
|
+
return decode_pcm_mono(path, SR, seconds, start)
|
|
66
58
|
|
|
67
59
|
|
|
68
60
|
def envelope(samples: List[float], step: int) -> List[float]:
|
|
69
|
-
"""RMS energy per block, mean-removed so silence does not correlate."""
|
|
70
|
-
|
|
71
|
-
for i in range(0, len(samples) - step + 1, step):
|
|
72
|
-
block = samples[i : i + step]
|
|
73
|
-
env.append(math.sqrt(sum(x * x for x in block) / step))
|
|
74
|
-
if not env:
|
|
75
|
-
return env
|
|
76
|
-
mean = sum(env) / len(env)
|
|
77
|
-
return [e - mean for e in env]
|
|
61
|
+
"""RMS energy per full block, mean-removed so silence does not correlate."""
|
|
62
|
+
return rms_envelope(samples, step, full_blocks_only=True, remove_mean=True)
|
|
78
63
|
|
|
79
64
|
|
|
80
65
|
def fft(a: List[complex]) -> List[complex]:
|
package/scripts/waveform.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color
|
|
25
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS
|
|
26
26
|
|
|
27
27
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
28
28
|
|
|
@@ -42,7 +42,7 @@ def main() -> int:
|
|
|
42
42
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
43
43
|
help="which audio stream of the input to render, 0-based in file order (default 0)")
|
|
44
44
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
45
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
45
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
46
46
|
add_common(ap)
|
|
47
47
|
args = ap.parse_args()
|
|
48
48
|
apply_common(args)
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
# Process pitfalls
|
|
2
|
-
|
|
3
|
-
Mistakes made (or nearly made) while developing this repo that were not about FFmpeg
|
|
4
|
-
or a platform's behaviour — about the *process* of making a change safely. Written down
|
|
5
|
-
for the same reason `references/ci-platform-pitfalls.md` exists: a mistake that isn't
|
|
6
|
-
recorded gets repeated the next time a session starts fresh with no memory of it.
|
|
7
|
-
|
|
8
|
-
**This file is a living record.** Whenever a change here is made (or nearly made, then
|
|
9
|
-
caught before landing) because an existing guardrail — a pinned test, an environment
|
|
10
|
-
constraint, a platform's real behaviour under repeated attempts — wasn't checked first,
|
|
11
|
-
add an entry below. Don't wait to be asked.
|
|
12
|
-
|
|
13
|
-
## Before narrowing a `required`/`optional` capability list, grep for the pinned test that checks it
|
|
14
|
-
|
|
15
|
-
`scripts/_contract.py`'s `TOOL_META[...]["required"]` drives `doctor`'s per-tool `usable`
|
|
16
|
-
answer (`_tool_usability()` in `_contract.py` only reads `required`, never `optional`).
|
|
17
|
-
Moving a capability from `required` to `optional` — even when it's honestly true that a
|
|
18
|
-
new flag makes it conditional — silently changes what `doctor` reports as `usable: no`
|
|
19
|
-
on a machine missing that capability, for the tool's *default* invocation too.
|
|
20
|
-
|
|
21
|
-
`tests/test_contract.py`'s `DoctorDetectionTests` pins specific `usable` outcomes against
|
|
22
|
-
real captured `ffmpeg -filters`/`-encoders` fixtures (e.g. a plain Homebrew macOS build
|
|
23
|
-
correctly reporting `caption.usable: "no"` because it lacks `filter:subtitles`). A change
|
|
24
|
-
to `required` that isn't checked against these first can pass a quick unit test and still
|
|
25
|
-
break this fixture-based guarantee.
|
|
26
|
-
|
|
27
|
-
Caught twice while adding capability metadata for new flags (`caption.py --mode mux` in
|
|
28
|
-
#51, `doctor`'s `gpu_encoders` in #52) — in both cases the fix was to grep
|
|
29
|
-
`tests/test_contract.py` for `usable` and `_doctor(` *before* editing `TOOL_META`, not
|
|
30
|
-
after a test failure revealed it. Do that grep first, every time `required`/`optional`
|
|
31
|
-
changes.
|
|
32
|
-
|
|
33
|
-
## Git tag push and GitHub Release creation are not reachable from this environment — so they are not done from it
|
|
34
|
-
|
|
35
|
-
The git credentials available here can push to `refs/heads/*` (branches) but not
|
|
36
|
-
`refs/tags/*` — confirmed by a 403 straight from the git-receive-pack endpoint, not an
|
|
37
|
-
auth failure, meaning it's a deliberate scope restriction, not a bug to route around.
|
|
38
|
-
The GitHub MCP tool surface has no `create_release`/`create_tag` equivalent either, and a
|
|
39
|
-
direct call to the GitHub REST API's `/releases` endpoint with a raw token is blocked by
|
|
40
|
-
the outbound proxy itself.
|
|
41
|
-
|
|
42
|
-
Confirmed once (retried the tag push a second time "just in case" before accepting it).
|
|
43
|
-
Don't retry either path a second time. Since 0.16.11 this is moot for releases:
|
|
44
|
-
`.github/workflows/release.yml` creates the tag, the GitHub Release and the npm publish
|
|
45
|
-
from GitHub Actions on every push to `main` (README, "Development" → "Releasing"), so a
|
|
46
|
-
session never needs to push a tag at all — merging the PR is the release. The one thing
|
|
47
|
-
still worth knowing: that workflow's own `git push origin HEAD:main` (the automatic
|
|
48
|
-
version-bump commit) and its tag run under the built-in `GITHUB_TOKEN`, which by design
|
|
49
|
-
triggers no further workflow runs, so a red `tests` run for that bump commit is not
|
|
50
|
-
"missing" — it is never scheduled; the PR run before the merge is the one that counted.
|
|
51
|
-
|
|
52
|
-
## A throwaway probe must be pointed at a copy in a directory you have just verified with `pwd`, never at "the repo, probably"
|
|
53
|
-
|
|
54
|
-
While validating the auto-bump script for `release.yml`, a scratch run was meant to
|
|
55
|
-
execute against a temporary copy of the repo. It ran with the real checkout as its
|
|
56
|
-
working directory instead — a `cd` into the scratch path happened in a shell whose
|
|
57
|
-
working directory was reset between commands — and its `git add -A && git commit`,
|
|
58
|
-
`git tag v0.16.12` and two empty commits landed on the real branch and moved the real
|
|
59
|
-
local `v0.16.12` tag. Nothing was pushed, and `git reflog` plus a `git fetch --force` of
|
|
60
|
-
the tag from `origin` restored everything, but it was only noticed because `git status`
|
|
61
|
-
showed files the session had not edited.
|
|
62
|
-
|
|
63
|
-
Rule: a probe that runs `git commit`, `git tag`, `rm -rf`, or writes into the tree goes
|
|
64
|
-
in a directory created *and* verified in the same command (`cd "$DIR" && pwd && ...`),
|
|
65
|
-
not one assumed from an earlier `cd`; prefer a fixture built from a few `printf` lines
|
|
66
|
-
over a copy of the whole checkout (the copy carries the real `.git`, so a mistake there
|
|
67
|
-
is a mistake in the real history); and run `git status` on the real repo before
|
|
68
|
-
committing anything afterwards. `.claude/skills/destructive-operations/SKILL.md` says the
|
|
69
|
-
same thing for the tools themselves — it applies to the person testing them too.
|
|
70
|
-
|
|
71
|
-
## A quantitative test failing three different ways across fixture redesigns means the platform, not the fixture, is the problem
|
|
72
|
-
|
|
73
|
-
`test_stabilize_reduces_frame_to_frame_motion` (macOS CI, `stabilize.py`) failed with
|
|
74
|
-
three independently redesigned shake fixtures in a row — each time the instinct was "the
|
|
75
|
-
fixture's frequencies must be wrong," each time the retuned fixture failed a *different*
|
|
76
|
-
way on the next CI run. The actual cause (libvidstab behaving differently across the
|
|
77
|
-
Linux and macOS ffmpeg builds) was diagnosable from the first failure: a synthetic
|
|
78
|
-
fixture that reliably improves under one implementation and reliably gets worse under
|
|
79
|
-
another is evidence the implementations disagree, not that the fixture is miscalibrated.
|
|
80
|
-
|
|
81
|
-
If a quantitative assertion fails on one platform, survives a redesign, and fails again
|
|
82
|
-
on the *same* platform in a different way: stop redesigning the fixture. Either restrict
|
|
83
|
-
the strict assertion to the platform where it's provably correct (keeping a weaker,
|
|
84
|
-
platform-general check — output exists, has the right duration — everywhere), or escalate
|
|
85
|
-
before spending a third CI cycle on it.
|
|
86
|
-
|
|
87
|
-
## A fix merged after CHANGELOG.md's current-version section was drafted can silently miss it
|
|
88
|
-
|
|
89
|
-
`CHANGELOG.md`'s `## 0.12.0` section was written once, covering everything merged up to
|
|
90
|
-
that point. Two fixes that closed real issues after that point (#62's `--audio-stream`
|
|
91
|
-
extension via PR #72, #77's dry-run-dims fix via PR #88) landed with no further nudge to
|
|
92
|
-
go back and add a bullet — #62's fix actually got a bullet (its content is genuinely
|
|
93
|
-
described) but the `Closes #62` link was left off, and #77 was missed outright until a
|
|
94
|
-
direct question ("shouldn't this bump the version?") prompted a manual check. Neither was
|
|
95
|
-
caught by CI, because nothing checked CHANGELOG.md against what had actually been closed.
|
|
96
|
-
|
|
97
|
-
Caught by hand both times, then closed properly with `tests/test_contract.py`'s
|
|
98
|
-
`test_changelog_mentions_every_closed_issue_since_last_tag`, which walks `git log` back to
|
|
99
|
-
the latest release tag, extracts every `Closes #N.` from a commit body, and fails if that
|
|
100
|
-
issue number doesn't appear anywhere in `CHANGELOG.md`. This needs real history (`ci.yml`'s
|
|
101
|
-
`actions/checkout` step now passes `fetch-depth: 0` for exactly this reason — the default
|
|
102
|
-
shallow clone leaves no tag reachable to diff against, which would make the test silently
|
|
103
|
-
skip itself in CI, not fail). If this test ever needs to skip a genuinely changelog-less
|
|
104
|
-
closed issue (a pure process note, a duplicate, a revert of an unreleased change), name the
|
|
105
|
-
exemption in the test itself with a reason — don't just widen the regex or drop the check.
|
|
106
|
-
|
|
107
|
-
## Automation that can publish must not take its "major" cue from text it did not write
|
|
108
|
-
|
|
109
|
-
On 2026-09-11, three routine Dependabot merges (`actions/upload-artifact` 4→7,
|
|
110
|
-
`actions/setup-node` 4→7, `dependabot/fetch-metadata` 2→3) were published to npm as
|
|
111
|
-
**1.0.0, 1.0.1 and 1.0.2**. The release pipeline (`release.yml`, since #129) resolves the next
|
|
112
|
-
version from PR labels; an autolabeler rule added the same day applied `major` to any PR whose
|
|
113
|
-
*body* contained the literal breaking-change marker. Dependabot PR bodies quote the upstream
|
|
114
|
-
project's release notes verbatim, and upload-artifact's v5.0.0 notes contain exactly that
|
|
115
|
-
phrase — about *their* Node runtime, nothing to do with this package. One label, three
|
|
116
|
-
accidental majors, in nine minutes, with every job green.
|
|
117
|
-
|
|
118
|
-
Two things made it worse than one bad rule: every chore merge released at all (so the first
|
|
119
|
-
accident was followed by two more before anyone looked), and nothing in the pipeline treated
|
|
120
|
-
"the major number changed" as different from any other bump.
|
|
121
|
-
|
|
122
|
-
Fixes (this commit): no autolabeler rule produces `major` any more; `chore`/`ci`/`docs`/
|
|
123
|
-
`dependencies` PRs are excluded from version resolution so they release nothing; `release.yml`
|
|
124
|
-
refuses to auto-bump across a major boundary regardless of labels; and the release job is
|
|
125
|
-
serialised (`concurrency`) so back-to-back merges cannot race on the bump push.
|
|
126
|
-
|
|
127
|
-
The general rule: a pipeline that publishes must never derive an irreversible decision (a
|
|
128
|
-
major bump, a publish, a tag) from text it did not author — PR bodies, commit messages and
|
|
129
|
-
release notes are quotations as often as they are statements. Match on labels a person
|
|
130
|
-
applied, or on files changed, and make the irreversible step refuse anything surprising rather
|
|
131
|
-
than assume the surprise was intended. And after wiring any such automation, watch the first
|
|
132
|
-
few real runs' *results* (npm, tags) rather than their exit codes: the three runs here were
|
|
133
|
-
"success" by every check the job had.
|
|
134
|
-
|
|
135
|
-
## An action input that does not exist is a warning, not an error -- and "excluded from the notes" is not "no release"
|
|
136
|
-
|
|
137
|
-
The fix for the accidental majors above (#145) still released **1.0.4** for its own,
|
|
138
|
-
workflow-only merge. Two assumptions in `release.yml` were wrong and nothing checked either:
|
|
139
|
-
|
|
140
|
-
- `release-drafter/release-drafter@v6` was called with `dry-run: true` to "compute the next
|
|
141
|
-
version read-only". That action has no `dry-run` input. GitHub Actions logs
|
|
142
|
-
`Unexpected input(s) 'dry-run'` as a *warning* and runs the step anyway -- so every release
|
|
143
|
-
run had been rewriting the draft release live, and the "read-only" in the comment was fiction.
|
|
144
|
-
- `exclude-labels` in `release-drafter.yml` was expected to make a chore-only merge resolve to
|
|
145
|
-
the same version as the last tag. It only removes those PRs from the draft *notes*; the
|
|
146
|
-
version resolver still applies `default: patch` and reports last+patch. The workflow's "same
|
|
147
|
-
version → no-op" guard therefore never fired.
|
|
148
|
-
|
|
149
|
-
Both were visible in the first run's log and in the action's documented inputs, and both were
|
|
150
|
-
missed because the PR's test plan verified the YAML *parsed* and the config *contained* the
|
|
151
|
-
intended keys -- not that the action *did* what the comment claimed. Fixed by taking the
|
|
152
|
-
decision away from the action: `.github/scripts/resolve_version.py` reads the merged PRs'
|
|
153
|
-
labels through `gh api`, returns nothing when nothing is releasable, refuses `major`, and has
|
|
154
|
-
a unit test in `tests/test_contract.py` with fake label data for each rule.
|
|
155
|
-
|
|
156
|
-
The general rule, twice over now: when wiring a third-party action, read its `action.yml`
|
|
157
|
-
inputs (or `Unexpected input(s)` in the first log) before trusting a parameter, and treat
|
|
158
|
-
any step whose output decides an irreversible action as something to unit-test with fixed
|
|
159
|
-
inputs, not something to confirm by reading its YAML. And watch the first real run's
|
|
160
|
-
*effect* (tags, npm), which is how both incidents were actually noticed.
|
|
161
|
-
|
|
162
|
-
### The release bump step required the literal `(nothing yet)` line under `## Unreleased`
|
|
163
|
-
|
|
164
|
-
Found on the first run after #163 (2026-09-11). `release.yml`'s auto-bump located the CHANGELOG
|
|
165
|
-
insertion point with `assert "## Unreleased\n\n(nothing yet)\n\n" in changelog`. #163 did the
|
|
166
|
-
natural thing and wrote its notes under Unreleased, so the bump step failed on the assert
|
|
167
|
-
before the push, the tag or the publish -- a clean no-op, but a red run and no release. The
|
|
168
|
-
script now takes whatever sits under Unreleased into the new version's section and puts the
|
|
169
|
-
placeholder back, so hand-written notes are welcome there. Lesson: an anchor that is also
|
|
170
|
-
prose will be edited; anchor on the heading, not on the placeholder text.
|
|
171
|
-
|
|
172
|
-
### The built-in GITHUB_TOKEN cannot push the release bump through a ruleset
|
|
173
|
-
|
|
174
|
-
Found on the first release after the main ruleset went active (2026-09-11, run for #166):
|
|
175
|
-
`git push origin HEAD:main` from release.yml was declined with GH013 ("Changes must be made
|
|
176
|
-
through a pull request", "8 of 8 required status checks are expected"). GitHub Actions cannot
|
|
177
|
-
be added as a ruleset bypass actor (the import rejects the actor, the UI does not list it), so
|
|
178
|
-
the bump push now uses the `RELEASE_PUSH_TOKEN` secret -- a fine-grained PAT of a repository
|
|
179
|
-
admin with Contents: read/write on this repo -- whose "Repository admin" bypass applies. A PAT
|
|
180
|
-
push triggers workflows (GITHUB_TOKEN's do not), so the bump commit carries `[skip ci]`; the
|
|
181
|
-
tag, Release and npm publish all happen in the originating run. Rotate the PAT before it
|
|
182
|
-
expires or the next release fails at the same step, cleanly, before anything is published.
|
|
183
|
-
|
|
184
|
-
### A literal `[skip ci]` anywhere in a PR body skips every workflow on the squash merge
|
|
185
|
-
|
|
186
|
-
Found on the merge of #170 (2026-09-11). The PR body quoted the new bump-commit message
|
|
187
|
-
verbatim, including `[skip ci]`; a squash merge copies the PR body into the merge commit, and
|
|
188
|
-
GitHub honours the marker wherever it appears in the commit message. Nothing ran on `main` for
|
|
189
|
-
that merge -- no tests, no CodeQL, no release -- and the release only happened when the next PR
|
|
190
|
-
merged. Describe the marker in words in PR bodies and commit messages ("the skip-CI marker"),
|
|
191
|
-
or wrap it so it does not match, and after any merge that touches CI check that the push
|
|
192
|
-
actually triggered the expected runs.
|
|
193
|
-
|
|
194
|
-
### Two merges minutes apart: the first release run bumps on a stale main and its push is rejected
|
|
195
|
-
|
|
196
|
-
Found on 1.4.2 (2026-09-11). #167 (fix) merged, then #171 (docs) a minute later while the
|
|
197
|
-
release run for #167 was still bumping. The concurrency group serialises the runs, but a run
|
|
198
|
-
checks out the SHA that triggered it, so the first run's bump commit sat behind #171's merge
|
|
199
|
-
and `git push origin HEAD:main` was rejected as non-fast-forward. The second run (for #171)
|
|
200
|
-
then found both PRs unreleased and published 1.4.2 correctly, so nothing was lost -- one red
|
|
201
|
-
run and a confusing timeline. release.yml now checks out `ref: main` and rebases the bump on
|
|
202
|
-
main right before pushing. Lesson: a workflow that pushes to the branch that triggered it must
|
|
203
|
-
start from the branch tip, not from the triggering commit.
|
|
204
|
-
|
|
205
|
-
### "Clean up the partial output on failure" deleted the user's file
|
|
206
|
-
|
|
207
|
-
Found by the 1.4.2 review (2026-09-12), present since #78 (2026-09-07). The cleanup that removes a
|
|
208
|
-
0-byte stray after a failed encode keyed on "output path exists after failure", which is also
|
|
209
|
-
true of a deliverable that was there before the run and that ffmpeg never opened (a bad filter
|
|
210
|
-
argument fails at graph init, before the muxer touches the output -- on 6.1+). The --overwrite
|
|
211
|
-
consent added in #163 guards the success path only; the failure path had its own delete. The
|
|
212
|
-
first fix (snapshot size/mtime, leave an unchanged file alone) passed on 6.1 and failed in the
|
|
213
|
-
5.1.1 CI job: FFmpeg 5.x opens (truncates) the output during option parsing, before any filter
|
|
214
|
-
initialises, so ffmpeg itself had already destroyed the file. The fix that holds on every
|
|
215
|
-
version is to never let ffmpeg write to an existing path: run against a hidden sibling temp
|
|
216
|
-
file and os.replace() it over the original on success only. Lessons: a destructive step must
|
|
217
|
-
know whether it created the thing it is about to destroy, "exists" is not that knowledge; and
|
|
218
|
-
"the tool fails before touching the file" is a version-specific fact, never a guarantee. And the second review found what the first one -- which had
|
|
219
|
-
just written the overwrite guard next to this code -- did not: a reviewer who wrote the fix
|
|
220
|
-
reads the file they fixed, not the one beside it.
|
|
221
|
-
|
|
222
|
-
### --timeout only worked when ffmpeg was talking
|
|
223
|
-
|
|
224
|
-
Same review. The --progress runner iterated the progress pipe and compared the clock per
|
|
225
|
-
line, so the one case the timeout exists for (a deadlocked ffmpeg, which prints nothing)
|
|
226
|
-
never reached the comparison. The non-progress path used subprocess.run(timeout=) and was
|
|
227
|
-
fine, and the test only exercised that path. Lesson: a deadline belongs on a clock the loop
|
|
228
|
-
wakes up to check, never on the arrival of the thing you are waiting for; and a test for
|
|
229
|
-
"hang" must use a shim that actually hangs silently, not one that fails fast.
|