chore: Expose security-scan output so consumers can report failures #29

Closed
opened 2026-09-03 18:34:54 +00:00 by ahmad · 1 comment
Owner

Context

Forgejo Actions serves no job logs over its REST API — /actions/jobs/{id}/logs, /actions/runs/{n}/jobs/{i}/logs and /actions/tasks/{id}/logs all return 404. Consuming repositories work around this by having a job capture its own output with tee and post the tail as a pull request comment on failure.

That workaround cannot be applied to a job whose work is a composite action. In ahmad/expiro's PR pipeline the whole security job is:

  security:
    steps:
      - uses: actions/checkout@v4
      - uses: .../platform-actions/security-scan@v3

There is nothing in the consuming workflow to redirect: the action's steps run inside it, and their output is not reachable from outside. So a red security is a bare word, and the report-only findings the action is supposed to surface are readable only from a browser.

This came up while fixing the same gap for expiro's validate job (ahmad/expiro#25), where a red check blocked a pull request through three rounds of guessing. validate could be fixed in the consuming repository; security cannot.

Scope

  • In: security-scan writes its own output to a path it exposes as an output (or a documented fixed path), so a consuming workflow can post the tail on failure; the action's README documents it.
  • Out: changing what the scan checks, its enforce default, or any consuming repository's workflow — those follow once the output exists.

Acceptance criteria

  • Given the action runs, when it finishes, then its combined output is at a path the action exposes and the README names.
  • Given a step inside the action fails, when the job ends, then that path still holds the output written before the failure.
  • Given a consuming workflow reads that path in an if: failure() step, when the job fails, then the tail can be posted without reading the run log.
  • Given the action pipes output to tee, when a piped command fails, then the step still fails — set -o pipefail is set, because a run: step's shell is bash -e without it.

Note

ahmad/expiro's validate job is the worked pattern, and one measured detail from writing it: prefer set -o pipefail with cmd 2>&1 | tee -a log over exec > >(tee -a log) 2>&1. Both preserve the command's exit status, but the process-substitution form's tee outlives the shell — the log can be unflushed or absent when a later step reads it. The pipeline finishes writing before it returns.

### Context Forgejo Actions serves no job logs over its REST API — `/actions/jobs/{id}/logs`, `/actions/runs/{n}/jobs/{i}/logs` and `/actions/tasks/{id}/logs` all return 404. Consuming repositories work around this by having a job capture its own output with `tee` and post the tail as a pull request comment on failure. That workaround cannot be applied to a job whose work is a composite action. In `ahmad/expiro`'s PR pipeline the whole `security` job is: ```yaml security: steps: - uses: actions/checkout@v4 - uses: .../platform-actions/security-scan@v3 ``` There is nothing in the consuming workflow to redirect: the action's steps run inside it, and their output is not reachable from outside. So a red `security` is a bare word, and the report-only findings the action is supposed to surface are readable only from a browser. This came up while fixing the same gap for `expiro`'s `validate` job (`ahmad/expiro#25`), where a red check blocked a pull request through three rounds of guessing. `validate` could be fixed in the consuming repository; `security` cannot. ### Scope - In: `security-scan` writes its own output to a path it exposes as an output (or a documented fixed path), so a consuming workflow can post the tail on failure; the action's README documents it. - Out: changing what the scan checks, its `enforce` default, or any consuming repository's workflow — those follow once the output exists. ### Acceptance criteria - [ ] Given the action runs, when it finishes, then its combined output is at a path the action exposes and the README names. - [ ] Given a step inside the action fails, when the job ends, then that path still holds the output written before the failure. - [ ] Given a consuming workflow reads that path in an `if: failure()` step, when the job fails, then the tail can be posted without reading the run log. - [ ] Given the action pipes output to `tee`, when a piped command fails, then the step still fails — `set -o pipefail` is set, because a `run:` step's shell is `bash -e` without it. ### Note `ahmad/expiro`'s `validate` job is the worked pattern, and one measured detail from writing it: prefer `set -o pipefail` with `cmd 2>&1 | tee -a log` over `exec > >(tee -a log) 2>&1`. Both preserve the command's exit status, but the process-substitution form's `tee` outlives the shell — the log can be unflushed or absent when a later step reads it. The pipeline finishes writing before it returns.
ahmad self-assigned this 2026-09-04 05:03:17 +00:00
Author
Owner

Picking this up. expiro's pipeline has been unable to reach Docker Hub since 02:06, so this repository is where work can still finish; this issue is also the thing that unblocks expiro#25's fourth criterion, which I left visibly unmet for exactly this reason.

The approach. Each scanning step already captures its tool's exit code inline (cmd && rc=0 || rc=$?) because the runner injects -e. Adding 2>&1 | tee -a "$LOG" keeps that working: every step already sets pipefail, so the pipeline's status is the tool's, not tee's. That is the trap recorded in the knowledge file and it is already avoided here — worth stating because it is the one change that could silently turn a failing scan green.

On exposing the path. I am declaring outputs.log-path with a literal value rather than threading it through a step's GITHUB_OUTPUT. A step that sets it would need if: always(), or it would not run in enforce mode when a scan fails — which is precisely the case a consumer wants the log for. A constant cannot fail to be set.

Two things I will be careful about, both from tonight:

  • The log must be truncated at the start of the run, not appended to. A stale file from an earlier job on the same runner would be reported as this run's output, and a tail that shows the wrong failure is worse than no tail.
  • A consumer reading the file must be able to distinguish "the scan produced nothing" from "the file is missing". expiro#26 shipped a fallback that could never fire because a redirection created the file it was checking for; the same shape is available here and I will test the composed form rather than the halves.

Out of scope: changing what the scanners check, the enforce default, or any consuming workflow. Consumers wiring up the tail is a follow-up in each repository — expiro#25 already names it.

Picking this up. `expiro`'s pipeline has been unable to reach Docker Hub since 02:06, so this repository is where work can still finish; this issue is also the thing that unblocks `expiro#25`'s fourth criterion, which I left visibly unmet for exactly this reason. **The approach.** Each scanning step already captures its tool's exit code inline (`cmd && rc=0 || rc=$?`) because the runner injects `-e`. Adding `2>&1 | tee -a "$LOG"` keeps that working: every step already sets `pipefail`, so the pipeline's status is the tool's, not `tee`'s. That is the trap recorded in the knowledge file and it is already avoided here — worth stating because it is the one change that could silently turn a failing scan green. **On exposing the path.** I am declaring `outputs.log-path` with a literal value rather than threading it through a step's `GITHUB_OUTPUT`. A step that sets it would need `if: always()`, or it would not run in enforce mode when a scan fails — which is precisely the case a consumer wants the log for. A constant cannot fail to be set. **Two things I will be careful about**, both from tonight: - The log must be truncated at the start of the run, not appended to. A stale file from an earlier job on the same runner would be reported as this run's output, and a tail that shows the wrong failure is worse than no tail. - A consumer reading the file must be able to distinguish "the scan produced nothing" from "the file is missing". `expiro#26` shipped a fallback that could never fire because a redirection created the file it was checking for; the same shape is available here and I will test the composed form rather than the halves. **Out of scope:** changing what the scanners check, the `enforce` default, or any consuming workflow. Consumers wiring up the tail is a follow-up in each repository — `expiro#25` already names it.
ahmad 2026-09-04 06:04:16 +00:00
Sign in to join this conversation.
No description provided.