22 minutes read
GitHub Action
Offing's reusable Action scans the checked-out repository locally, emits GitHub annotations for findings, compares against an optional baseline, enforces a severity gate, and uploads the versioned JSON result.
What it looks like
One comment per pull request, updated in place on every re-run, with the severity counts, the top findings, and their exact location. Each row links to the source the rule cites, so a reviewer can check the claim before changing code.

Pull-request workflow
name: Offing
on:
pull_request:
permissions:
contents: read
jobs:
lifecycle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: velella-sh/offing@v0.1.1
with:
baseline: .offing-baseline.json
fail-on-severity: high
Version tags provide stable release references. Pin a full commit SHA when your threat model requires immutable third-party action references. A release tag points at a commit that carries the compiled Action bundle, which the release chain builds onto a release/<tag> branch and never rebuilds, so the Action runs without installing anything and a tag you pinned keeps doing what it did. Releasing explains why the bundle is not on main.
Reusable workflow (one uses: line)
Organizations that want the same scan in every repository without copying checkout and Action steps can call the reusable workflow instead. It checks out the caller, runs Offing with the common inputs, and needs only contents: read. The Action version is pinned inside the workflow file, so pinning the workflow pins the scanner.
name: Offing
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 4 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
lifecycle:
uses: velella-sh/offing/.github/workflows/offing-reusable.yml@v0.8.0
with:
baseline: .offing-baseline.json
fail-on-severity: high
Version. The reusable workflow first ships in
v0.8.0. Until that tag exists, call@mainonly to try it, then replace it with the release tag before relying on it.
Every workflow input is optional and matches the Action default of the same name. Pass only what you need to change:
| Workflow input | Default | Meaning |
|---|---|---|
path |
. |
Repository root or subdirectory to scan. |
paths |
empty | Multi-line list of directories or globs relative to path. |
baseline |
empty | Baseline path relative to path. |
baseline-warn-days |
14 |
Warn when a baseline suppression expires within this many days. |
on-expired |
resurface |
resurface or fail after a suppression's expires day. |
min-support-days |
empty | Warn on lines whose support ends within this many days. |
fail-on-severity |
high |
Lowest failing level: none, info, low, medium, high, or critical. |
mode |
scan |
scan or diff (diff fetches full history for the base and head SHAs). |
annotations |
empty | changed, all, or none; empty keeps the Action default. |
report-name |
offing-report |
Artifact name when upload-report is true. |
upload-report |
true |
Upload the JSON report as an Actions artifact. |
attribution |
true |
Credit Offing in the job summary. |
The workflow exposes the same result outputs as the Action (report-path, finding-count, has-new-findings, highest-severity, next-eol-date, findings-json) so a later job can branch on them. Features that need write permissions — pr-comment, suggest-fixes, commands, fix-pr, tracking-issue, upload-sarif, upload-badge — stay on the Action step above; the reusable workflow keeps the minimal contents: read permission set.
For an organization .github repository that offers the starter in the Actions "New workflow" gallery, copy templates/workflows/ into that repository's workflow-templates/ directory. See Organization rollout.
SHA-pinned actions
For the first-party actions covered by Offing, a full 40-character commit pin is classified in this order:
- A trailing release comment, such as
# v4,# v4.1.1,# tag=v4.1.1, or# version: v4.1.1. - Offing's bundled map of exact release-tag SHAs, when the pin has no trailing comment.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
A malformed, ambiguous, or conflicting comment is not replaced by a map lookup: Offing reports it as invalid-version-comment. A SHA absent from the bundled map is reported as unknown-sha. These references are not lifecycle findings, so they do not affect severity gates or annotations.
The uploaded JSON report records the total in unclassified.count and lists each reference with its action, SHA, reason, and workflow location under unclassified.references. CLI text output adds a one-line count when any exist. Classification uses only repository contents and the bundled map; scans make no network requests.
Create and commit the baseline locally when existing findings are accepted:
npx @velella/offing baseline . --output .offing-baseline.json
A baseline entry can also say why a finding is accepted and until when. These fields are all optional, so a baseline written without them keeps working, and they never change which findings are suppressed:
{ "fingerprint": "…", "ruleId": "nodejs.runtime.eol-20", "path": "package.json",
"owner": "platform-team", "reason": "Upgrade tracked in PLAT-12", "created": "2026-10-17", "expires": "2027-01-31" }
owner and reason are non-empty text (up to 500 characters); created and expires are calendar dates (YYYY-MM-DD), and expires is not before created. An entry with a malformed field makes the baseline fail to load, with the entry's position in the message. The schema is schemas/baseline.v1.schema.json. Expiry warnings. When a scan uses a baseline, every suppression that matched a finding and expires within the next 14 days is listed in the scan's diagnostics (and as a warning on the Action run), soonest first, with its owner and reason:
Baseline suppression expires in 5 days (2026-10-22): nodejs.runtime.eol-18 at package.json (owner: platform-team; reason: Upgrade tracked in PLAT-12).
Change the window with --baseline-warn-days <n> on offing scan or the baseline-warn-days Action input (0 to 365). The comparison day is today in UTC; --today YYYY-MM-DD sets it explicitly, which makes a run reproducible; suppression dates are compared only with that day.
After expires. A suppression is in force through its expires day and expired from the next day. An expired suppression stops hiding its finding: the finding is reported again, and the diagnostics say which suppression expired, how long ago, and its owner and reason. What happens beyond that is a policy, --on-expired on offing scan or the on-expired Action input:
| Policy | Behavior |
|---|---|
resurface (default) |
The finding is back in the results and is judged by the usual severity gate. |
fail |
The finding is back, and the scan also fails (the CLI exits 1, the Action fails the job) if any suppression has expired, even when the finding is below the gate. |
Only entries with an expires can expire, so a baseline written before these fields behaves exactly as before. A finding stays hidden while at least one of its suppressions is in force.
Seeing what a baseline does. A scan that uses a baseline never hides suppressions silently. offing scan --json adds a suppressions object to the result (and scan.v1.schema.json documents it): how many findings the baseline hid, the day it compared with, and the expiring and expired suppressions with their rule, path, expires day, owner, and reason. The text report ends its findings with a line such as Baseline: 3 findings suppressed, 1 expiring within 14 days, 0 expired., the Action's job summary shows the same line and a table of the expiring and expired suppressions, and the MCP scan_repository tool returns the object when it is given a baseline. A scan without a baseline has no suppressions field.
Upgrading needs no migration. A baseline written by an earlier version (the offing.baseline.v1 schema, with only fingerprint, ruleId, and path) loads and suppresses exactly what it always did; a test loads a baseline written by the published 0.1.0 against the current scanner. Fingerprints do not depend on the new fields, so adding an owner or reason to an existing entry by hand does not change what it suppresses. A newer release can report findings that an older baseline never recorded (for example a rule added since); those appear as new, which is the point of a baseline.
The Action then annotates and gates only findings not represented by that baseline.
Chat and webhook digests
offing scan --format slack, --format teams, and --format webhook print a JSON payload to standard output. Offing only renders the payload and never sends it: the network call belongs to a step you write, so the scan itself stays offline.
slackis a Slack incoming-webhook message: a fallbacktext, a header, a counts summary, and one section per finding. It fits Slack's 50-block and 3,000-character section limits.teamsis a Microsoft Teams message with an Adaptive Card (version 1.4) for a Teams workflow webhook.webhookis a generic JSON payload (schemaVersion: "offing.webhook.v1") with a count per severity and every finding, for any system that accepts JSON.
The chat formats list at most 20 findings, most severe first, and say how many were left out; webhook lists them all. Output is deterministic, with no timestamps in the chat formats.
Pipe the payload into your own webhook step. The URL lives in a secret, and the step runs only where you choose:
- uses: actions/checkout@v7
- run: npm ci # your project must already depend on offing
- name: Post digest to Slack
if: always()
run: npx --no-install offing scan . --format slack | curl -sS -X POST -H "Content-Type: application/json" --data @- "$SLACK_WEBHOOK_URL"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Use --format teams with a Teams workflow URL, or --format webhook with any endpoint, in the same way.
Inputs and outputs
Every input is optional. The values below are the defaults used when a workflow
does not provide a with entry.
| Input | Default | Meaning |
|---|---|---|
path |
. |
Repository root or subdirectory to scan; . is the checked-out repository. |
paths |
empty | Multi-line list of directories or globs (for example packages/*) relative to path. Each match is scanned in one run; findings go into one report and SARIF, with a job-summary section per package. Empty scans only path. |
baseline |
empty | Baseline path relative to path (or to each matched package when paths is set). Empty reports every finding; a configured path that is missing fails the Action. |
min-support-days |
empty | Warn (medium severity) on lines whose support ends within this many days; off when empty. |
baseline-warn-days |
14 |
Warn when a baseline suppression expires within this many days (0 to 365). |
on-expired |
resurface |
What a baseline suppression does after its expires day: resurface (its finding is reported again) or fail (also fail the job). |
fail-on-severity |
high |
Lowest failing level: none, info, low, medium, high, or critical. |
mode |
scan |
scan scans the checked-out tree. diff compares the pull request base and head SHAs from the event payload and fails only on findings the pull request introduces. See pull request diff mode. |
annotations |
empty | Which findings become workflow annotations: changed annotates only findings on lines the pull request added or changed (the default in diff mode), all annotates every finding (the default in scan mode), none emits no annotations. Findings left out stay counted in the summary and the JSON report. |
report-name |
offing-report |
Artifact name for the JSON report when upload-report is true. |
upload-report |
true |
Upload the JSON report as an Actions artifact. false keeps the generated report only on the runner. |
upload-sarif |
false |
Set to true to upload the findings to GitHub code scanning as SARIF. Needs security-events: write. |
upload-badge |
false |
Generate a Shields.io endpoint JSON and a branded SVG badge, and upload both as the offing-badge artifact (14 days). |
pr-comment |
false |
Set to true to post one summary comment on pull requests (counts and the top findings). Re-runs update the same comment. Needs pull-requests: write. |
agent-authors |
Copilot, google-labs-jules[bot], devin-ai-integration[bot], chatgpt-codex-connector[bot], claude[bot] |
Comma-separated logins treated as coding agents. In diff mode, when the pull request author matches, the comment adds the Stale choices this agent introduced section. Set to none to turn it off. Matching ignores case and a leading @. |
suggest-fixes |
false |
In diff mode, post one review whose suggestion blocks apply the deterministic fixes for findings on lines the pull request changed. Re-runs update the same review. Needs pull-requests: write; pull requests from forks are skipped. |
commands |
false |
Set to true to answer /offing explain and /offing ignore comments when the workflow runs on issue_comment. Replies only to owners, members, and collaborators. Needs pull-requests: write. |
fix-pr |
false |
Set to true to open or update one pull request on offing/fixes with every deterministic fix, and close it when nothing is left to fix. Runs only on schedule and workflow_dispatch events. Needs contents: write and pull-requests: write. See scheduled fix pull request. |
tracking-issue |
false |
Set to true in a scheduled workflow to keep one issue listing findings by end-of-life date. Re-runs update the same issue; it closes when nothing is left and reopens when findings return. Needs issues: write. |
github-token |
${{github.token}} |
Token used to post the comment, command reply, or fix suggestions when pr-comment, commands, or suggest-fixes is true, to open, update, or close the fix pull request when fix-pr is true, and to update the tracking issue when tracking-issue is true. |
attribution |
true |
Adds one line to the job summary saying the scan was done by Offing, with a link. Set to false to leave it out. |
| Output | Availability | Meaning |
|---|---|---|
report-path |
Always | Absolute runner path to the generated JSON report, whether or not upload-report is enabled. |
finding-count |
Always | Number of findings remaining after baseline comparison, encoded as a decimal string. |
has-new-findings |
Always | true when at least one finding remains after baseline comparison, false otherwise. In diff mode it covers the findings the pull request introduces. |
highest-severity |
Always | The highest remaining severity (info, low, medium, high, or critical), or empty when there are no findings. |
next-eol-date |
Always | The earliest end-of-life date among the remaining findings (YYYY-MM-DD), or empty when no finding carries a deadline. |
findings-json |
Always | The remaining findings as one compact JSON array. Each entry has id, ruleId, severity, title, path, and line and deadline when known. |
badge-path |
When upload-badge is true |
Absolute runner path to offing-badge.json; otherwise empty. |
badge-svg-path |
When upload-badge is true |
Absolute runner path to offing-badge.svg, next to the JSON; otherwise empty. |
sarif-path |
When upload-sarif is true |
Absolute runner path to the generated SARIF file; otherwise empty. |
The job summary shows how many findings there are per severity, the ten most severe findings with their location and a link to the evidence each one cites, the age of the lifecycle data, and a warning when the data is stale. When there are more than ten findings, it says how many more there are; the JSON report artifact lists them all.

Branching on results
The outputs describe the findings that remain after baseline comparison, so a later step can act on them without reading the report. Every value is empty or false when nothing remains.
| Output | Example value | Meaning |
|---|---|---|
has-new-findings |
true |
true when at least one finding remains, false otherwise. |
highest-severity |
critical |
The most severe remaining level, or empty when there are none. |
next-eol-date |
2026-09-23 |
The earliest deadline among the remaining findings, or empty when none is dated. |
findings-json |
[{"id":"…","ruleId":"…","severity":"high","title":"…","path":"package.json","line":4,"deadline":"2026-09-23"}] |
The remaining findings as one compact JSON array. |
The scan step must pass for the steps after it to run, so this example sets fail-on-severity: none and leaves the decision to the later steps:
- uses: velella-sh/offing@v0
id: offing
with:
fail-on-severity: none
- name: Note new findings
if: steps.offing.outputs.has-new-findings == 'true'
run: echo "Offing found new findings; highest severity is ${{ steps.offing.outputs.highest-severity }}."
- name: Warn about the next deadline
if: steps.offing.outputs.next-eol-date != ''
run: echo "The next end of life is ${{ steps.offing.outputs.next-eol-date }}."
- name: List finding ids
run: echo '${{ steps.offing.outputs.findings-json }}' | jq -r '.[].id'
In diff mode the same outputs cover only the findings the pull request introduces, so findings-json lists what the pull request added rather than the whole backlog.
Monorepo paths
Monorepos should not need one job per package. Set paths to a multi-line list of directories or globs under path. Offing scans each match, writes one combined JSON report and SARIF, and adds one job-summary section per package. fail-on-severity is evaluated across all packages together.
- uses: actions/checkout@v7
- uses: velella-sh/offing@v0
with:
paths: |
packages/*
apps/web
fail-on-severity: high
Literals must exist and be directories. A glob that matches nothing fails the Action. * and ? stay inside one path segment; ** crosses segments. Diff mode ignores paths and compares the whole pull request checkout instead.
High and critical findings become error annotations, medium findings become warnings, and low or informational findings become notices. The gate is evaluated independently from annotation level.
Pull request comment
Set pr-comment: true to get the comment shown at the top of this page: the counts per severity and the top findings, instead of reading every annotation. The same comment is updated on each re-run. The scan itself stays offline; only posting the comment calls the GitHub API.
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: velella-sh/offing@v0
with:
pr-comment: "true"
Pull requests from forks get a read-only token, so the comment cannot be posted there: the Action logs a warning and the scan result is unaffected.
Agent-authored pull requests
When mode: diff and the pull request author is a coding agent, the comment also carries a Stale choices this agent introduced section. It lists each finding the pull request introduced with its path:line, rule title, replacement, and evidence link, and closes with the line the agent can act on: "Replace these and push again; Offing will re-check." The comment is still the one Offing comment, updated on re-runs; a second comment is never posted.
agent-authors takes a comma-separated list of logins. The default covers GitHub's Copilot coding agent (Copilot), Jules (google-labs-jules[bot]), Devin (devin-ai-integration[bot]), OpenAI Codex cloud (chatgpt-codex-connector[bot]), and Claude (claude[bot]). Matching ignores case and a leading @. Set agent-authors: none to turn the section off, or name the logins your agents author with. The author comes from the event payload, so no extra request is made; scans stay offline.
Comment commands
Set commands: "true" and run the Action on issue_comment so a reviewer can ask about a finding or accept it without leaving the pull request. A short workflow of its own is enough; it can sit next to the scan workflow:
name: Offing commands
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
answer:
if: github.event.issue.pull_request != null
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: refs/pull/${{ github.event.issue.number }}/head
- uses: velella-sh/offing@v0
with:
commands: "true"
/offing explain <finding-id> replies with the finding, its location, replacement and deadline, the affected usages, and a link to every source. Take the id from an annotation's Finding: line, or from offing scan . --json.
/offing ignore <finding-id> <reason> replies with a baseline entry that is ready to review and commit, for example:
{ "fingerprint": "…", "ruleId": "nodejs.runtime.eol-20", "path": "package.json",
"reason": "Upgrade tracked in PLAT-12", "created": "2026-10-29", "expires": "2027-01-27" }
Nothing is pushed. The reviewer adds the entry to the file named by the baseline input (.offing-baseline.json when it is empty) and commits it. The entry hides the finding until expires, 90 days after the reply; after that the finding is reported again, and on-expired: fail fails the job as well.
Comment text is untrusted input. Offing reads the first line that starts with /offing, parses it, and never evaluates it; file paths and ids from the comment are rendered as code, never as links. A reply is written only when the comment's author_association is OWNER, MEMBER, or COLLABORATOR; a command from anyone else, or a comment on an issue rather than a pull request, is skipped and logged as a notice. The finding comes from a local scan of the checked-out pull request head, so the scan stays offline and runs no repository code. Each reply carries a hidden marker and is updated on a re-run instead of being posted twice.
Lifecycle tracking issue
Set tracking-issue: true to keep one issue that lists what ends soon, without gating a build. On schedule and workflow_dispatch runs the Action finds the issue by a hidden marker, updates it, or creates it. Findings are sorted by end-of-life date with a link to the exact file and line, and a "Changed since the last run" table names what appeared and what is gone. When a scan finds nothing, the issue closes with an explanatory body; the next run that finds a lifecycle risk reopens it.
name: Offing lifecycle tracking
on:
schedule:
- cron: "0 7 * * 1" # Mondays 07:00 UTC
workflow_dispatch:
permissions:
contents: read
issues: write
concurrency:
group: offing-tracking-issue
cancel-in-progress: false
jobs:
track:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: velella-sh/offing@v0.1.1
with:
tracking-issue: "true"
fail-on-severity: none # the issue is the signal; do not fail the schedule
The marker is a hidden HTML comment, so the issue body stays readable and a re-run recognizes its own issue. The previous run's findings live in another hidden marker; that snapshot is how the next run says what changed. Do not edit or delete the markers: without them the next run creates a new issue. The scan stays offline; only updating the issue calls the GitHub API, and the job needs issues: write. Pull request tokens are read-only, so the input acts only on schedule and workflow_dispatch runs.
The Action looks for its issue among the 1,000 most recently updated issues. On a repository with more issue activity than that while the tracking issue is closed, it can fall outside the window and a second issue is created; delete one and the next run updates the other. GitHub caps issue bodies at 65,536 characters, so the findings table is shortened when needed while the hidden snapshot keeps tracking every finding; if even the shortened body would exceed the cap, the Action logs a warning and leaves the issue unchanged.
Pull request diff mode
finding-count and the severity gate cover every finding in the checkout. On a busy repository that backlog grows, so reviewers cannot see which findings a pull request actually introduced. mode: diff fixes that: the Action scans the pull request base and head refs with the same engine as offing diff, and the gate, annotations, badge, comment, and summary count only what the pull request introduced. Findings the pull request fixes are listed in a "Fixed by this pull request" table in the summary and the pull request comment.
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # diff mode archives the base and head SHAs from git objects, so fetch their history
- uses: velella-sh/offing@v0
with:
mode: diff
The base and head come from the pull_request event payload (pull_request.base.sha and pull_request.head.sha); the comparison stays offline — both refs are unpacked with git archive and scanned locally. Outside a pull_request event, or when the payload lacks those SHAs, the Action logs a notice and runs a regular scan instead. The baseline input does not apply in diff mode: it describes accepted findings in the whole repository, not in one pull request, and the Action says so with a notice. Because the gate judges only introduced findings, a pull request that adds a deprecated package fails and one that only edits documentation passes.
Annotations on untouched files are noise, so diff mode annotates only findings on lines the pull request added or changed and counts the others in one summary line with a link to the full report. The gate, the severity counts, the badge, the comment, and the JSON report still cover every introduced finding. Set annotations: all to annotate every introduced finding, or annotations: none to emit no annotations at all. Scan mode defaults to all; an explicit annotations: changed there falls back to all with a notice because there is no pull request range.
Suggested fixes
Set suggest-fixes: true with mode: diff to let a reviewer accept a fix with one click instead of reading migration notes. For findings on lines the pull request added or changed that offing fix can repair deterministically, the Action posts one review whose comments each contain a suggestion block, so GitHub's Apply suggestion rewrites the line. Findings without a deterministic fix get no suggestion; the scan, the gate, and the report are unchanged.
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # diff mode archives the base and head SHAs from git objects
- uses: velella-sh/offing@v0
with:
mode: diff
suggest-fixes: "true"
A re-run updates the review it left instead of adding another: the review body and the comments on the same file and lines are rewritten, and comments whose suggestion no longer applies are deleted. A suggestion at a new line arrives as one more review, because GitHub does not allow adding comments to a submitted review. Pull requests from forks get a read-only token, so the Action logs a notice and posts nothing. The scan itself stays offline; only posting the review calls the GitHub API.
End-of-life reminder issue
examples/lifecycle-watch.yml is a copyable starter that reminds a team before a deadline without anyone running a scan. Copy it to .github/workflows/lifecycle-watch.yml. Every Monday (and on demand) it scans with min-support-days, then opens one issue listing each line whose support ends within WINDOW_DAYS (default 90). The next run updates that same issue instead of opening another, and closes it when nothing is near its deadline.
The scan stays offline; only the final step calls the GitHub API. The job needs contents: read and issues: write, and sets fail-on-severity: none so the schedule does not fail. Change WINDOW_DAYS or the cron line to taste.
Scheduled fix pull request
Dependabot, but for end of life. Set fix-pr: true on a scheduled workflow and the Action keeps one pull request that contains every fix Offing can apply deterministically, the same patches offing fix prints. It commits them to offing/fixes, force-pushes the branch, and opens or updates that pull request. The body lists each fix with its exact location and the evidence it cites. When a run finds nothing left to fix, it closes the pull request instead of opening an empty one.
name: Offing fixes
on:
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: offing-fixes
cancel-in-progress: false
jobs:
fixes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: velella-sh/offing@v0.1.1
with:
fix-pr: "true"
fail-on-severity: none
Every run force-pushes offing/fixes, and the pull request is found by its head branch rather than by title or body, so running twice updates the same pull request. A fixer that cannot apply a change exactly leaves that finding alone: the pull request contains only the patches that were applied. Review the pull request as you would any other; Offing never merges it and never touches the default branch.
fix-pr runs only on schedule and workflow_dispatch events. On any other event the Action logs a notice and leaves the repository alone, so a pull request from a fork, whose token is read-only, never tries to push. The scan itself stays offline: only the git push and the GitHub API calls that create, update, or close the pull request use the network, after the scan has finished. The job needs contents: write to push the branch and pull-requests: write to manage the pull request.
See it working on a schedule in offing-action-sample: it keeps one fix pull request and one tracking issue current every Monday.
Permissions and privacy
Only contents: read is required. Offing does not need pull-requests: write unless you set pr-comment: true, commands: true, or suggest-fixes: true; it then writes one pull request comment, command reply, or review of suggestions, each found again by a hidden marker and updated on every re-run, and needs only pull-requests: write for that. Offing does not send repository contents to an Offing backend. Annotations are emitted through runner workflow commands. tracking-issue: true needs issues: write; it updates one issue, also found by a hidden marker, and touches nothing else. With fix-pr: true it also needs contents: write (to push offing/fixes) and pull-requests: write (to open, update, or close the fix pull request).
The bundled scan is local and deterministic. When upload-report is true, the official actions/upload-artifact adapter sends offing-report.json to GitHub and retains it for 14 days. The report can contain repository-relative paths, matched source excerpts, dependency versions, and lifecycle evidence. Repository administrators should apply normal Actions artifact access controls or disable uploading for sensitive repositories.
Forked pull requests receive the same read-only scan behavior; no repository secrets are required.
README badge
Set upload-badge: true to generate offing-badge.json and offing-badge.svg and upload the
offing-badge artifact, even when the severity gate fails. The badge-path
output lets a later step publish the same file. This is opt-in and does not grant
the Action write access or make network requests during the scan.
The badge reads offing | clean, or offing | 3 high when findings remain.
The message counts findings after baseline comparison and names the highest
remaining severity. It does not say a repository is risk-free: suppressed findings
are excluded, and the severity gate can pass while lower-severity findings remain.
stale means the lifecycle data is old (appended as · stale when findings remain),
and expired means a baseline suppression expired under on-expired: fail. Colors
come from the Offing palette: Verified Green for clean, muted amber for low and
medium, red for high and critical.
offing-badge.svg is the finished badge with the Offing mark. The
Shields.io endpoint format file,
offing-badge.json, holds schemaVersion, label, message, color, and
labelColor, for use with img.shields.io/endpoint when you want a different
Shields style.
Actions artifacts require authentication, so their download URLs cannot be used
as public Shields endpoints. For a public README, publish the JSON to a public
URL, such as a dedicated branch in a public repository. This example reserves
the offing-badge branch for the generated file; only trusted pushes to main
and manual runs on main may publish. No pull-request code gets a write token.
Use the default branch name of your repository if it is different.
name: Offing badge
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: offing-badge
cancel-in-progress: false
jobs:
badge:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: velella-sh/offing@vX.Y.Z
id: scan
with:
upload-badge: true
- name: Publish aggregate badge data
if: always() && steps.scan.outputs.badge-path != ''
env:
BADGE_PATH: ${{ steps.scan.outputs.badge-path }}
BADGE_SVG_PATH: ${{ steps.scan.outputs.badge-svg-path }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
publish=$(mktemp -d)
git init "$publish"
git -C "$publish" remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# GitHub CLI is preinstalled on ubuntu-latest.
gh auth setup-git
if git -C "$publish" ls-remote --exit-code --heads origin offing-badge; then
git -C "$publish" fetch --depth=1 origin offing-badge
git -C "$publish" checkout -b offing-badge FETCH_HEAD
else
git -C "$publish" checkout --orphan offing-badge
fi
cp "$BADGE_PATH" "$publish/offing-badge.json"
cp "$BADGE_SVG_PATH" "$publish/offing-badge.svg"
git -C "$publish" config user.name github-actions[bot]
git -C "$publish" config user.email 41898282+github-actions[bot]@users.noreply.github.com
git -C "$publish" add offing-badge.json offing-badge.svg
if ! git -C "$publish" diff --cached --quiet; then
git -C "$publish" commit -m "chore: update Offing badge"
git -C "$publish" push origin HEAD:offing-badge
fi
Then add this to the README, replacing OWNER and REPO:
[](https://github.com/OWNER/REPO/actions)
Shields and GitHub cache badge images, so updates can take time to appear. The badge describes the last published scan, not the latest commit or a guarantee of freshness. A scan that cannot complete emits no new badge; its workflow failure remains the authoritative status. Schedule trusted scans if you need regular refreshes. Publishing aggregate counts makes them public; private repositories can keep the artifact private or deliberately publish only this aggregate file.
Code scanning
Set upload-sarif: true to show findings in the repository's Security → Code scanning tab. The Action writes the same findings as SARIF 2.1.0 and hands the file to github/codeql-action/upload-sarif under the category offing. The job needs one more permission:

permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v7
- uses: velella-sh/offing@v0.1.1
with:
upload-sarif: true
The token of a pull request from a fork is read-only, so the upload would fail there. Enable it for pushes and for pull requests from the same repository, for example upload-sarif: ${{ github.event_name == 'push' }}. Code scanning is free for public repositories and needs GitHub Code Security on private ones. The SARIF carries only locations and Offing's own rule text, not source content; the mapping is below.
SARIF output
The same SARIF is available from the CLI, so other CI systems and editors can read it:
offing scan . --format sarif > offing.sarif
Output is deterministic: rules and results are sorted, paths are repository-relative with forward slashes on every platform, and neither the scan time nor the checkout path is written. Two scans of the same commit produce identical files.
| SARIF | Offing |
|---|---|
tool.driver.name, version |
Offing and the installed version |
rules[].id, name |
The lifecycle rule id, for example github-actions.checkout.node20 |
rules[].shortDescription |
The rule title |
rules[].help (text and markdown) |
Replacement, deadline, migration steps, verification commands, and sources |
rules[].helpUri |
The first evidence source |
rules[].defaultConfiguration.level |
The same level mapping as results[].level, for the rule's severity |
rules[].properties.tags |
lifecycle and the detector id |
results[].ruleId, ruleIndex |
The finding's rule |
results[].level |
critical and high → error; medium → warning; low and info → note |
results[].message.text |
The finding summary, which names the exact reference, such as actions/checkout@v3 |
results[].locations[] |
File path (uriBaseId %SRCROOT%) and startLine when the finding has a line |
results[].partialFingerprints["offingFindingId/v1"] |
The finding id, so alerts keep their identity between runs |
JSON, text, annotations, and SARIF are renderings of the same findings. A finding suppressed by a baseline is absent from all of them.
Privacy. SARIF contains no source code: only file paths, line numbers, and Offing's own rule text and evidence links. It is uploaded only when upload-sarif is true, and then only to GitHub code scanning in the same repository. Nothing is sent to an Offing service.