The runner replaces every secret a job uses with *** in the log. The staging job of 3.11.3 tested how far that goes:
gh api repos/{owner}/{repo}/actions/jobs/108068565047/logs --allow-escape-sequences \
| cut -d' ' -f2- | grep -E '^( SHELF|Region|Key|Reversed|Base64)'SHELF_API_KEY: *** Region ap-southeast-1, key *** Key length 24 Reversed, first 6: c89b58 Base64: ***
The value and its Base64 form were masked; the variable was not. But the reversed string leaked six real characters: masking is string matching, so any other transformation (reversing, slicing, URL encoding, splitting into lines) shows the secret, and the length leaked too. Hence the rules:
Never print secrets; pass them to tools through env: or standard input.
Mask values derived from a secret with echo "::add-mask::$VALUE" (Runners and Commands) before printing anything.
Store each field separately, not a JSON document or .env file as one secret.
Give every workflow a minimal permissions: block, so a leaked GITHUB_TOKEN can do little.
Rotate a secret the moment it may have leaked, as the demo key was:
openssl rand -hex 12 | gh secret set SHELF_API_KEY && gh secret listSHELF_API_KEY 2026-09-25T12:31:31Z
Any signed-in user can read a public repository's logs, so treat every log line as published.