Skip to content

release: sync dev/v0.9 to main for v0.9.3 - #87

Merged
scttbnsn merged 5 commits into
mainfrom
dev/v0.9
Aug 12, 2026
Merged

release: sync dev/v0.9 to main for v0.9.3#87
scttbnsn merged 5 commits into
mainfrom
dev/v0.9

Conversation

@scttbnsn

@scttbnsn scttbnsn commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Brings main level with dev/v0.9 ahead of the v0.9.3 cut.

Three commits: the Conventional Commits migration (#84), the lockfile regeneration that restores the cross-platform optional-dependency matrix (#85), and the v0.9.3 changelog entry plus a release-cut gate fix (#86).

Merging this as a merge commit, matching #83. Squashing would flatten the individual subjects that release-cut's bump math reads. Verified over v0.9.2..dev/v0.9: no feat and no !, so the bump computes patch and the cut lands on v0.9.3. The version input will be left blank so the math decides.

The gate fix in #86 matters for this cut. release-cut's "CHANGELOG entry is non-empty" check used awk '/\S/', and mawk (the default awk on the ubuntu runners) has no Perl character classes, so it read \S as a literal S. The check only ever passed for entries that happened to contain a capital S. The v0.9.2 section has five; the v0.9.3 section has none, so the cut would have hard-failed on a section that is plainly not empty. Replaced with [^[:space:]].

Changelog

  • ✨ Added plain Conventional Commit support across validation, release calculation, documentation, and changelog filtering.
  • 🔧 Changed CI validation to check commits against the pull request base branch.
  • 🔧 Regenerated lockfiles to restore cross-platform optional dependencies.
  • 🔧 Pinned dependency versions and updated Biome configuration.
  • 🔧 Updated the Wolfi base image digest.
  • 🐛 Fixed portable release-cut CHANGELOG validation.
  • 🐛 Fixed release parsing for valid Conventional Commit breaking-change markers.
  • ✨ Added the v0.9.3 changelog entry.

Concerns

  • Verify the merge commit preserves individual commit subjects for release bump calculation.
  • Verify the release calculation produces a patch bump from v0.9.2 to v0.9.3.
  • Verify the new Wolfi digest matches the intended amd64/arm64 image.
  • Verify regenerated lockfiles include optional dependencies for all supported platforms.
  • Run commit validation, release-cut validation, and CI workflow checks.

- 📦 deps(npm): pin devDependencies per renovate policy — biome 2.5.7, turbo 2.10.8, @types/react 19.2.18, @types/node 26.1.2, postcss 8.5.25, typescript 7.0.2, @types/react-dom 19.2.4
- 📦 deps(npm): regenerate package-lock.json from scratch (lock file maintenance)
- 🐛 fix(docker): bump wolfi-base digest in Dockerfile.release, missed when Dockerfile got the same bump
- 🔧 config(biome): migrate config to the 2.5.7 schema; replace the migrator's preset "none" output (which silently disabled every rule) with preset "recommended"
- chore(commits): 11 plain types, no emoji; hook + CI validator updated
- fix(ci): release-cut classifies bumps via the full commit header grammar
- ci: PR commit check delegates to scripts/validate-commit-msg.sh against the PR base
- style(scripts): shfmt-clean validator
- docs: convention updated in CONTRIBUTING/AGENTS/RELEASING; BREAKING CHANGE footer caveat
…eps (#85)

The lockfile only carried resolved entries for darwin-arm64 platform binaries (npm/cli#4828), so npm ci on Linux builders could not install lightningcss-linux-x64-gnu and every Vercel build failed at the Tailwind step. Full regen restores the complete platform matrix; 23 transitive deps moved within their existing ranges. Verified with fresh npm ci + website build.
* docs(changelog): record v0.9.3 changes

* fix(ci): make the release-cut CHANGELOG non-empty check portable

mawk, the default awk on the ubuntu runners, has no Perl character
classes and reads \S as a literal S. The non-empty check therefore
passed only for entries that happened to contain a capital S. The
v0.9.3 entry does not, so the cut would have hard-failed on a
changelog section that is plainly not empty.
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
getportwing Ready Ready Preview Aug 12, 2026 1:55am

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request migrates commit validation and documentation from emoji-prefixed messages to standard Conventional Commits. CI validates commits against the pull request base branch. Release parsing supports valid headers and legacy leading emojis. Changelog filters and validation were updated. The release image digest, Biome configuration, and package version specifications were also updated.

Possibly related PRs

  • CodesWhat/portwing#59: Both changes update Conventional Commit parsing and major-version bump detection in .github/workflows/release-cut.yml.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/v0.9

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@scttbnsn

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 255-261: Update the commit-validation workflow around the git log
process substitution to resolve and validate origin/${BASE_REF} before
iterating. Write the output of git log --format=%s to a temporary file, fail the
step if that command cannot resolve the ref, then feed the file into the
existing validation loop while preserving its warning and failed-flag behavior.

In `@AGENTS.md`:
- Line 57: Update the Commits guidance in AGENTS.md to state that scope is
optional, showing both accepted formats: <type>: <description> and
<type>(scope): <description>. Keep the existing Conventional Commits and
validation references unchanged.

In `@scripts/validate-commit-msg.sh`:
- Around line 50-52: Update the validation pipeline inside the ctype loop to
avoid grep’s early pipe closure: replace grep -qE with grep -E redirected to
/dev/null, or use a Bash regular-expression match, while preserving the existing
commit-subject pattern and success behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cf13dc2a-ca76-46f7-b5c2-f524cd26ef07

📥 Commits

Reviewing files that changed from the base of the PR and between 44cfd21 and c87ba80.

⛔ Files ignored due to path filters (2)
  • CHANGELOG.md is excluded by !CHANGELOG.md
  • package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
📒 Files selected for processing (13)
  • .github/workflows/ci.yml
  • .github/workflows/release-cut.yml
  • .goreleaser.yml
  • AGENTS.md
  • CONTRIBUTING.md
  • Dockerfile.release
  • RELEASING.md
  • biome.json
  • docs/package.json
  • lefthook.yml
  • package.json
  • scripts/validate-commit-msg.sh
  • website/package.json

Comment thread .github/workflows/ci.yml
Comment on lines +255 to +261
while IFS= read -r subject; do
printf '%s\n' "${subject}" > "${tmp}"
if ! bash scripts/validate-commit-msg.sh "${tmp}" >/dev/null 2>&1; then
echo "::warning::Non-conventional commit: ${subject}"
failed=1
fi
done < <(git log --format=%s origin/main..HEAD)
done < <(git log --format=%s "origin/${BASE_REF}..HEAD")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

: "${BASE_REF:?Set BASE_REF to the pull request base branch}"
base_ref="origin/${BASE_REF}"

git rev-parse --verify "${base_ref}^{commit}" >/dev/null
git log --format=%s "${base_ref}..HEAD" >/dev/null

Repository: CodesWhat/portwing

Length of output: 225


🏁 Script executed:

#!/usr/bin/env bash
set -u

printf '%s\n' '--- workflow context ---'
sed -n '220,275p' .github/workflows/ci.yml
printf '%s\n' '--- BASE_REF and git setup references ---'
rg -n -C 3 'BASE_REF|origin/|fetch-depth|git fetch|set -e|shell:' .github/workflows/ci.yml

printf '%s\n' '--- isolated process-substitution status probe ---'
probe_dir="$(mktemp -d)"
trap 'rm -rf "$probe_dir"' EXIT
git -C "$probe_dir" init -q
git -C "$probe_dir" config user.email test@example.invalid
git -C "$probe_dir" config user.name test
printf 'x\n' > "$probe_dir/file"
git -C "$probe_dir" add file
git -C "$probe_dir" commit -qm initial

cat > "$probe_dir/probe.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
failed=0
count=0
while IFS= read -r subject; do
  count=$((count + 1))
done < <(git log --format=%s "origin/does-not-exist..HEAD")
printf 'loop_count=%s failed=%s status=success\n' "$count" "$failed"
EOF
chmod +x "$probe_dir/probe.sh"
(
  cd "$probe_dir"
  if ./probe.sh; then
    printf 'probe_exit=0\n'
  else
    printf 'probe_exit=%s\n' "$?"
  fi
)

Repository: CodesWhat/portwing

Length of output: 3474


🏁 Script executed:

#!/usr/bin/env bash
set -u

printf '%s\n' '--- process substitution with a delayed failing producer ---'
test_dir="$(mktemp -d)"
trap 'rm -rf "$test_dir"' EXIT
git -C "$test_dir" init -q
git -C "$test_dir" config user.email test@example.invalid
git -C "$test_dir" config user.name test
printf 'x\n' > "$test_dir/file"
git -C "$test_dir" add file
git -C "$test_dir" commit -qm initial

cat > "$test_dir/behavior.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
count=0
while IFS= read -r subject; do
  count=$((count + 1))
done < <(sleep 0.2; git log --format=%s "origin/does-not-exist..HEAD")
printf 'reached_after_loop count=%s\n' "$count"
EOF
chmod +x "$test_dir/behavior.sh"
(
  cd "$test_dir"
  if ./behavior.sh; then
    printf 'delayed_probe_exit=0\n'
  else
    printf 'delayed_probe_exit=%s\n' "$?"
  fi
)

printf '%s\n' '--- process substitution with producer failure suppressed ---'
(
  cd "$test_dir"
  if bash -c '
    set -euo pipefail
    count=0
    while IFS= read -r subject; do count=$((count + 1)); done < <(
      git log --format=%s "origin/does-not-exist..HEAD" || true
    )
    printf "reached_after_loop count=%s\n" "$count"
  '; then
    printf 'suppressed_probe_exit=0\n'
  else
    printf 'suppressed_probe_exit=%s\n' "$?"
  fi
)

printf '%s\n' '--- fail-closed ref check ---'
(
  cd "$test_dir"
  if bash -c '
    set -euo pipefail
    base_ref=origin/does-not-exist
    git rev-parse --verify "${base_ref}^{commit}" >/dev/null
    git log --format=%s "${base_ref}..HEAD" > subjects.tmp
  '; then
    printf 'fail_closed_probe_exit=0\n'
  else
    printf 'fail_closed_probe_exit=%s\n' "$?"
  fi
)

Repository: CodesWhat/portwing

Length of output: 734


Fail closed when origin/${BASE_REF} cannot be resolved.

set -euo pipefail does not propagate the git log status from process substitution. A failed git log can leave the loop with no subjects and failed=0. Validate the ref and write the log to a temporary file before the loop.

Proposed fix
+          subjects_tmp="$(mktemp)"
+          trap 'rm -f "${tmp}" "${subjects_tmp}"' EXIT
+          base_ref="origin/${BASE_REF}"
+          git rev-parse --verify "${base_ref}^{commit}" >/dev/null
+          git log --format=%s "${base_ref}..HEAD" > "${subjects_tmp}"
           while IFS= read -r subject; do
...
-          done < <(git log --format=%s "origin/${BASE_REF}..HEAD")
+          done < "${subjects_tmp}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while IFS= read -r subject; do
printf '%s\n' "${subject}" > "${tmp}"
if ! bash scripts/validate-commit-msg.sh "${tmp}" >/dev/null 2>&1; then
echo "::warning::Non-conventional commit: ${subject}"
failed=1
fi
done < <(git log --format=%s origin/main..HEAD)
done < <(git log --format=%s "origin/${BASE_REF}..HEAD")
subjects_tmp="$(mktemp)"
trap 'rm -f "${tmp}" "${subjects_tmp}"' EXIT
base_ref="origin/${BASE_REF}"
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
git log --format=%s "${base_ref}..HEAD" > "${subjects_tmp}"
while IFS= read -r subject; do
printf '%s\n' "${subject}" > "${tmp}"
if ! bash scripts/validate-commit-msg.sh "${tmp}" >/dev/null 2>&1; then
echo "::warning::Non-conventional commit: ${subject}"
failed=1
fi
done < "${subjects_tmp}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 255 - 261, Update the
commit-validation workflow around the git log process substitution to resolve
and validate origin/${BASE_REF} before iterating. Write the output of git log
--format=%s to a temporary file, fail the step if that command cannot resolve
the ref, then feed the file into the existing validation loop while preserving
its warning and failed-flag behavior.

Comment thread AGENTS.md
## Conventions

- **Commits:** emoji conventional commits — `<emoji> <type>(scope): <description>` (see CONTRIBUTING.md). Enforced by lefthook via `scripts/validate-commit-msg.sh`.
- **Commits:** plain Conventional Commits, no emoji — `<type>(scope): <description>` (see CONTRIBUTING.md). Enforced by lefthook via `scripts/validate-commit-msg.sh`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

State that the scope is optional.

Line 57 presents <type>(scope): <description> as the required form. scripts/validate-commit-msg.sh accepts both scoped and unscoped messages, and CONTRIBUTING.md states that scope is optional. Use <type>: <description> or <type>(scope): <description>.

Proposed fix
-- **Commits:** plain Conventional Commits, no emoji — `<type>(scope): <description>` (see CONTRIBUTING.md). Enforced by lefthook via `scripts/validate-commit-msg.sh`.
+- **Commits:** plain Conventional Commits, no emoji — `<type>: <description>` or `<type>(scope): <description>` (see CONTRIBUTING.md). Enforced by lefthook via `scripts/validate-commit-msg.sh`.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **Commits:** plain Conventional Commits, no emoji — `<type>(scope): <description>` (see CONTRIBUTING.md). Enforced by lefthook via `scripts/validate-commit-msg.sh`.
- **Commits:** plain Conventional Commits, no emoji — `<type>: <description>` or `<type>(scope): <description>` (see CONTRIBUTING.md). Enforced by lefthook via `scripts/validate-commit-msg.sh`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` at line 57, Update the Commits guidance in AGENTS.md to state that
scope is optional, showing both accepted formats: <type>: <description> and
<type>(scope): <description>. Keep the existing Conventional Commits and
validation references unchanged.

Comment on lines +50 to +52
for ctype in "${types[@]}"; do
if printf '%s\n' "$first_line" |
grep -qE "^${emoji} ${ctype}(\([A-Za-z0-9._/-]+\))?!?: .+"; then
grep -qE "^${ctype}(\([A-Za-z0-9._/-]+\))?!?: .+"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- scripts/validate-commit-msg.sh ---'
sed -n '1,100p' scripts/validate-commit-msg.sh
printf '%s\n' '--- relevant shell settings and grep usage ---'
rg -n -C 3 'pipefail|grep -qE|first_line|types=' scripts/validate-commit-msg.sh

Repository: CodesWhat/portwing

Length of output: 3795


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- shell versions ---'
bash --version | head -n 1
grep --version 2>/dev/null | head -n 1 || true

printf '%s\n' '--- single-run pipeline statuses ---'
bash -c '
set -o pipefail
for n in 100 4096 65536 1048576; do
  subject=$(printf "feat: %*s" "$n" "")
  subject=${subject// /x}
  printf "%s " "$n"
  set +e
  printf "%s\n" "$subject" | grep -qE "^feat(\([A-Za-z0-9._/-]+\))?!?: .+"
  rc=$?
  ps0=${PIPESTATUS[0]}
  ps1=${PIPESTATUS[1]}
  set -e
  printf "pipeline=%s printf=%s grep=%s\n" "$rc" "$ps0" "$ps1"
done
'

printf '%s\n' '--- repeated long-subject failure counts ---'
bash -c '
set -o pipefail
q=0
for i in $(seq 1 200); do
  subject=$(printf "feat: %*s" 1048576 "")
  subject=${subject// /x}
  set +e
  printf "%s\n" "$subject" | grep -qE "^feat(\([A-Za-z0-9._/-]+\))?!?: .+"
  rc=$?
  ps0=${PIPESTATUS[0]}
  set -e
  if [ "$rc" -ne 0 ]; then
    q=$((q + 1))
    printf "failure iteration=%s pipeline=%s printf=%s\n" "$i" "$rc" "$ps0"
    [ "$q" -ge 3 ] && break
  fi
done
printf "failures=%s\n" "$q"
'

printf '%s\n' '--- comparison with grep consuming the stream ---'
bash -c '
set -o pipefail
for n in 65536 1048576; do
  subject=$(printf "feat: %*s" "$n" "")
  subject=${subject// /x}
  set +e
  printf "%s\n" "$subject" | grep -E "^feat(\([A-Za-z0-9._/-]+\))?!?: .+" >/dev/null
  rc=$?
  ps0=${PIPESTATUS[0]}
  ps1=${PIPESTATUS[1]}
  set -e
  printf "%s pipeline=%s printf=%s grep=%s\n" "$n" "$rc" "$ps0" "$ps1"
done
'

Repository: CodesWhat/portwing

Length of output: 634


🏁 Script executed:

#!/bin/bash
set -u

bash -c '
set -o pipefail
pattern="^feat(\([A-Za-z0-9._/-]+\))?!?: .+"

for n in 100 4096 65536 1048576; do
  body=$(printf "%*s" "$n" "")
  body=${body// /x}
  subject="feat: $body"

  set +e
  printf "%s\n" "$subject" | grep -qE "$pattern"
  ps=("${PIPESTATUS[@]}")
  set -e
  if [ "${ps[0]}" -ne 0 ] || [ "${ps[1]}" -ne 0 ]; then
    pipeline=1
  else
    pipeline=0
  fi
  printf "q length=%s pipeline=%s printf=%s grep=%s\n" \
    "$n" "$pipeline" "${ps[0]}" "${ps[1]}"

  set +e
  printf "%s\n" "$subject" | grep -E "$pattern" >/dev/null
  ps=("${PIPESTATUS[@]}")
  set -e
  if [ "${ps[0]}" -ne 0 ] || [ "${ps[1]}" -ne 0 ]; then
    pipeline=1
  else
    pipeline=0
  fi
  printf "full length=%s pipeline=%s printf=%s grep=%s\n" \
    "$n" "$pipeline" "${ps[0]}" "${ps[1]}"
done

failures=0
for i in 1 200; do
  body=$(printf "%*s" 1048576 "")
  body=${body// /x}
  subject="feat: $body"
  set +e
  printf "%s\n" "$subject" | grep -qE "$pattern"
  ps=("${PIPESTATUS[@]}")
  set -e
  if [ "${ps[0]}" -ne 0 ] || [ "${ps[1]}" -ne 0 ]; then
    failures=$((failures + 1))
    printf "failure iteration=%s printf=%s grep=%s\n" \
      "$i" "${ps[0]}" "${ps[1]}"
    [ "$failures" -ge 3 ] && break
  fi
done
printf "q_failures=%s\n" "$failures"
'

Repository: CodesWhat/portwing

Length of output: 515


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- available tracing and shells ---'
command -v strace || true
find /bin /usr/bin -maxdepth 1 -type f \( -name 'bash*' -o -name 'dash' -o -name 'ksh*' \) -print 2>/dev/null | sort || true

printf '%s\n' '--- larger valid subjects with Bash builtin printf ---'
bash -c '
set -o pipefail
pattern="^feat(\([A-Za-z0-9._/-]+\))?!?: .+"
for n in 4194304 16777216 67108864; do
  body=$(printf "%*s" "$n" "")
  body=${body// /x}
  subject="feat: $body"
  set +e
  printf "%s\n" "$subject" | grep -qE "$pattern"
  ps=("${PIPESTATUS[@]}")
  set -e
  if [ "${ps[0]}" -ne 0 ] || [ "${ps[1]}" -ne 0 ]; then pipeline=1; else pipeline=0; fi
  printf "length=%s pipeline=%s printf=%s grep=%s\n" \
    "$n" "$pipeline" "${ps[0]}" "${ps[1]}"
done
'

printf '%s\n' '--- external writer comparison ---'
python3 - <<'PY'
import os
import subprocess

pattern = r"^feat(\([A-Za-z0-9._/-]+\))?!?: .+"
for size in (65536, 1048576, 16777216):
    code = r'''
import os, sys
data = b"feat: " + b"x" * SIZE + b"\n"
os.write(sys.stdout.fileno(), data)
'''
    code = code.replace("SIZE", str(size))
    p = subprocess.run(
        ["bash", "-c", "set -o pipefail; python3 -c \"$1\" | grep -qE \"$2\"", "--", code, pattern],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    print(f"external_writer length={size} pipeline={p.returncode}")
PY

Repository: CodesWhat/portwing

Length of output: 148


🌐 Web query:

Bash builtin printf pipefail grep -q SIGPIPE long line

💡 Result:

When using the Bash builtin printf in a pipeline (e.g., printf ... | grep -q ...) with set -o pipefail enabled, you may encounter an intermittent failure where the pipeline exits with a non-zero status, even if the grep command successfully finds a match [1][2]. The root cause is a race condition: 1. grep -q is an "early-exit" command [2]. As soon as it finds a match, it closes its input pipe and exits with status 0 [1][2]. 2. If printf is still attempting to write the remainder of its output, it writes to a pipe that has been closed by grep [1][2]. 3. The operating system sends a SIGPIPE signal to the process writing the data (printf) [1][2]. 4. By default, a process that receives SIGPIPE terminates with a non-zero exit status (typically 141) [3][4][5]. 5. Because set -o pipefail is active, Bash sees this non-zero exit status from printf and marks the entire pipeline as failed, even if grep succeeded [1][2]. This behavior is particularly common with large amounts of data or "long lines" because the larger volume of data makes it more likely that printf will still be writing when grep exits [1][6]. Recommended solutions: - Disable pipefail for the specific pipeline where this occurs by running set +o pipefail locally [2][7]. - If you must use pipefail, you can ignore the specific error code caused by SIGPIPE (141) after the pipeline executes, or ensure the writer handles SIGPIPE gracefully [3][5]. - A robust, non-timing-dependent approach is preferred over trying to "fix" the race condition [1]. For example, instead of relying on early exit within a pipeline, you might process the data in a way that ensures the writer is not dependent on the reader's state [1][2]. Note that if your shell environment has SIGPIPE ignored (SIG_IGN), the write operation will instead return an EPIPE error, which can also trigger the printf builtin to report a write error and cause the pipeline to fail under pipefail [3][8][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -u

bash -c '
set -o pipefail
pattern="^feat(\([A-Za-z0-9._/-]+\))?!?: .+"

for n in 1048576 4194304 16777216 33554432; do
  subject=$(python3 -c "import sys; sys.stdout.write(\"feat: \" + \"x\" * int(sys.argv[1]))" "$n")

  set +e
  printf "%s\n" "$subject" | grep -qE "$pattern"
  ps=("${PIPESTATUS[@]}")
  set -e
  if [ "${ps[0]}" -ne 0 ] || [ "${ps[1]}" -ne 0 ]; then pipeline=1; else pipeline=0; fi
  printf "q length=%s pipeline=%s printf=%s grep=%s\n" \
    "$n" "$pipeline" "${ps[0]}" "${ps[1]}"

  set +e
  printf "%s\n" "$subject" | grep -E "$pattern" >/dev/null
  ps=("${PIPESTATUS[@]}")
  set -e
  if [ "${ps[0]}" -ne 0 ] || [ "${ps[1]}" -ne 0 ]; then pipeline=1; else pipeline=0; fi
  printf "full length=%s pipeline=%s printf=%s grep=%s\n" \
    "$n" "$pipeline" "${ps[0]}" "${ps[1]}"
done
'

Repository: CodesWhat/portwing

Length of output: 524


🌐 Web query:

Bash 3.2 builtin printf grep -q pipefail SIGPIPE

💡 Result:

In Bash 3.2, interactions between builtins like printf, external commands like grep, and the pipefail option are heavily influenced by how the shell handles SIGPIPE and write errors [1][2]. Key technical aspects include: 1. Pipefail Behavior: When set -o pipefail is enabled, the exit status of a pipeline is the status of the last (rightmost) command that exited with a non-zero status [3]. If a command in the middle of a pipeline exits due to SIGPIPE (a common occurrence when a downstream command like grep -q terminates early), that command's non-zero status will cause the entire pipeline to be considered failed [4][5][6]. 2. SIGPIPE and Early Termination: Commands like grep -q terminate as soon as they find a match, closing their end of the pipe [5][6]. If the upstream command (e.g., printf, echo, or other data producers) is still attempting to write, it receives a SIGPIPE signal [5]. Because SIGPIPE is a terminating signal, the process exits with a non-zero status (typically 141), which triggers the failure behavior in a pipefail-enabled pipeline [4][5]. 3. Bash 3.2 Specific Changes: Bash 3.2 introduced significant changes that made these interactions more visible [1][2]. Specifically, the shell began reporting write errors for builtins like echo and printf that were previously silent [1][2]. In older versions, these errors might not have been reported to the user, but in 3.2, if the shell catches a terminating signal like SIGPIPE, it may report the write error explicitly [1][2]. This behavior was later refined in Bash 4.0 to improve how output builtins handle these situations [1]. 4. Race Conditions: These issues are frequently timing-dependent (race conditions) [4][5][6]. If an upstream producer finishes writing all its data before the downstream consumer exits, no SIGPIPE occurs, and the pipeline may succeed despite the use of pipefail [4][5]. If the output is large or the producer is slow, the downstream consumer will exit first, resulting in a SIGPIPE and a pipeline failure [5][6]. Because of this, combining pipefail with commands that terminate early (like grep -q or head) is widely documented as prone to inconsistent results in Bash [5][6][7].

Citations:


Replace grep -qE in the pipefail pipeline.

When a valid subject is long enough, grep -qE can close the pipe while printf is still writing. Bash 3.2 can report SIGPIPE, and pipefail then rejects the commit. Use grep -E ... >/dev/null or a Bash regular expression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/validate-commit-msg.sh` around lines 50 - 52, Update the validation
pipeline inside the ctype loop to avoid grep’s early pipe closure: replace grep
-qE with grep -E redirected to /dev/null, or use a Bash regular-expression
match, while preserving the existing commit-subject pattern and success
behavior.

@biggest-littlest biggest-littlest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forward-ports the OpenSSF assurance evidence from main and syncs dev/v0.9 for the v0.9.3 cut.

@ALARGECOMPANY ALARGECOMPANY left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync looks right: main's evidence files survive the merge and the changelog entry is in place.

@scttbnsn
scttbnsn merged commit 66053e1 into main Aug 12, 2026
38 of 39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants