Skip to content

πŸ› fix(crux): cmdlog next_seq never skips an unfinished command's ended line #58144

πŸ› fix(crux): cmdlog next_seq never skips an unfinished command's ended line

πŸ› fix(crux): cmdlog next_seq never skips an unfinished command's ended line #58144

# changelog-fragment-guard (#5675): PR-level guard for the changelog.d/
# fragment convention, modelled on testutil-guard.yml (#5603).
#
# Changelog entries used to be appended directly under CHANGELOG.md's
# `## Unreleased` heading, so any two PRs landing near each other edited the
# same lines under the same heading and the second one to merge always hit a
# structural conflict β€” six rebases of unrelated PRs on 2026-09-02 alone.
# Entries now land as one file per PR under changelog.d/ (see its README.md),
# compiled into Unreleased at release time by src/scripts/compile-changelog.sh
# inside tagged-release.yml. This guard is what keeps new PRs on the fragment
# path.
#
# WHAT IT ASKS: a PR changing code under src/** must either
# - add a changelog.d/<category>-<pr-or-slug>.md fragment, or
# - carry the `no-changelog` label.
# Docs-only and test-only changes are exempt without the label β€” the same
# exemption the changelog discipline has always had (CHANGELOG.md's "How we
# maintain this file": routine refactors, test-only changes, and dependency
# churn are out of scope; changelog-reminder.yml applies the same carve-out).
# It also validates any fragments the PR does add (name and shape), so a
# miscategorized fragment fails at PR time rather than at release time β€”
# the category prefix drives the semver bump.
#
# TRANSITION (date-gated below, remove after it lapses): until 2026-09-09 a
# direct CHANGELOG.md edit still satisfies the guard, so PRs already in flight
# with entries under `## Unreleased` land without rework. The pass message
# tells those authors to prefer fragments. After the window a direct-edit-only
# PR goes red here β€” still non-blocking β€” because a direct edit is exactly the
# conflict generator this migration removes.
#
# REQUIRED CONTEXT β€” this check is a required status check on the protected
# release lines (#11250), so it MUST report on every PR. GitHub leaves a
# path-filtered required check "expected" forever on PRs whose diff never
# matches `on.paths`, and such a PR can never merge (hivecommons/hive#11234, a
# workflow-only change, was refused with `405 Required status check
# "changelog-fragment-guard" is expected`). The path gate therefore lives in
# the job, not in the trigger: check-changelog-fragment.sh exits 0 with "no
# non-exempt code changes under src/" on docs-, test-, CI- and bin-only PRs,
# which is the same exemption `on.paths` used to express. Do NOT reintroduce
# an `on.paths` filter here while the context is required.
#
# Registered in .github/release-lines.yml (`[-v2]`): changelog.d/ and the
# compiler exist only on v4, and v2 is sunset β€” a PR to v2 would run the v2
# merge-ref's workflows anyway, where this file does not exist.
name: changelog-fragment-guard
on:
pull_request:
branches: [v4, v5, v6]
# Default types plus labeled/unlabeled: adding or removing `no-changelog`
# must re-evaluate the verdict without waiting for a push.
types: [opened, reopened, synchronize, labeled, unlabeled]
# No `paths:` filter on purpose β€” see REQUIRED CONTEXT above. The job
# itself decides whether the diff is changelog-relevant.
permissions:
# contents: write lets the autofix step push a generated fragment to
# same-repo PR branches; pull-requests: write posts the sticky guidance
# comment. Both are read-only on fork PRs and those steps are skipped there.
# No PR code is executed in this workflow β€” only git, grep and echo run.
# actions: write lets the autofix dispatch pr-verifier.yml on the head it
# pushed with github.token, the one event shape that token may fire
# (#17036).
contents: write
pull-requests: write
actions: write
# Only a new push may cancel a running guard: the superseded run is on the
# old head SHA, where nothing merges anyway. Label events must NOT cancel β€”
# hive's bots apply three or four labels seconds after every push, each
# label spawned a run that cancelled the push-triggered one, and the head SHA
# was left with CANCELLED check-runs for this *required* context. GitHub
# scores every run of a required name, so one cancelled sibling kept the PR
# at mergeStateStatus=BLOCKED with a green survivor beside it (#11250).
concurrency:
group: changelog-fragment-guard-${{ github.event.pull_request.number }}
cancel-in-progress: ${{ github.event.action == 'synchronize' }}
jobs:
changelog-fragment-guard:
name: changelog-fragment-guard
# No job-level `if:` on label events β€” on purpose (#14755). This job used
# to skip itself for every label other than `no-changelog`, and a skipped
# job still reports a check-run that GitHub scores as *passing* for a
# required context. hive's labeler adds three or four labels seconds
# after every push, so a genuine failure on the push-triggered run was
# superseded by a "skipped" sibling on the same head SHA and tide merged
# a malformed fragment (#14629), which then broke the release `decide`
# job on v5 and held every open v5 PR behind a red base. The guard is
# cheap (git, grep, echo); re-running it on each label event keeps the
# reported verdict truthful. Label runs are not cancelled by concurrency
# (see above), so they cannot leave CANCELLED check-runs either.
# Forward-merge sync PRs (v5-topup.yml / v6-topup.yml, and the hand-rolled
# ones maintainers open when those conflict) live on sync/* branches β€”
# or on <lane>/sync-vN-to-vM when an agent opens the top-up, since the
# push broker only accepts <lane>/... heads (#10498) β€” and carry the
# source line's entire history. The three-dot diff below
# re-attributes every src/ change and every release-time CHANGELOG.md
# compilation edit to the sync PR, so it can never satisfy the guard β€”
# each carried change already passed it on its own PR (#7783).
# Required on v5: keep on the self-hosted pool so a GitHub-hosted runner
# outage does not make otherwise-mergeable PRs impossible to merge.
runs-on: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- name: Detect forward-merge sync branch
id: sync_branch
run: |
HEAD_REF=${GITHUB_HEAD_REF:-}
case "$HEAD_REF" in
# Human-opened top-ups live on sync/*; agent-opened ones are
# forced onto <lane>/sync-vN-to-vM by the push broker. Keep in
# step with src/scripts/check-changelog-fragment.sh.
sync/*|*/sync-v[0-9]*-to-v[0-9]*) echo "skip=true" >> "$GITHUB_OUTPUT" ;;
*) echo "skip=false" >> "$GITHUB_OUTPUT" ;;
esac
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: ${{ steps.sync_branch.outputs.skip != 'true' }}
with:
# Full history so the merge base with the target branch exists
# locally and the three-dot diff below is computable.
fetch-depth: 0
# The autofix push uses TOPUP_PUSH_TOKEN when set: a push made with
# that PAT fires `pull_request` AND `pull_request_target` runs, so
# every check re-reports on the new head. With github.token the
# push lands and fires the `pull_request` runs, but NO
# `pull_request_target` run (observed on #16567's b9cd4bac: eleven
# pull_request runs with actor=github-actions[bot], no PR Verifier).
# Dependabot-triggered runs see no Actions secrets, so Dependabot PRs
# (#16567) always take the github.token path; the dispatch step
# after the push re-runs pr-verifier for them. Same optional-PAT
# convention as cli-pin-bump.yml / v5-topup.yml / notice-autofix.yml.
token: ${{ secrets.TOPUP_PUSH_TOKEN != '' && secrets.TOPUP_PUSH_TOKEN || github.token }}
- name: Code changes must carry a changelog.d fragment (or no-changelog)
id: guard
if: ${{ steps.sync_branch.outputs.skip != 'true' }}
env:
BASE_REF: ${{ github.base_ref }}
# Read from the event payload, not the API: needs no token scope and
# therefore behaves identically on fork PRs (#4440's lesson).
HAS_NO_CHANGELOG_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'no-changelog') }}
# The guard copies any src/changelog.d/ fragments it rejected here so
# the autofix and comment steps below can act on them (#11919).
MISPLACED_OUT: ${{ runner.temp }}/changelog-misplaced.txt
run: |
set -euo pipefail
src/scripts/check-changelog-fragment.sh "$BASE_REF"
# api-reference.md and api-reference/*.md `pkg/<file>:<line>` citations go stale when a PR edits
# a route-registering server.go. The `relative links and anchors resolve`
# job reports that too, but it is not a required check, so the causing PR
# merged red and every later PR inherited the failure (#11866). This
# context is required, so the drift fails the PR that caused it. Pure
# grep/sed, no toolchain.
- name: Route-registering or static page files changed β€” api-reference citations must be resynced
if: ${{ steps.sync_branch.outputs.skip != 'true' }}
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
if ! git diff --name-only "origin/${BASE_REF}...HEAD" | grep -Eq '^src/pkg/(hub|dashboard)/server\.go$|^src/pkg/(hub|dashboard)/static/'; then
echo "No server.go or hub/dashboard static page change in this PR β€” nothing to check."
exit 0
fi
if ! bash src/scripts/check-api-reference-citations.sh; then
echo "::error::This PR edited a server.go or static pages and src/docs/api-reference.md / api-reference/*.md citations drifted. Run 'bash src/scripts/check-api-reference-citations.sh --fix' and commit the result. If static pages under src/pkg/dashboard/ also changed, lower the matching baseline in src/pkg/dashboard/webstatic/style_ratchet_test.go (styleRatchetBaselines) in the same PR."
exit 1
fi
# Most guard failures are humans who never heard of changelog.d. For
# same-repo PRs (the author already has push rights to the branch, so
# this adds no privilege), generate a fragment from the PR title and push
# it signed-off; the author can reword it or swap in `no-changelog`.
# Fork PRs cannot be pushed to and only get the comment below.
- name: Autofix β€” add a fragment derived from the PR title
id: autofix
if: ${{ always() && steps.guard.outcome == 'failure' && steps.sync_branch.outputs.skip != 'true' && github.event.pull_request.head.repo.full_name == github.repository }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }}
PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
MISPLACED_FILE: ${{ runner.temp }}/changelog-misplaced.txt
run: |
set -euo pipefail
echo "pushed=false" >> "$GITHUB_OUTPUT"
# A fragment under src/changelog.d/ is never compiled. Generating a
# title-only twin at the root would bury the hand-written entry
# (#11919) β€” fail with the move instruction instead.
if [ -s "$MISPLACED_FILE" ]; then
while IFS= read -r m; do
[ -z "$m" ] && continue
echo "::error file=$m::misplaced fragment β€” not autofixing. Run: git mv $m changelog.d/$(basename "$m")"
done < "$MISPLACED_FILE"
exit 1
fi
# Only the "no fragment" failure is auto-fixable; a malformed
# fragment (wrong name / no bullet) is the author's to correct.
if [ -s fragments.txt ]; then
echo "PR already has a fragment that failed validation β€” not autofixing."
exit 0
fi
# Sanitise the title: single line, printable ASCII/UTF-8 letters,
# digits and common punctuation; strip the leading emoji/symbols and
# any HTML-comment opener so the entry cannot smuggle a release marker.
entry="$(printf '%s' "$PR_TITLE" | tr -d '\r\n' | tr -cd '[:print:]' | sed -E 's/^[^[:alnum:]]+//; s/<!--//g; s/-->//g; s/[[:space:]]+$//' | cut -c1-200)"
[ -n "$entry" ] || entry="Change from PR #${PR_NUMBER}"
lower="$(printf '%s' "$PR_TITLE" | tr '[:upper:]' '[:lower:]')"
category=changed
case "$lower" in
*πŸ›*|fix*|bug*|*"fix("*|*"fix:"*) category=fixed ;;
*✨*|feat*|add*|*"feat("*|*"feat:"*) category=added ;;
*πŸ”’*|*security*|*"sec("*|*"sec:"*|*cve-*) category=security ;;
*deprecat*) category=deprecated ;;
esac
f="changelog.d/${category}-${PR_NUMBER}.md"
if [ -e "$f" ]; then
echo "$f already exists on the branch β€” not overwriting."
exit 0
fi
printf -- '- %s (#%s)\n' "$entry" "$PR_NUMBER" > "$f"
echo "Generated $f:"; cat "$f"
# Author AND sign off as the PR author's GitHub noreply identity, not
# as github-actions[bot]. The squash merge attributes the landed
# commit to the PR author while keeping this message, so a bot
# Signed-off-by here becomes a permanent mismatched-signoff on the
# protected branch β€” exactly what dco-squash-attribution.yml
# (check-squash-signoff-attribution.sh) rejects, which turned every
# autofixed human PR red. The noreply form <id>+<login>@... is the
# one that gate accepts for the PR author; the bot is credited with
# Co-authored-by, the correct trailer for an automated contributor.
author_email="${PR_AUTHOR_ID}+${PR_AUTHOR_LOGIN}@users.noreply.github.com"
git config user.name "$PR_AUTHOR_LOGIN"
git config user.email "$author_email"
git add -- "$f"
git commit -m "changelog: fragment for #${PR_NUMBER}
Generated by changelog-fragment-guard from the PR title (#5675).
Edit the wording freely, or replace this file with the no-changelog
label if the change is not user-visible.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: ${PR_AUTHOR_LOGIN} <${author_email}>"
# --force-with-lease against the SHA this run evaluated: never
# clobber a push the author made while this job ran.
# When this push was made with github.token it fires no
# `pull_request_target` run, so pr-verifier (a required context)
# would stay "expected"; the step below re-triggers it (#17036).
if git push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" origin "HEAD:refs/heads/${HEAD_REF}"; then
echo "pushed=true" >> "$GITHUB_OUTPUT"
echo "fragment=$f" >> "$GITHUB_OUTPUT"
else
echo "::warning::could not push the generated fragment (branch moved or token lacks push); leaving guidance comment instead"
fi
# Deterministically re-run pr-verifier on the head just pushed (#17036).
# On #16567 the autofix push landed 47s after the last prow label and
# nothing relabelled the PR for three days, so pr-verifier's new
# labeled/unlabeled types alone had no producer. When the push used
# TOPUP_PUSH_TOKEN it already fired `pull_request_target` and nothing
# more is needed. When it used github.token, the only events that token
# may trigger are workflow_dispatch and repository_dispatch, so dispatch
# pr-verifier.yml on the PR branch: the run's check lands on the branch
# tip, which is the head just pushed. The autofix only pushes to
# same-repo branches, so this never dispatches a fork's workflow file.
- name: Dispatch pr-verifier on the autofix head (github.token push)
if: ${{ steps.autofix.outputs.pushed == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
HAS_PUSH_TOKEN: ${{ secrets.TOPUP_PUSH_TOKEN != '' }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
if [ "$HAS_PUSH_TOKEN" = "true" ]; then
echo "Pushed with TOPUP_PUSH_TOKEN β€” the push itself fired pull_request_target; nothing to dispatch."
exit 0
fi
if gh workflow run pr-verifier.yml --repo "$GITHUB_REPOSITORY" --ref "$HEAD_REF" -f "pr_number=${PR_NUMBER}"; then
echo "dispatched pr-verifier.yml on ${HEAD_REF} for #${PR_NUMBER}"
else
# Most likely the branch's pr-verifier.yml predates the
# workflow_dispatch trigger (HTTP 422): a rebase onto the base
# branch fixes it, and the rebase push fires everything anyway.
echo "::warning::could not dispatch pr-verifier.yml on ${HEAD_REF}; the required 'verify PR contents' context stays expected on the new head until a push, a non-github.token label flip, or a close/reopen (#17036). Rebasing onto the base branch resolves it."
fi
# Whatever the autofix did, tell the author once (one sticky comment,
# updated in place) and remove the comment when the check passes.
- name: Post or update guidance comment
if: ${{ always() && steps.sync_branch.outputs.skip != 'true' && github.event.pull_request.head.repo.full_name == github.repository }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GUARD_OUTCOME: ${{ steps.guard.outcome }}
PR_NUMBER: ${{ github.event.pull_request.number }}
AUTOFIX_PUSHED: ${{ steps.autofix.outputs.pushed }}
AUTOFIX_FILE: ${{ steps.autofix.outputs.fragment }}
MISPLACED_FILE: ${{ runner.temp }}/changelog-misplaced.txt
with:
script: |
const marker = '<!-- changelog-fragment-guard -->';
const number = Number(process.env.PR_NUMBER);
const { owner, repo } = context.repo;
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: number, per_page: 100 });
const existing = comments.find(c => c.user?.type === 'Bot' && (c.body || '').includes(marker));
const failed = process.env.GUARD_OUTCOME === 'failure';
// Misplaced src/changelog.d/ fragments get their own comment with
// the exact git mv commands (next step); the generic "fragment
// needed" text would be wrong there (#11919).
const fs = require('fs');
const misplaced = fs.existsSync(process.env.MISPLACED_FILE) && fs.readFileSync(process.env.MISPLACED_FILE, 'utf8').trim() !== '';
if (!failed || misplaced) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
return;
}
if (process.env.AUTOFIX_PUSHED === 'true') {
const body = [
marker,
'### Changelog fragment added for you',
'',
'This PR changes code under `src/` and had no `changelog.d/` fragment, so CI pushed `' + process.env.AUTOFIX_FILE + '` generated from the PR title (signed-off, by github-actions[bot]).',
'',
'- Reword the bullet if the title is not how the entry should read in CHANGELOG.md (category prefix drives the semver bump: `added` / `changed` / `deprecated` / `fixed` / `security`).',
'- If the change is **not** user-visible, delete the file and ask a maintainer for the `no-changelog` label.',
'- Pull before your next push: `git pull --rebase`.',
'',
'See `changelog.d/README.md` (#5675). This comment is removed automatically once the check passes.',
].join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
}
return;
}
const body = [
marker,
'### Changelog fragment needed',
'',
'This PR changes code under `src/` but has no `changelog.d/` fragment and no `no-changelog` label, so the **changelog-fragment-guard** check fails.',
'',
'Pick one:',
'',
'**A. The change is user-visible** (feature, fix, new env var, behavior/security change) β€” add one file and push:',
'',
'```sh',
'# category: added | changed | deprecated | fixed | security',
"echo '- <one-line entry in the style of CHANGELOG.md>' > changelog.d/<category>-" + number + '.md',
'git add changelog.d && git commit -s -m "changelog: fragment for #' + number + '"',
'```',
'',
'**B. Not user-visible** (refactor, internal test plumbing, CI) β€” ask a maintainer to add the `no-changelog` label.',
'',
'Do not edit `CHANGELOG.md` directly; the release compiler assembles it from fragments (see `changelog.d/README.md`, #5675). This comment is removed automatically once the check passes.',
].join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
}
# Fragments under src/changelog.d/ are never compiled (#11919). Fork PRs
# cannot be pushed to and are skipped by the steps above, so every PR β€”
# fork or not β€” gets ONE sticky comment with the exact git mv commands,
# removed again once nothing is misplaced. Best-effort: if the token is
# read-only (some fork configurations) the commands are still in the job
# summary and the guard's ::error annotations.
- name: Post or update misplaced-fragment comment
if: ${{ always() && steps.sync_branch.outputs.skip != 'true' }}
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
MISPLACED_FILE: ${{ runner.temp }}/changelog-misplaced.txt
with:
script: |
const fs = require('fs');
const marker = '<!-- hive-changelog-misplaced -->';
const number = Number(process.env.PR_NUMBER);
const { owner, repo } = context.repo;
const files = fs.existsSync(process.env.MISPLACED_FILE)
? fs.readFileSync(process.env.MISPLACED_FILE, 'utf8').split('\n').map(s => s.trim()).filter(Boolean)
: [];
const cmds = files.map(f => 'git mv ' + f + ' changelog.d/' + f.split('/').pop());
if (cmds.length) {
await core.summary
.addHeading('Misplaced changelog fragments', 3)
.addRaw('Fragments live in `changelog.d/` at the repository root, not under `src/`. Run:\n\n')
.addCodeBlock(cmds.join('\n'), 'sh')
.write();
}
try {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100 });
const existing = comments.find(c => c.user?.type === 'Bot' && (c.body || '').includes(marker));
if (!cmds.length) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
return;
}
const body = [
marker,
'### Changelog fragment in the wrong directory',
'',
'Fragments live in `changelog.d/` at the **repository root**, not under `src/`. Files under `src/changelog.d/` are never compiled into `CHANGELOG.md`, so the **changelog-fragment-guard** check fails. Move them and push:',
'',
'```sh',
...cmds,
'git commit -s -m "changelog: move fragment to repo-root changelog.d/"',
'```',
'',
'See `changelog.d/README.md` (#5675, #11919). This comment is removed automatically once nothing is misplaced.',
].join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
}
} catch (e) {
core.warning('could not post the misplaced-fragment comment (' + e.message + '); the git mv commands are in the job summary');
}