Skip to content

fix(io): emit valid GitHub log groups, and only on GitHub - #113

Merged
jahvon merged 1 commit into
mainfrom
fix/ci-log-grouping
Aug 27, 2026
Merged

fix(io): emit valid GitHub log groups, and only on GitHub#113
jahvon merged 1 commit into
mainfrom
fix/ci-log-grouping

Conversation

@jahvon

@jahvon jahvon commented Aug 27, 2026

Copy link
Copy Markdown
Member

Part of making flow's CI logs readable — see flowexec/flow#448 and flowexec/action#3.

Summary

Three problems with CI log grouping, all of which put output where a reader cannot see it.

1. Workflow commands were emitted on every CI, not just GitHub. isCI() is true when CI=true, so GitLab, CircleCI, Buildkite and friends printed literal ::group::build app lines instead of a header. ::group:: is GitHub syntax, not a CI convention — it is now gated on GITHUB_ACTIONS, and every other provider gets the styled header that local runs already got. isCI() had no other callers, so it is removed rather than left dead.

2. Nested groups produced invalid output. GitHub does not support nesting: a second ::group:: before the first closes swallows the rest of the log. Depth is now tracked so only the outermost pair is emitted, and a nested caller degrades to no group rather than a broken one.

3. An unmatched EndGroup emitted a stray ::endgroup::, which closes whatever GitHub had open around it. It is now a no-op.

Group names are also escaped per GitHub's spec — an unescaped newline previously ended the command early and leaked the remainder as ordinary log lines.

Why now

flow wraps every serial/parallel executable's output in a group named after the ref. For a single-task run that means the entire output is collapsed behind one click for no structural benefit — flow test unit shows nothing but a group header until you expand it.

The fix on flow's side is to skip the group when there is only one child task. Point 3 is what makes that safe: the caller can drop BeginGroup without having to mirror the condition around EndGroup.

Notable Changes

  • isCI() is replaced by isGitHubActions(); it had no remaining callers.
  • groupDepth is guarded by its own mutex, consistent with the existing modeMu/writeMu split.
  • No change to local (non-CI) output.

Testing

Five cases in io/ci_group_test.go, covering each behavior above: the GitHub path, the non-GitHub CI path, nesting, the unmatched EndGroup, and escaping of % and newlines.

go test -race ./... and golangci-lint run ./... both pass locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R328pa3FUUfga4gYah1iQi

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Three problems with CI log grouping, all of which put output where a reader
cannot see it.

Workflow commands were gated on isCI, which is true for any provider. ::group::
is GitHub syntax, so every other CI printed literal "::group::name" lines
instead of a header. They are now gated on GITHUB_ACTIONS, and everything else
gets the styled header that local runs already got. isCI had no other callers,
so it is removed rather than left dead.

Nested groups are not supported by GitHub: a second ::group:: before the first
closes swallows the rest of the log. Depth is now tracked so only the outermost
pair is emitted, and a nested caller degrades to no group rather than a broken
one.

An unmatched EndGroup emitted a stray ::endgroup::, which closes whatever
GitHub had open around it. It is now a no-op, so a caller can skip BeginGroup
conditionally without mirroring that condition on the way out — which is what
lets flow suppress the group for single-task runs, where wrapping the only
output in a collapsed group hides it for no benefit.

Group names are escaped per GitHub's spec; an unescaped newline previously
ended the command early and leaked the remainder as ordinary log lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R328pa3FUUfga4gYah1iQi
@jahvon
jahvon force-pushed the fix/ci-log-grouping branch from ac80913 to 2bcef05 Compare August 27, 2026 04:57
jahvon added a commit to flowexec/action that referenced this pull request Aug 27, 2026
Companion to flowexec/tuikit#113 and flowexec/flow#448, which fix the
log grouping this works around.

# Summary

When a flow executable failed, the reason was hard or impossible to find
from the run page. Three changes:

**1. stderr was thrown away.** `execute.sh` captured it to a temp file,
mined it for a JSON error code, and `rm -f`'d it without ever printing
it. Anything flow wrote to stderr was lost outright — most importantly
the structured error envelope under `--output json`, whose message never
reached the log. It is now echoed through.

**2. The annotation reported only an exit code.** It now carries the
error code and message when flow emits a structured envelope:

```
::error::flow test unit failed with exit code 1 (EXECUTION_FAILED): unable to parse file - 14:1
```

Newlines are folded to spaces, since a literal newline ends a workflow
command early.

**3. A step summary is written for both outcomes.**

> ### ❌ `flow test unit`
>
> Exit code `1` · `EXECUTION_FAILED`
> ```
> unable to parse file - 14:1
> ```

# Why this matters

flow wraps a serial/parallel run's output in a GitHub log group, and
groups are **always collapsed** — GitHub offers no way to default one
open. So the actual output sits one click away, and on failure there was
nothing outside the group saying what went wrong. Annotations and the
step summary render on the run page itself, where a collapsed group does
not.

flowexec/tuikit#113 and flowexec/flow#448 stop burying single-task
output in a group in the first place. These changes are complementary:
they make failures visible regardless of how the output is grouped.

# Testing

The action has no CI workflows to add cases to, so I exercised
`execute.sh` directly against a stand-in `flow` binary, checking
`GITHUB_OUTPUT` and `GITHUB_STEP_SUMMARY`:

| Case | Result |
|---|---|
| Success | Clean ✅ summary, no annotation, exit 0 |
| Failure with JSON envelope | stderr printed; annotation carries code +
message; summary shows both; `error-code` output still set |
| Failure with plain multi-line stderr | stderr printed (**previously
lost entirely**); annotation stays single-line; summary shows exit code
|

`bash -n scripts/execute.sh` is clean. The `capture`/`upload` path and
the `continue-on-error` path are unchanged.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jahvon
jahvon merged commit 917305a into main Aug 27, 2026
9 checks passed
@jahvon
jahvon deleted the fix/ci-log-grouping branch August 27, 2026 05:20
jahvon added a commit to flowexec/flow that referenced this pull request Aug 27, 2026
Companion to flowexec/tuikit#113 and flowexec/action#3.

# Summary

A serial or parallel executable wrapped its whole run in a GitHub log
group named after the ref. GitHub log groups are **always collapsed**,
and there is no way to default one open — so a single-task run hid the
only output worth reading behind a click, gaining no structure in
exchange.

From a real CI run of `test unit`:

```
##[endgroup]
Executing: flow test unit --param CI=true
##[group]test flow/unit          ← everything below is collapsed
ok  github.com/flowexec/flow/v2/cmd/internal  1.271s
...
```

`test unit` is a serial executable with one step, so the group had
exactly one child.

# Change

Emit the group only when there is more than one task:

```go
groupOutput := parentTask == nil && len(execs) > 1
```

`EndGroup` is guarded by the same condition rather than relying on the
logger to ignore an unmatched call, so this is correct against the
currently pinned tuikit (v0.4.1) as well as the version that hardens it.

# Verified

Built and run with `CI=true GITHUB_ACTIONS=true`:

| Case | Before | After |
|---|---|---|
| `flow test unit` (1 step) | `::group::test flow/unit` — all output
collapsed | no group, output visible |
| serial, 2 steps | grouped | `::group::run flow/many` … `::endgroup::`
— unchanged |
| parallel, 1 step | grouped | no group |
| parallel, 2+ steps | grouped | unchanged |

`flow validate` passes.

# Related

- flowexec/tuikit#113 — stops emitting GitHub-only `::group::` syntax on
other CI providers, prevents invalid nested groups, and makes an
unmatched `EndGroup` a no-op. Independent of this PR, but it is what
makes the guard above belt-and-braces rather than load-bearing.
- flowexec/action#3 — prints the stderr the action was discarding, and
adds a step summary plus a real error message in the failure annotation,
so a failure is visible on the run page without expanding any log at
all.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01R328pa3FUUfga4gYah1iQi

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant