Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,42 @@ nix-fast-build --result-format junit --result-file result.xml
nix-shell -p python3Packages.junit2html --run 'junit2html result.xml result.html'
```

## GitHub Actions Job Summaries

nix-fast-build can automatically generate GitHub Actions job summaries when running
in GitHub Actions. The summary includes:

- Overall build status
- Success/failure counts by operation type (EVAL, BUILD, UPLOAD, etc.)
- Detailed sections for failed builds with logs

### Automatic Detection

When running in GitHub Actions (detected via `GITHUB_ACTIONS` environment variable),
nix-fast-build will automatically write a summary to the file specified by the
`GITHUB_STEP_SUMMARY` environment variable:

```yaml
- name: Build with nix-fast-build
run: nix-fast-build --no-nom --skip-cached
```

### Custom Summary File

You can override the summary file location with the `--github-summary` flag:

```console
nix-fast-build --github-summary /tmp/summary.md
```

This is useful for:
- Testing the summary generation locally
- Using custom locations in CI environments
- Generating summaries outside of GitHub Actions

The summary includes collapsible log sections for failed builds, making it easy to
diagnose issues without leaving the GitHub Actions UI.

## Reference

```console
Expand All @@ -198,6 +234,7 @@ usage: nix-fast-build [-h] [-f FLAKE] [-j MAX_JOBS] [--option name value]
[--eval-workers EVAL_WORKERS]
[--result-file RESULT_FILE]
[--result-format {json,junit}]
[--github-summary GITHUB_SUMMARY]
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot drop

Copy link
Author

Choose a reason for hiding this comment

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

Done. Removed "GitHub Actions Job Summaries" section from README. (c4b7308)


options:
-h, --help show this help message and exit
Expand Down Expand Up @@ -241,6 +278,9 @@ options:
File to write build results to
--result-format {json,junit}
Format of the build result file
--github-summary GITHUB_SUMMARY
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot drop

Copy link
Author

Choose a reason for hiding this comment

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

Done. Removed --github-summary flag documentation from reference section. (c4b7308)

File to write GitHub Actions job summary to (defaults to
$GITHUB_STEP_SUMMARY if set)
--override-input input_path flake_url
Override a specific flake input (e.g. `dwarffs/nixpkgs`).
```
140 changes: 133 additions & 7 deletions nix_fast_build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class Options:
result_format: ResultFormat = ResultFormat.JSON
result_file: Path | None = None
override_inputs: list[list[str]] = field(default_factory=list)
github_summary: str | None = None

cachix_cache: str | None = None

Expand Down Expand Up @@ -110,6 +111,7 @@ class Result:
success: bool
duration: float
error: str | None
log_output: str | None = None


def _maybe_remote(
Expand Down Expand Up @@ -282,6 +284,12 @@ async def parse_args(args: list[str]) -> Options:
metavar=("input_path", "flake_url"),
help="Override a specific flake input (e.g. `dwarffs/nixpkgs`).",
)
parser.add_argument(
"--github-summary",
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot env variable is enough here.

Copy link
Author

Choose a reason for hiding this comment

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

Done. Removed the --github-summary CLI argument. Now uses only GITHUB_STEP_SUMMARY environment variable. (c4b7308)

type=str,
default=None,
help="File to write GitHub Actions job summary to (defaults to $GITHUB_STEP_SUMMARY if set)",
)

a = parser.parse_args(args)

Expand Down Expand Up @@ -340,6 +348,7 @@ async def parse_args(args: list[str]) -> Options:
result_format=ResultFormat[a.result_format.upper()],
result_file=a.result_file,
override_inputs=a.override_input,
github_summary=a.github_summary,
)


Expand Down Expand Up @@ -595,18 +604,40 @@ class Build:

async def build(
self, stack: AsyncExitStack, build_output: IO[str], opts: Options
) -> int:
) -> tuple[int, str]:
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot make this a dataclass

Copy link
Author

Choose a reason for hiding this comment

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

Done. Created BuildResult dataclass to replace the tuple return type. (9cb1c16)

"""Build and return (return_code, stderr_output)."""
# Create a temporary file to capture stderr
stderr_lines: list[bytes] = []

proc = await stack.enter_async_context(
nix_build(self.attr, self.drv_path, build_output, opts)
)

# Capture stderr if available
if proc.stderr:
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot use nix log here instead once a build failed.

Copy link
Author

Choose a reason for hiding this comment

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

Done. Changed to use nix log command to fetch build logs after build failure instead of capturing stderr during build. (c4b7308)

async def capture_stderr() -> None:
assert proc.stderr
async for line in proc.stderr:
stderr_lines.append(line)

# Start capturing stderr in background
stderr_task = asyncio.create_task(capture_stderr())
else:
stderr_task = None

rc = 0
for _ in range(opts.retries + 1):
rc = await proc.wait()
if rc == 0:
logger.debug(f"build {self.attr} succeeded")
return rc
if stderr_task:
await stderr_task
return rc, b"".join(stderr_lines).decode("utf-8", errors="replace")
logger.warning(f"build {self.attr} exited with {rc}")
return rc

if stderr_task:
await stderr_task
return rc, b"".join(stderr_lines).decode("utf-8", errors="replace")

async def nix_copy(
self, args: list[str], exit_stack: AsyncExitStack, opts: Options
Expand Down Expand Up @@ -728,7 +759,10 @@ async def nix_build(

args = maybe_remote(args, opts)
logger.debug("run %s", shlex.join(args))
proc = await asyncio.create_subprocess_exec(*args, stderr=stderr)
# Always capture stderr to get build logs for GitHub summary
proc = await asyncio.create_subprocess_exec(
*args, stderr=asyncio.subprocess.PIPE if stderr is None else stderr
)
try:
yield proc
finally:
Expand Down Expand Up @@ -825,15 +859,15 @@ async def run_builds(
drv_paths.add(job.drv_path)
build = Build(job.attr, job.drv_path, job.outputs)
start_time = timeit.default_timer()
rc = await build.build(stack, build_output, opts)
rc, log_output = await build.build(stack, build_output, opts)
results.append(
Result(
result_type=ResultType.BUILD,
attr=job.attr,
success=rc == 0,
duration=timeit.default_timer() - start_time,
# TODO: add log output here
error=f"build exited with {rc}" if rc != 0 else None,
log_output=log_output if rc != 0 else None,
)
)
if rc != 0:
Expand Down Expand Up @@ -968,6 +1002,93 @@ class Summary:
failed_attrs: list[str] = field(default_factory=list)


def is_github_actions() -> bool:
"""Detect if running inside GitHub Actions."""
return os.environ.get("GITHUB_ACTIONS") == "true"
Copy link
Owner

Choose a reason for hiding this comment

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

@copilot still document this behaviour in the readme.

Copy link
Author

Choose a reason for hiding this comment

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

Done. Added "GitHub Actions Job Summaries" section to README documenting the automatic detection and build log behavior. (9cb1c16)



def get_github_summary_file(opts: Options) -> Path | None:
"""Get the GitHub summary file path."""
# Use explicit argument if provided
if opts.github_summary:
return Path(opts.github_summary)
# Otherwise use GITHUB_STEP_SUMMARY if in GitHub Actions
if is_github_actions():
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
return Path(summary_path)
return None


def write_github_summary(
summary_file: Path, opts: Options, results: list[Result], rc: int
) -> None:
"""Write GitHub Actions job summary in markdown format."""
# Group results by type
stats_by_type: dict[ResultType, Summary] = defaultdict(Summary)
failed_builds: list[Result] = []

for r in results:
stats = stats_by_type[r.result_type]
stats.successes += 1 if r.success else 0
stats.failures += 1 if not r.success else 0
if not r.success:
stats.failed_attrs.append(r.attr)
if r.result_type == ResultType.BUILD:
failed_builds.append(r)

# Build the markdown content
lines = []
lines.append("# nix-fast-build Results\n")

# Overall status
if rc == 0:
lines.append("## ✅ Build Successful\n")
else:
lines.append("## ❌ Build Failed\n")

# Summary table
lines.append("## Summary\n")
lines.append("| Type | Successes | Failures |")
lines.append("|------|-----------|----------|")

for result_type, summary in sorted(stats_by_type.items(), key=lambda x: x[0].name):
emoji = "✅" if summary.failures == 0 else "❌"
lines.append(
f"| {emoji} {result_type.name} | {summary.successes} | {summary.failures} |"
)

# Failed builds section with logs
if failed_builds:
lines.append("\n## Failed Builds\n")
for result in failed_builds:
attr_name = f"{opts.flake_url}#{opts.flake_fragment}.{result.attr}"
lines.append(f"\n### ❌ {result.attr}\n")
lines.append(f"**Full attribute:** `{attr_name}`\n")
lines.append(f"**Duration:** {result.duration:.2f}s\n")
if result.error:
lines.append(f"**Error:** {result.error}\n")
if result.log_output:
# Truncate very long logs (keep last 100 lines)
log_lines = result.log_output.strip().split("\n")
if len(log_lines) > 100:
log_lines = ["... (truncated, showing last 100 lines) ...", *log_lines[-100:]]
lines.append("\n<details>")
lines.append(f"<summary>Build Log ({len(log_lines)} lines)</summary>\n")
lines.append("```")
lines.extend(log_lines)
lines.append("```")
lines.append("</details>\n")

# Write to file
try:
with summary_file.open("a") as f:
f.write("\n".join(lines))
logger.info(f"GitHub summary written to {summary_file}")
except OSError as e:
logger.warning(f"Failed to write GitHub summary to {summary_file}: {e}")


async def run(stack: AsyncExitStack, opts: Options) -> int:
if opts.remote:
tmp_dir = await stack.enter_async_context(remote_temp_dir(opts))
Expand Down Expand Up @@ -1100,7 +1221,7 @@ async def run(stack: AsyncExitStack, opts: Options) -> int:
assert task.done(), f"Task {task.get_name()} is not done"

rc = 0
stats_by_type = defaultdict(Summary)
stats_by_type: dict[ResultType, Summary] = defaultdict(Summary)
for r in results:
stats = stats_by_type[r.result_type]
stats.successes += 1 if r.success else 0
Expand Down Expand Up @@ -1137,6 +1258,11 @@ async def run(stack: AsyncExitStack, opts: Options) -> int:
elif opts.result_format == ResultFormat.JUNIT:
dump_junit_xml(f, opts.flake_url, opts.flake_fragment, results)

# Write GitHub Actions summary if configured
github_summary_file = get_github_summary_file(opts)
if github_summary_file:
write_github_summary(github_summary_file, opts, results, rc)

return rc


Expand Down
Loading