Skip to main content

Post build and test results to pull requests

· 6 min read
Erik Osterman
Founder @ Cloud Posse

Moving build, test, and deploy pipelines out of GitHub Actions YAML and into Atmos custom commands removes a lot of glue, but one piece of it always came back. To tell the pull request what happened, the script had to shell out to gh, append to $GITHUB_ENV by hand, and print workflow commands for annotations. None of that works on a laptop, so the reporting half of a pipeline could only be debugged by pushing a commit and waiting for a runner.

Scripts written in the Atmos Automation Language now have a ci module that reports to the CI provider with one call each, and renders the same calls on your terminal when there is no provider.

Reporting was the part you could not run locally​

Building, testing, and validating policy are easy to run on your own machine. The last lines of a pipeline step are the problem: the pull request comment, the job summary, the step output, the commit status. They depend on files and tokens that only exist inside a runner, so they stay untested until the pipeline runs for real.

One module, the same script everywhere​

From any script (standalone, custom command, workflow, or hook step) you can post a pull request comment, write a job summary, set step outputs, export environment variables and PATH entries for later steps, mask a value in logs, emit inline annotations, create and update a commit status, open a collapsible log group, and upload a SARIF report. The run's context is available through ci.context: provider, event, commit SHA, branch, repository, run URL, and pull request.

With no CI provider detected, every call renders locally instead. A comment becomes a preview, a summary renders as Markdown on the terminal, and outputs print as name=value. You develop and debug the reporting on your laptop, and the CI run executes the same code.

In CI, each call honors the same ci configuration switches as the built-in reporting that Atmos already does for Terraform. Comments and checks are opt-in. A call whose switch is off does not fail silently: it warns, names the switch that is off, and renders locally. When ci.enabled itself is off, that is the switch the warning names, rather than the flag for the individual feature.

The GitHub provider honors GITHUB_API_URL, so the calls work against GitHub Enterprise Server, and ATMOS_CI_GITHUB_API_URL overrides it. A malformed URL fails the command instead of sending your token to an unintended host.

On a CI platform Atmos does not recognize, pass --ci or set ATMOS_CI=true. That forced CI mode makes the generic provider stand in for a real one: the same switches apply, ci.context.local is False, and files named by ATMOS_CI_OUTPUT, ATMOS_CI_SUMMARY, ATMOS_CI_ENV, and ATMOS_CI_PATH receive the outputs, summary, environment exports, and PATH additions. The generic provider reads the run's context from variables such as ATMOS_CI_PR, ATMOS_CI_EVENT, ATMOS_CI_RUN_ID, and ATMOS_CI_RUN_URL.

How to Use It​

This custom command runs tests and OPA policy validation, then reports one result to the job summary and to a single pull request comment that later runs update in place:

commands:
- name: app test
arguments: [{ name: component, required: true }]
flags: [{ name: stack, shorthand: s, required: true }]
steps:
- name: test
type: script
interpreter: starlark
script: |
component, stack = ctx.arguments["component"], ctx.flags["stack"]
tests = ci.group("test", lambda: exec.run(["go", "test", "./..."], check = False, output = "capture"))
policy = atmos.validate("component", component, flags = {"stack": stack}, check = False, output = "capture")
ok = tests.exit_code == 0 and policy.exit_code == 0
body = "## Test: `" + component + "` in `" + stack + "`\n\n" + \
"| Check | Result |\n|---|---|\n" + \
"| Tests | " + ("✅" if tests.exit_code == 0 else "❌") + " |\n" + \
"| OPA policy | " + ("✅" if policy.exit_code == 0 else "❌") + " |\n"
ci.summary(body)
ci.comment(body, key = "app-test:" + component + ":" + stack)
ci.output("tested", "true" if ok else "false")
if not ok:
fail("tests or policy failed")

Run it the same way in CI and on a laptop:

atmos app test api --stack=dev

On GitHub Actions, give the job pull-requests: write and set ci.enabled: true and ci.comments.enabled: true. The ci reference covers every call, the gating rules, and the local rendering.

Comments when there is no pull request​

The comment above would have nowhere to go on a push to a branch. By default, ci.comment posts to the pull request when the run has one and otherwise leaves a comment on the commit, so the same script reports on both kinds of run. Pass target = "pr" when a missing pull request should be an error, or target = "commit" to always comment on the commit. Commit comments need the contents: write permission.

Templates, image comments, and fork pull requests​

Reports that several scripts share do not have to be assembled from strings. Pass template="plan.md" and data={...} to ci.summary or ci.comment and Atmos renders the file from ci.templates.base_path with the same template engine the built-in plan summaries use. The call always names its template: a script has no configured default, so changing ci.summary.template for your Terraform summaries never changes what a script renders. A misspelled key in the data fails the call and names the key, and a template that cannot be found reports the path it looked in.

Container builds and pushes already wrote an image summary to the job. They now go through the same path. With ci.comments.enabled on, each image repository gets a comment on the pull request when one is known, or on the commit otherwise. Later builds update the comment for that image in the same pull request or commit, regardless of tag. Push runs that post commit comments need contents: write permission. Customize the Markdown with ci.templates.container.image.

The hold under pull_request_target and workflow_run applies only when the pull request comes from a fork. For a fork pull request, Atmos holds comments, commit statuses, environment and PATH exports, and SARIF uploads, whether they come from a script or from the native Terraform plan comments. Summaries, outputs, annotations, and masks only touch the current job, so they still run. A pull request from the same repository posts normally, a deleted fork counts as a fork, and a script can read ci.context.pr.fork to decide for itself. Setting ci.allow_unsafe_fork_execution (or ATMOS_ALLOW_UNSAFE_FORK_EXECUTION) releases both this hold and the clone gate of atmos git clone.

Get Involved​

Try moving the reporting lines of one pipeline step into a script, and open an issue if a report you need is missing.