Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ To make output more concise for CI environments, use the `--no-nom` flag. This
replaces `nom` with a streamlined status reporter, which updates only when
there's a change in the number of pending builds, uploads, or downloads.

## GitHub Actions Job Summaries

When running in GitHub Actions, nix-fast-build automatically generates job
summaries that appear directly in the Actions UI. The summary includes:

- Overall build status (✅ Success / ❌ Failed)
- Summary table with success/failure counts by operation type (EVAL, BUILD, UPLOAD, etc.)
- Detailed sections for each failed build with build logs

This feature is automatically enabled when the `GITHUB_ACTIONS` environment
variable is set to `true` and `GITHUB_STEP_SUMMARY` is available. No additional
configuration is required.

Example GitHub Actions workflow:

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

Build logs for failed packages are retrieved using `nix log` and displayed in
collapsible sections within the summary. Very long logs are automatically
truncated to the last 100 lines.

## Avoiding Redundant Package Downloads

By default, `nix build` will download pre-built packages, leading to needless
Expand Down
161 changes: 152 additions & 9 deletions nix_fast_build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class Result:
success: bool
duration: float
error: str | None
log_output: str | None = None


def _maybe_remote(
Expand Down Expand Up @@ -587,6 +588,14 @@ async def run_cachix_daemon_stop(
return await proc.wait()


@dataclass
class BuildResult:
"""Result of a build operation."""

return_code: int
log_output: str


@dataclass
class Build:
attr: str
Expand All @@ -595,18 +604,46 @@ class Build:

async def build(
self, stack: AsyncExitStack, build_output: IO[str], opts: Options
) -> int:
) -> BuildResult:
"""Build and return BuildResult."""
proc = await stack.enter_async_context(
nix_build(self.attr, self.drv_path, build_output, opts)
)

rc = 0
for _ in range(opts.retries + 1):
rc = await proc.wait()
if rc == 0:
logger.debug(f"build {self.attr} succeeded")
return rc
return BuildResult(return_code=rc, log_output="")
logger.warning(f"build {self.attr} exited with {rc}")
return rc

# If build failed, get the log using nix log
if rc != 0:
log_output = await self.get_build_log(opts)
return BuildResult(return_code=rc, log_output=log_output)

return BuildResult(return_code=rc, log_output="")

async def get_build_log(self, opts: Options) -> str:
"""Get build log using nix log command."""
cmd = maybe_remote(nix_command(["log", self.drv_path]), opts)
logger.debug("run %s", shlex.join(cmd))
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0 and stdout:
return stdout.decode("utf-8", errors="replace")
# If nix log fails, return stderr or empty
if stderr:
return stderr.decode("utf-8", errors="replace")
except OSError as e:
logger.debug(f"Failed to get build log: {e}")
return ""

async def nix_copy(
self, args: list[str], exit_stack: AsyncExitStack, opts: Options
Expand Down Expand Up @@ -825,18 +862,18 @@ 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)
build_result = await build.build(stack, build_output, opts)
results.append(
Result(
result_type=ResultType.BUILD,
attr=job.attr,
success=rc == 0,
success=build_result.return_code == 0,
duration=timeit.default_timer() - start_time,
# TODO: add log output here
error=f"build exited with {rc}" if rc != 0 else None,
error=f"build exited with {build_result.return_code}" if build_result.return_code != 0 else None,
log_output=build_result.log_output if build_result.return_code != 0 else None,
)
)
if rc != 0:
if build_result.return_code != 0:
continue
upload_queue.put_nowait(build)
download_queue.put_nowait(build)
Expand All @@ -855,6 +892,9 @@ async def run_uploads(
if isinstance(build, StopTask):
logger.debug("finish upload task")
return 0
# Skip if copy_to is not configured
if not opts.copy_to:
continue
start_time = timeit.default_timer()
rc = await build.upload(stack, opts)
results.append(
Expand All @@ -880,6 +920,9 @@ async def run_cachix_upload(
if isinstance(build, StopTask):
logger.debug("finish cachix upload task")
return 0
# Skip if cachix is not configured
if cachix_socket_path is None:
continue
start_time = timeit.default_timer()
rc = await build.upload_cachix(cachix_socket_path, opts)
results.append(
Expand All @@ -903,6 +946,9 @@ async def run_attic_upload(
if isinstance(build, StopTask):
logger.debug("finish attic upload task")
return 0
# Skip if attic is not configured
if opts.attic_cache is None:
continue
start_time = timeit.default_timer()
rc = await build.upload_attic(opts)
results.append(
Expand All @@ -927,6 +973,9 @@ async def run_downloads(
if isinstance(build, StopTask):
logger.debug("finish download task")
return 0
# Skip if not using remote or download is disabled
if not opts.remote_url or not opts.download:
continue
start_time = timeit.default_timer()
rc = await build.download(stack, opts)
results.append(
Expand Down Expand Up @@ -968,6 +1017,95 @@ 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() -> Path | None:
"""Get the GitHub summary file path from environment."""
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):
# Only show result types that have actual operations
if summary.successes == 0 and summary.failures == 0:
continue
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 +1238,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 +1275,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()
if github_summary_file:
write_github_summary(github_summary_file, opts, results, rc)

return rc


Expand Down
Loading