A Sanitized Summary Is Not a Sanitized Workflow

The fallback has more than one possible outcome.
One repository can supply contributor statistics through the GitHub API.
Another can need a bare clone and git log --numstat.
Another can still fail.
My workflow needed to say that much without printing a list of repository names into the log.
So I gave it one line shaped like this:
Lines changed sources: API 12 | git fallback 4 | failed 1
Those values are illustrative. The checked source implements the format and counters; this article does not claim they came from a particular workflow run.
That line is useful.
It tells me whether the public card came mostly from cached API values or from the slower recovery path. It tells me whether any repository contributed a synthetic zero after an unrecovered source failure. It lets me notice a system-wide shift from “mostly API” to “mostly fallback.”
It does not tell a log reader which repository failed.
That was the intended boundary.
Then I looked one layer lower and found the uncomfortable part: a sanitized summary does not make the rest of the process sanitized.
The exception path still has a vote.
Aggregate the operational fact, not the input identity
The lines-changed calculation returns three pieces per repository:
additions, deletions, source
The source can be:
api
git_fallback
git_unavailable
clone_failed
git_log_failed
other_api_error
The batch aggregator adds the numeric values and increments counters:
if source == "api":
summary["api_success"] += 1
elif source == "git_fallback":
summary["git_fallback_success"] += 1
else:
summary["failed"] += 1
summary[source] += 1
No repository name enters that summary object.
The routine output is built from three counts:
return (
"Lines changed sources: "
f"API {summary['api_success']} | "
f"git fallback {summary['git_fallback_success']} | "
f"failed {summary['failed']}"
)
This is a form-level privacy decision.
It does not redact a repository name after formatting a detailed log entry. It never puts the repository name into the routine summary at all.
That is stronger than a late regular expression trying to replace owner/private-repo after the string already exists.
A second aggregate can improve diagnosis without naming inputs
One three-tuple answers “How were values sourced?” It does not answer “What kind of failure happened?”
The current code adds a second line only when at least one repository failed:
Lines changed failure causes: git unavailable 1 | clone failed 1 | other/api error 1
Again, the dimensions are fixed and aggregate:
_LINES_CHANGED_FAILURE_LABELS = {
"git_unavailable": "git unavailable",
"clone_failed": "clone failed",
"git_log_failed": "git log failed",
"other_api_error": "other/api error",
}
Zero-count causes are omitted. Repository identities are still absent.
This gives the workflow two observability layers:
Layer 1: API / fallback / failed counts
Layer 2: aggregate failure-cause counts
Neither layer answers “which repository?”
That is a deliberate loss of diagnostic detail. The routine CI surface can reveal that clone failures increased without becoming a repository inventory.
The current test proves the sentence, not the identity flow
An exact-string assertion proves formatting. It does not prove that sensitive dimensions stayed out.
The current test starts with an already-aggregated counter dictionary and checks the formatted sentence:
self.assertEqual(
summary,
"Lines changed sources: API 2 | git fallback 1 | failed 1",
)
self.assertNotIn("owner/api", summary)
self.assertNotIn("owner/fallback", summary)
The two negative assertions look like an identity-flow check, but neither identity is present in the fixture that reaches the formatter. The exact-string assertion would already reject any appended repository name.
So this test proves the formatter's fixed output, not that repository identities stayed out of the upstream summary object.
A stronger regression would route dummy repository identities through aggregation, render every resulting log line, and assert that the identities are absent from the captured output.
If a future refactor changes the output to:
API 2 | git fallback 1 (owner/fallback) | failed 1
the count is still correct. The privacy contract is not.
The failure-cause test separately verifies that only nonzero fixed categories appear.
The existing tests are narrow. They exercise the two summary formatters. They do not prove that every line printed by the workflow is safe.
That last sentence is the load-bearing one.
A workflow log has more writers than your summary function
The generator prints the two summary strings:
print(await stats.lines_changed_summary_text)
failure_summary = await stats.lines_changed_failure_summary_text
if failure_summary is not None:
print(failure_summary)
But those are not the only paths to stdout and stderr.
A workflow log can also receive text from:
- HTTP client errors
- retry diagnostics
- subprocess stderr
- uncaught exceptions and tracebacks
- dependency installers
- runner diagnostics
Sanitizing one formatter controls one writer. It does not automatically control the others.
This distinction matters in GitHub Actions. GitHub's workflow-log documentation says a signed-in user with repository read access can view run information, including for public repositories. The workflow-log REST endpoints also allow access to public resources without a token.
A log in a public repository is therefore an output surface, even though the Web UI requires a login.
The clone command reopens the credential boundary
The git fallback constructs an authenticated repository URL:
safe_username = quote(login, safe="")
safe_token = quote(access_token, safe="")
repo_url = f"https://{safe_username}:{safe_token}@github.com/{repo}.git"
It passes that URL as a subprocess argument:
clone = subprocess.run(
[
"git",
"clone",
"--bare",
"--filter=blob:limit=1m",
"--no-tags",
"--single-branch",
repo_url,
repo_path,
],
capture_output=True,
text=True,
timeout=300,
)
For a normal nonzero exit, the code returns clone_failed without printing the captured stderr.
That path preserves the aggregate boundary.
Timeout is different.
subprocess.run(..., timeout=300) raises subprocess.TimeoutExpired.
The current function does not catch that exception.
During this draft's verification, I reproduced the behavior in the checked Python runtime with a dummy credential and a local sleeping process. The exception string included the entire command argument list, including:
https://user:dummy-token-for-test@example.invalid/repo.git
No real token, repository, or network request was used in that check.
The implication is still real: an uncaught clone timeout can print a traceback containing the credential-bearing URL.
The aggregate summary is sanitized. The workflow is not proven sanitized.
Capture output does not sanitize exceptions
capture_output=True is useful.
It keeps normal child-process stdout and stderr from flowing directly into the parent log.
It does not rewrite the command stored in an exception.
This is a common observability trap:
normal failure path → categorized and sanitized
exceptional failure path → generic traceback machinery
The current tests cover the first path. They do not force the second path with a dummy secret.
That suggests a different test shape.
Do not test only:
assert summary == expected_counts
Also force each exceptional boundary and capture the resulting log:
HTTP timeout
clone timeout
git log timeout
malformed subprocess output
unexpected exception
Then assert that a dummy token, dummy private repository name, and credential-bearing URL do not appear anywhere in the captured output.
The test must fail before the fix. Otherwise it may be reading a branch that never received the sensitive fixture.
The fix should remove secrets from command arguments
Catching TimeoutExpired and returning a new category such as clone_timeout would stop this specific traceback from escaping.
That is necessary. It is not the strongest boundary.
The stronger design is to stop embedding the token in the command argument in the first place.
Possible designs include an ephemeral GIT_ASKPASS helper or another credential mechanism whose secret is not part of the argument list.
The exact mechanism needs its own cross-platform and cleanup verification before implementation.
The target properties are clearer than the mechanism:
secret absent from argv
secret absent from rendered command errors
timeout mapped to a fixed failure category
captured stderr not printed raw
temporary credential material removed
dummy-secret regression test fails before the fix and passes after it
The repository does not yet satisfy that full set at the checked revision. This article is not claiming otherwise.
Separate routine observability from privileged diagnosis
An aggregate log cannot identify the failing repository. That makes reproduction slower.
The answer is not necessarily to put repository names back into the default workflow output.
Use two surfaces with different audiences:
Routine CI surface
fixed aggregate dimensions
no repository identities
no credentials
enough signal to detect drift
Controlled diagnostic surface
explicitly requested
access-limited
retention-limited
sanitized before sharing elsewhere
An issue tracker can collect reproduction details, but a public issue is still public output. Sensitive repository names, tokens, clone URLs, and raw traces do not become safe merely because they moved from Actions to an issue template.
The diagnostic path needs its own disclosure policy.
Logs are generated artifacts too
The first article in this series treated a public metrics card as a projection, not a raw dump. The second carried provenance alongside a fallback value so “measured zero” stayed distinct from “failed and substituted zero.”
The log needs both ideas.
It should be a projection with an explicit allowlist of dimensions. It should preserve enough provenance to explain whether the metric came from the API, git fallback, or failure.
And it should assume that every exception path is another renderer competing to publish data.
The illustrative line below is a good interface:
Lines changed sources: API 12 | git fallback 4 | failed 1
It is not a security certificate.
A sanitized summary is one well-designed output surface inside a larger workflow. The job is finished only when subprocess arguments, exceptions, retry logs, debug modes, and diagnostic handoffs obey the same boundary.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.