# Offing: full documentation > The complete text of the Offing documentation pages listed in llms.txt, in that order. Each section starts with the page's public address. --- Source: https://velella.sh/offing/QUICKSTART/ # Quickstart Five copy-paste steps, from the first scan to a baseline in CI. You need Node.js 22 or newer and a repository to scan. Every command runs locally; nothing is uploaded. ## 1. Run the first scan ```bash npx @velella/offing scan . ``` Each finding names its severity, the exact file and line, the replacement, and the source. The first lines look like this on the demo project: ```text Found 5 lifecycle risks. The most urgent: HIGH Unsupported actions/checkout runtime (.github/workflows/ci.yml:9) MEDIUM Node.js 18 is end-of-life (package.json:5) … ``` Add `--json` for machine-readable output, or `--format compact` for one line per finding when an agent reads the result. ## 2. Check one dependency before adding it ```bash npx @velella/offing check pkg:npm/request@2.88.2 ``` The command prints the lifecycle problem, the replacement, and the evidence. It exits `1` when a rule at or above `--fail-on` (default `high`) applies and `0` otherwise. `no-data` means Offing has no rule for the package, not that it is safe. ## 3. Read the evidence for one finding Take a finding id from step 1 and inspect it: ```bash npx @velella/offing explain '' . ``` For structured context an agent can follow, including affected files, usages, constraints, and verification commands: ```bash npx @velella/offing context '' . --json ``` Offing suggests verification commands; it never runs migration commands for you. ## 4. Add the check to CI Create `.github/workflows/offing.yml`: ```yaml name: Offing on: pull_request: permissions: contents: read jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: velella-sh/offing@v0.7.1 with: fail-on-severity: high ``` The action annotates the pull request, can upload SARIF to code scanning, and fails the job on a finding at or above the chosen severity. See the [GitHub Action guide](GITHUB_ACTION.md) for inputs, permissions, and artifacts. ## 5. Add a baseline Accept the findings you already know about, then report only new regressions: ```bash npx @velella/offing baseline . npx @velella/offing scan . --baseline .offing-baseline.json ``` Commit `.offing-baseline.json`. The action reads the same file with `baseline: .offing-baseline.json`. ## Next - [Install Offing on every channel](INSTALL.md) - [Use it with an agent](AGENT_SKILLS.md) through the Agent Skill or the [local MCP server](MCP.md) - [Lifecycle rules](RULES.md) and the [evidence policy](../CONTRIBUTING.md#evidence-policy) - [Contribute](../CONTRIBUTING.md) --- Source: https://velella.sh/offing/DEMO/ # 90-second demo This repository includes a deliberately outdated project at `examples/demo`. Its manifest is stored as `package.json.fixture`; the example helper restores the real name in a temporary copy. It declares an unsupported Node.js range, uses the deprecated `request` package, and runs an old checkout action. ```bash git clone https://github.com/velella-sh/offing.git cd offing npm ci npm run build demo="$(node dist/tools/examples.js demo)" node dist/src/cli.js scan "$demo" ``` Offing reports four evidence-backed lifecycle findings: Node.js 18 and 20, `request`, and `actions/checkout@v4`. Each result includes the exact declaration, severity, replacement, and authoritative sources. Pick the `request` finding ID from the scan and inspect it: ```bash node dist/src/cli.js explain '' "$demo" node dist/src/cli.js context '' "$demo" --json ``` The context packet identifies `package.json` and `src/client.js`, preserves HTTP behavior as a migration constraint, proposes ordered changes, and suggests checks. Offing does not modify files or execute suggested commands. For an agent-driven version of the same flow, start `offing mcp` and use `scan_repository`, `explain_deprecation`, `create_migration_context`, and `verify_migration` in one local session. ## Example gallery `examples/gallery` holds one minimal repository per detector, each with the exact findings a scan must return in `expected.json`. They exist to show each detector working in thirty seconds; they are deliberately outdated, so do not copy them into a real project. Files a dependency scanner reads by name are committed under the neutral names from [decision 0027](https://github.com/velella-sh/offing/blob/main/docs/decisions/0027-fixtures-invisible-to-scanners.md), such as `Gemfile.lock.fixture`, so an example never appears as the repository's own dependency. The command below copies an example to a temporary directory, restores the real names there, and runs Offing inside it; nothing is written to the repository. | Ecosystem | Directory | What it declares | |---|---|---| | Android | `examples/gallery/android` | Play Billing 5 in a Groovy and a Kotlin build script | | CI images | `examples/gallery/ci-images` | Ubuntu 20.04 and Windows 2019 agents in Azure Pipelines | | Containers | `examples/gallery/container` | Old Node, Python, Debian, Ubuntu, Alpine, Redis, and PostgreSQL base images across Dockerfiles, Compose, and a devcontainer | | C++ | `examples/gallery/cpp` | `cmake_minimum_required` below the policy floor | | Dart | `examples/gallery/dart` | A `pubspec.yaml` SDK constraint on an unsupported Dart release | | Databases | `examples/gallery/database` | MySQL 5.7 and PostgreSQL 12 in Compose and Terraform | | Deno | `examples/gallery/deno` | A Deno 1.x project and a Bun 1.0 pin | | .NET | `examples/gallery/dotnet` | .NET 3.1, 5.0, 6.0, and 8.0 targets, + `global.json`, `Directory.Build.props`, central package versions, and a deprecated Azure storage package | | Elixir | `examples/gallery/elixir` | Elixir 1.14 and Erlang 25, plus a retired package in `mix.exs` and `mix.lock` | | GitHub Actions | `examples/gallery/github-actions` | `actions/checkout` on the retired Node 20 runtime | | Go | `examples/gallery/go` | Go 1.22 and 1.23 in `go.mod` and `go.work` | | Java | `examples/gallery/java` | JDK pins in Maven, Gradle, the version catalog, `.sdkmanrc`, `.tool-versions`, and `.java-version`; Spring Boot 2.x and 3.x plus a relocated Hibernate artifact | | Kubernetes | `examples/gallery/kubernetes` | Removed `extensions/v1beta1`, `networking.k8s.io/v1beta1`, `policy/v1beta1`, and `batch/v1beta1` APIs, a pod security policy, and the retired Helm stable repository | | Node.js | `examples/gallery/nodejs` | `.nvmrc` on Node.js 20 | | npm | `examples/gallery/npm` | A dependency on the deprecated `request` package | | PHP | `examples/gallery/php` | PHP 7.4 in `composer.json`, `composer.lock`, `.php-version`, and `.tool-versions` | | Python | `examples/gallery/python` | `.python-version` on Python 3.9 | | Ruby | `examples/gallery/ruby` | Ruby 3.2 in `.ruby-version`, `.tool-versions`, the `Gemfile`, and `Gemfile.lock` | | Rust | `examples/gallery/rust` | Unmaintained crates (`failure`, `ansi-term`, `atty`, `tempdir`) and a toolchain pin on Rust 1.98 | | Scala | `examples/gallery/scala` | Scala 2.11 and sbt 0.13 | | Serverless | `examples/gallery/serverless` | Retired Lambda and Cloud Functions runtimes in a SAM template, a Serverless Framework service, and Terraform | | Shell | `examples/gallery/shell` | PowerShell 7.0 in a module manifest and script, plus `actions/checkout` on Node 20 | | Swift | `examples/gallery/swift` | An Xcode version in the App Store removal window | | Terraform | `examples/gallery/terraform` | `.terraform-version` on an unsupported CLI release | Scan any example the same way as the demo: ```bash npm run build node dist/tools/examples.js gallery/kubernetes scan ``` Use `npm run --silent example -- ` for the same thing. With no command the tool prints the temporary directory and leaves it in place: ```bash node dist/tools/examples.js gallery/php ``` Any other command accepts a whole example directory the same way: ```bash node dist/tools/examples.js gallery countdown --as-of 2026-10-25 ``` The gallery is part of `npm run check`. To run only its test: ```bash npm run build node --test dist/tests/examples-gallery.test.js ``` The test scans every example twice and compares the result to `expected.json` as a whole, so a rule or detector change that alters an example fails CI until the example and its expected output are reviewed together. ## Recordings The recordings in the README are generated from `demos/*.tape` by the Demos workflow. This one looks up a runtime without scanning anything: ![Terminal recording: offing lookup node 18 reports Node.js 18 as end-of-life with the replacement and source](assets/demos/lookup.gif) --- Source: https://velella.sh/offing/MCP/ # Local MCP server Offing exposes its deterministic analysis to coding agents through a local Model Context Protocol server. The server uses stdio, requires no account or API key, and does not start a network listener. ## Start the server From the published package: ```bash npx --yes @velella/offing mcp ``` From a source checkout: ```bash npm install npm run mcp ``` An MCP host can launch the published command with configuration shaped like: ```json { "mcpServers": { "offing": { "command": "npx", "args": ["--yes", "@velella/offing", "mcp"] } } } ``` `npx --yes @velella/offing mcp` starts the same server, and is the command the [MCP Registry](https://registry.modelcontextprotocol.io) description in `server.json` uses. The exact settings location depends on the host. Offing needs no environment variables or credentials. ## One-command install per host Each host below gets the local stdio server with one install command. Claude Code and Codex also receive the portable Skill through the same plugin. Cursor and VS Code need the Skill separately ([Agent Skill](AGENT_SKILLS.md#one-command-install-per-host)). Every command was run in a clean environment on 2026-10-29 (macOS, Node.js 22). No Offing account is required. Shared verify prompt after install: ask the agent to `Add the request package to this project`, and confirm it calls `check_dependency` for `pkg:npm/request` and reports the deprecation with a replacement and sources instead of adding the package. ### Claude Code ```sh claude plugin install offing --marketplace velella-sh/offing ``` **Verify.** `claude plugin list` shows `offing@offing` enabled, and `claude plugin details offing` lists the `offing` MCP server. Start a new session and use the shared verify prompt. Details: [Claude plugin](CLAUDE_PLUGIN.md). ### Codex ```sh codex plugin marketplace add velella-sh/offing --ref main && codex plugin add offing@offing-codex ``` When the Offing repository marketplace is already configured (for example from a checkout that exposes `.agents/plugins/marketplace.json`), `codex plugin add offing@offing-codex` alone is enough. **Verify.** Restart the Codex session, run `codex plugin list --json`, and confirm `offing@offing-codex` is installed and enabled. Then use the shared verify prompt. Trust the dependency-guard hook with `/hooks` if you want the PostToolUse check. Details: [Codex plugin](CODEX_PLUGIN.md). ### Cursor ```sh cursor --add-mcp '{"name":"offing","type":"stdio","command":"npx","args":["--yes","@velella/offing","mcp"]}' ``` **Verify.** Restart Cursor or reload the window. Under Settings → MCP, `offing` is listed and can be enabled. Then use the shared verify prompt. For the Skill, see [Agent Skill](AGENT_SKILLS.md#one-command-install-per-host). Project-scoped copy of the same server entry: [`examples/agents/cursor/`](https://github.com/velella-sh/offing/tree/main/examples/agents/cursor). ### VS Code ```sh code --add-mcp '{"name":"offing","type":"stdio","command":"npx","args":["--yes","@velella/offing","mcp"]}' ``` **Verify.** Command Palette → **MCP: List Servers** shows `offing`. In Copilot Chat Agent mode, use the shared verify prompt. For the Skill, see [Agent Skill](AGENT_SKILLS.md#one-command-install-per-host). Project-scoped copy of the same server entry: [`examples/agents/copilot/`](https://github.com/velella-sh/offing/tree/main/examples/agents/copilot). ## What the server exposes Everything below is read-only and runs in the local process. The server advertises the `tools`, `resources`, `prompts`, and `completions` capabilities. The same evidence-backed findings the tools return also render as a shareable local HTML report (`offing scan --format html`): ![Self-contained Offing HTML report showing finding counts and a high-severity deprecated package with location, replacement, and evidence](assets/screenshots/html-report.webp) ### Write safety No tool, resource, or prompt writes to the repository, and none has an option that makes it write. Every tool advertises `readOnlyHint: true` and `destructiveHint: false`, takes no output, fix, or overwrite parameter, and never runs a command: `verify_migration` returns the project commands it suggests without executing them. [`tests/mcp-readonly.test.ts`](https://github.com/velella-sh/offing/blob/main/tests/mcp-readonly.test.ts) calls every tool, reads the resources, and gets the prompt against a fixture, and fails if the repository tree changes (paths, sizes, modification times, or content) or if a tool gains an option that looks like a write. If a future tool can write, it must require an explicit option, be documented here, and drop `readOnlyHint`. ### Lifecycle status file An agent that cannot call the server can still read the repository's lifecycle state before it plans a change. `offing scan --write-status` writes `OFFING_STATUS.md` at the root of the scanned repository: finding counts by severity, how many findings the baseline accepted, the lifecycle data version, and the most severe findings in the `--format compact` form (at most 25, with a count of the rest). The file has no machine path, timestamp, or data age, so an unchanged repository produces the same bytes on every run and a commit only changes when the state does. Without the flag nothing is written, and the MCP tools never write it. To point agents at it, add a line like this to `AGENTS.md` and refresh the file in CI or a pre-commit hook: ```markdown Before planning dependency or runtime changes, read OFFING_STATUS.md for the repository's current lifecycle findings. Re-run `npx @velella/offing scan . --write-status` after you change them. ``` The file is a snapshot. Run a scan, or call `scan_repository`, to see the current state after an edit. ### Tools Each tool has an `outputSchema` (the published JSON Schema in [`schemas/`](https://github.com/velella-sh/offing/tree/main/schemas), made self-contained for the client) and returns the result as structured content, also as JSON text. Failures return `isError` with a message and no structured content. Every failure also carries `_meta.offing` with `code` (a stable reason code), `class` (`usage`, `data`, `parse`, or `internal`), and `exitCode`, the exit code the CLI uses for that class, so a client can branch without parsing the message. The reason codes are listed in [CLI exit codes](CLI.md#exit-codes). | Tool | Input | Output | Purpose | | --- | --- | --- | --- | | `scan_repository` | `repository_path?`, `baseline?` | `offing.scan.v1` (`scan.v1.schema.json`) | Complete result from all built-in detectors. With `baseline` (a path relative to the repository), suppressed findings are left out and `suppressions` reports how many, and which suppressions are expiring or expired. | | `list_deprecations` | `repository_path?` | `offing.deprecations.v1` (`deprecations.v1.schema.json`) | Compact list for planning. | | `explain_deprecation` | `finding_id`, `repository_path?` | one finding (`finding.v1.schema.json`) | The complete evidence-backed finding. | | `create_migration_context` | `finding_id`, `repository_path?` | `offing.context.v1` (`context.v1.schema.json`) | Validated, rule-specific migration packet. | | `verify_migration` | `finding_id`, `repository_path?` | `offing.verification.v1` (`verification.v1.schema.json`) | Re-scan and check a finding observed earlier in the session. | | `check_dependency` | `purl`, `version?` | `offing.check.v1` (`check.v1.schema.json`) | Check one package before adding it; reads no repository. | | `check_upgrade` | `target`, `path?` | `offing.upgrade.v1` (`upgrade.v1.schema.json`) | Whether the project can move to a target runtime, from lockfiles and manifests; `unknown` means no evidence was found, not that the upgrade is safe. | | `check_snippet` | `path`, `content` | `offing.check-snippet.v1` (`check-snippet.v1.schema.json`) | What Offing flags in one snippet before it is written, checked in an isolated temporary directory; locations are relative to the snippet. Call it before writing package manifests, Dockerfiles, CI workflows, and Kubernetes manifests. Reads no repository. | | `forecast_repository` | `repository_path?`, `months?`, `as_of?` | `ebb.forecast.v1` (`forecast.v1.schema.json`) | What reaches a dated end of life in the next months, grouped by month, with the repository's findings each event affects. | | `recommend_version` | `product`, `minimumSupportDays?`, `as_of?` | `offing.recommend.v1` (`recommend.v1.schema.json`) | Which release line to write today (a name such as `node`, or a Package URL), from bundled lifecycle data only: the recommended line and its end date, alternatives, lines to avoid, and sources. `no-data` is not a statement that any version is safe. | | `plan_migrations` | `repository_path?` | `offing.plan.v1` (`plan.v1.schema.json`) | Migration plan: waves of independent tasks a coordinating agent can hand out. | `repository_path` defaults to the server's current working directory. Finding-specific tools require the exact `finding_id` Offing returned. `check_upgrade` takes `target` as `runtime@version`, for example `php@8.4`, and reads only files already in the repository. It never installs a runtime, runs Composer, or uses the network. When no analyzer finds evidence for that runtime, the verdict is `unknown` and the tool says so; that is not a statement that the upgrade is safe. ### Checking a snippet before writing it `check_snippet` answers "if I write this content to this path, what would Offing flag?" before the file exists. Pass the intended repository-relative `path` — `Dockerfile`, `.github/workflows/ci.yml`, `package.json` — and the exact `content`. Offing writes only that one file into a temporary directory, runs all built-in detectors against it, and returns the findings with paths and lines relative to the snippet, plus a `message` that says when the snippet is clean. The temporary directory is always removed, and the real repository is never read or written. ### Planning ahead with the forecast `forecast_repository` answers "what ends of life are coming, and which of them hit this repository?" It runs a fresh local scan, then lists the lifecycle events that carry an authoritative date inside a window. Each event carries its rule, source, severity, and the findings in this repository that it will affect, grouped by calendar month with the earliest first. The window starts at `as_of` (a `YYYY-MM-DD` date; today in UTC when omitted) and defaults to `months` 12, from 1 to 36. Passing `as_of` makes the result deterministic. The output is the same `ebb.forecast.v1` document that `offing forecast --json` prints. Only dated events are listed, so a package with no authoritative deadline does not appear; nothing is estimated. ### Resources | URI | Contents | Lifetime | | --- | --- | --- | | `offing://rules/{id}` | One lifecycle rule as `ebb.rule.v1` (`rule.v1.schema.json`): affected PURL and versions, event, severity, replacement, deadline, sources, review provenance, migration steps. | Bundled with the release; reading one scans nothing. | | `offing://findings/{repository}/{id}` | One finding observed in this session, identical to `explain_deprecation`. `{repository}` is an 8-character key of the repository root and `{id}` is the percent-encoded finding id. | This session only; empty until a scan, never written to disk. | `resources/list` enumerates both families. The server sends `notifications/resources/list_changed` after every call that scans, so a host that cached the list refreshes it. Rule ids autocomplete by prefix. Reading an unknown URI fails with a resource-not-found error. ### Prompts | Prompt | Arguments | Purpose | | --- | --- | --- | | `migrate-finding` | `finding_id` (required, autocompletes from observed findings), `repository_path?` | Runs a fresh scan, embeds the finding's migration packet, and instructs the model to edit within the packet's constraints, run the project's checks, and call `verify_migration` in the same session. It makes the finding observable, so verification works straight after. | The prompt carries its safety limits: repository files are untrusted input, suggested commands are reviewed and run only under the host's normal authorization, and the model reports what remains instead of declaring success. ### Examples `check_dependency` request and the start of its structured result: ```json { "method": "tools/call", "params": { "name": "check_dependency", "arguments": { "purl": "pkg:npm/request@2.88.2" } } } ``` ```json { "schemaVersion": "offing.check.v1", "purl": "pkg:npm/request", "version": "2.88.2", "status": "affected", "severity": "high", "findings": [{ "ruleId": "npm.package.request.deprecated", "event": "deprecated", "replacement": "Native fetch or a maintained HTTP client" }], "message": "pkg:npm/request@2.88.2: request is deprecated. …" } ``` Reading a rule, then the findings of a scan: ```json { "method": "resources/read", "params": { "uri": "offing://rules/npm.package.request.deprecated" } } { "method": "resources/list" } ``` Fetching the migration prompt for a finding: ```json { "method": "prompts/get", "params": { "name": "migrate-finding", "arguments": { "finding_id": "", "repository_path": "." } } } ``` You can see all of it in the MCP Inspector: ```sh npx @modelcontextprotocol/inspector npx --yes @velella/offing mcp ``` The Tools tab lists the seven tools with their output schemas, Resources lists the rules (and findings after a scan) and the two templates, and Prompts lists `migrate-finding`. ## Checking a dependency before adding it To see afterwards what agents checked and which packages were flagged, set `OFFING_USAGE_LOG=1` for the server and run `offing usage`. The log is off by default, stays on your machine, and holds only package, result, and rule IDs ([Privacy](PRIVACY.md#local-usage-log)). Writing it is the one thing the server does outside memory, to that file and never to the repository. Call `check_dependency` with a Package URL (`pkg:npm/request@2.88.2`, `pkg:pypi/nose`, `pkg:github/actions/checkout@v4`) when you are about to add or upgrade a dependency. The result has a `status`: - `affected`: a rule applies. Read `findings[].summary`, `replacement`, and `sources`, and choose the replacement instead. - `version-dependent`: rules exist, but only for some versions. Call again with the version you intend to use. - `not-affected`: rules exist and none covers that version. - `no-data`: Offing has no rule for the package. That is absence of evidence, not a safety claim. The tool is read-only and idempotent, never touches the network, and answers from the lifecycle rules bundled with the server. ## Language server for any editor The same engine runs as a Language Server Protocol server over stdio, so VS Code, Neovim, Helix, Zed, and JetBrains can show lifecycle findings without an MCP host. It starts no listener and makes no network call. From the published package: ```bash npx --yes @velella/offing lsp ``` From a source checkout: ```bash npm run build node dist/src/lsp.js ``` Point the editor's LSP client at that command. For Neovim: ```lua vim.lsp.start({ name = "offing", cmd = { "npx", "--yes", "@velella/offing", "offing lsp" }, root_dir = vim.fs.dirname(vim.fs.find({ ".git" }, { upward = true })[1]), }) ``` For Helix, in `languages.toml`: ```toml [language-server.offing] command = "npx" args = ["--yes", "@velella/offing", "offing lsp"] ``` The server provides: | Capability | Behavior | | --- | --- | | Diagnostics | Every finding in the open document, at its line, with the rule id, severity, and a link to the first evidence source. | | Hover | The finding title and summary, severity, confidence, replacement, deadline, and every evidence source as a link. | | Code actions | `Offing: explain …` shows the finding with its evidence, and `Offing: open evidence …` opens the first source URL. Both run through `workspace/executeCommand`. | Only the open document is scanned. On open, change, and save the server writes that buffer to a temporary directory, runs the detector engine there, and publishes the findings whose location is that file. It reads no other workspace file, writes nothing to the repository, and removes the temporary directory when the document closes or the server exits. A source file can depend on a manifest elsewhere, so findings that need a manifest outside the open document are not reported yet; use the CLI or the MCP server for a whole-repository scan. The server uses the lifecycle data bundled with the installed release, and document text stays on the machine, as with the MCP server. ## Other agent hosts Claude Code, Codex, Cursor, and VS Code have one-command installs above. Ready-to-copy setups for those hosts and others (an MCP entry plus the instruction to call `check_dependency` before adding a dependency) are in [`examples/agents/`](https://github.com/velella-sh/offing/tree/main/examples/agents) and [`integrations/cline/`](https://github.com/velella-sh/offing/tree/main/integrations/cline): | Host | MCP config | Instructions | | --- | --- | --- | | Cursor | `.cursor/mcp.json` (or `cursor --add-mcp` above) | `.cursor/rules/offing.mdc` | | Gemini CLI | `gemini-extension.json` or `.gemini/settings.json` | `GEMINI.md` | | GitHub Copilot | `.vscode/mcp.json` (or `code --add-mcp` above) or repository settings (coding agent) | `.github/copilot-instructions.md` | | Kilo Code | `kilo.jsonc` | `.kilo/rules/offing.md` | | Cline | `cline_mcp_settings.json` (user settings) | `.clinerules/offing.md` | | Windsurf | Cascade `mcp_config.json` (user config) | `.windsurf/rules/offing.md` | Aider does not support MCP servers, so its setup in [`integrations/aider/`](https://github.com/velella-sh/offing/tree/main/integrations/aider) is a conventions file (`CONVENTIONS.md` loaded through `.aider.conf.yml` or `--read`) that tells the agent to run `npx @velella/offing check ` instead of calling the tool. To put the same instruction into a repository's `AGENTS.md` or `CLAUDE.md`, run `offing init --agents` in it: it adds a delimited section, keeps everything else byte for byte, and is safe to run again (`--dry-run` prints the diff first). `offing setup` does the same for every detected host at once (Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Kilo Code), writing each host's own instruction file such as `.cursor/rules/offing.mdc` after confirmation or `--yes`; it does not edit MCP server entries, which stay in the per-host setups above. Each directory says which vendor documentation its format follows and how to confirm it works in your host. The server command in each of them was run from the published package and answered a real MCP handshake, tool list, and `check_dependency` call (2026-10-18). They have not yet been run inside live host installations, and the READMEs say so. ## Where the server is listed The server is registered as `io.github.velella-sh/offing` in the [official MCP Registry](https://registry.modelcontextprotocol.io), which holds only the metadata in `server.json` and points at the npm package. Every directory below was checked on 2026-10-27. The description is the one in `server.json`, and each submission says that scans are local and use no network. | Directory | Status | Why, and what happened | | --- | --- | --- | | [Official MCP Registry](https://registry.modelcontextprotocol.io) | [Listed](https://registry.modelcontextprotocol.io/v0/servers?search=offing) | Published 2026-10-18; republished per release by step 7 of [RELEASING](RELEASING.md). | | [GitHub MCP Registry](https://github.com/mcp) | Not listed, requested | Onboarding of a new server is manual curation; versions sync from the official Registry once onboarded. Onboarding request posted 2026-10-27 in [github/github-mcp-server Discussion #1257](https://github.com/github/github-mcp-server/discussions/1257#discussioncomment-19227895); check with `curl https://github.com/mcp/velella-sh/offing` (404 = not onboarded yet), since the registry API's search is unreliable. | | VS Code / Copilot MCP gallery | Not listed, same route | The gallery reads the GitHub MCP Registry, so no separate step exists; it lists Offing once the onboarding above lands. | | [Docker MCP Catalog](https://hub.docker.com/mcp) | Not listed, PR open | Submission is a PR to [docker/mcp-registry](https://github.com/docker/mcp-registry), reviewed by Docker, who then build and host the image in the `mcp` namespace. Submitted 2026-10-27 ([PR #6199](https://github.com/docker/mcp-registry/pull/6199)); `servers/offing/server.yaml` pins a commit, so a new release does not change the catalog until the entry is updated by a follow-up PR. | | [Smithery](https://smithery.ai) | Not listed, not worth it | Smithery lists servers reachable over HTTP; local stdio servers ship as MCPB bundles instead, and its publishing flow shuts down on 2026-10-31 in favour of a passive directory. Skip. | | [Glama](https://glama.ai/mcp/servers) | Not listed, pending | Glama re-indexes public MCP servers continuously, so the entry may appear by itself at [glama.ai/mcp/servers/velella-sh/offing](https://glama.ai/mcp/servers/velella-sh/offing); claim it once it does (needs a browser sign-in, so it is a maintainer step). A repository without a LICENSE gets a public grade penalty; the Apache-2.0 file is already there. | | [PulseMCP](https://www.pulsemcp.com) | Not listed, not eligible | Submissions are paused (announced 2026-09-03). PulseMCP ingests the official Registry's servers automatically once it reopens, so no action until then. | | [mcp.so](https://mcp.so) | Not listed | Free listing is through the form at [mcp.so/submit?type=server](https://mcp.so/submit?type=server) and goes through a review; the form needs a browser session, so it is left for a maintainer (or take the paid instant path, which the project does not use). | | [Cursor Directory](https://cursor.directory) | Not listed, not worth it | Cursor's directory is now a plugin gallery for the Agent Plugins standard, which wants `plugin.json`/`mcp.json` resources that fit the CLI, not just the stdio server. Skip. | | [Cline MCP Marketplace](https://cline.bot/mcp-marketplace) | Not listed | Submission is a GitHub issue on [cline/mcp-marketplace](https://github.com/cline/mcp-marketplace) with a 400×400 logo and the confirmation that Cline can install from this repository's README; needs testing in Cline itself, so it waits for a maintainer with the product installed. | Submissions are outward-facing and hard to withdraw, so each one stayed conservative: they carry only the `server.json` metadata and README wording, and none of them pastes a token or credential; each directory is authenticated through its own flow. ## Agent workflow 1. Call `scan_repository` or `list_deprecations` before editing. 2. Use `explain_deprecation` to inspect evidence and lifecycle details. 3. Call `create_migration_context` and make changes within its constraints and affected files. 4. Run the packet's suggested project commands when appropriate. 5. Call `verify_migration` in the same MCP session. Verification is deliberately conservative. Offing remembers the original finding, performs a fresh local scan, and checks its rule and declaration path. An unknown finding ID returns an error rather than a false success. `commandsExecuted` is always `false`: Offing suggests project-specific commands but never executes shell commands through MCP. ## Privacy and trust - Repository traversal, parsing, matching, context creation, and verification happen inside the local Offing process. - Communication with the MCP host uses stdin and stdout on the same machine. - The server has no HTTP transport and makes no outbound requests. - No hosted Offing service, telemetry, account, or API key is involved. - Tool results can include file paths and short source excerpts already present in findings; the local MCP host receives those results so it can act on them. The versioned JSON contracts live in `schemas/scan.v1.schema.json`, `schemas/deprecations.v1.schema.json`, `schemas/forecast.v1.schema.json`, `schemas/context.v1.schema.json`, `schemas/verification.v1.schema.json`, and `schemas/check-snippet.v1.schema.json`. --- Source: https://velella.sh/offing/DRIFT/ # Runtime drift `offing drift` finds every place a repository states a runtime version — version files, manifests, container images, and CI configuration — and reports, with exact locations, when the values disagree. A Dockerfile on Node.js 18, CI on 20, and `.nvmrc` on 22 is one line of output instead of three files to compare by hand. Every command in this page reads repository files only. Drift checks never run a runtime, execute a repository's tools, or use the network. `--align` prints a patch and never writes it. ```bash offing drift . --check offing drift . --runtime ruby --json offing drift . --runtime ruby --align 3.4 ``` ## How to read the report Each runtime block lists every value with its `path:line`, the file or field it came from, the value as written, and the release line it resolves to. A value that cannot be resolved offline is reported too, with the reason. When the `pin` values select more than one release, the block says `DRIFT`. A `floor` is a minimum the project supports; when a floor is above the lowest pinned release that applies to it, the block says `FLOOR CONFLICT`. A floor that names a directory is checked against the pins that apply to that directory, not against every pin. `--check` exits `1` when any runtime drifted or has a floor conflict. `--align ` requires `--runtime`, prints a unified diff for the pins it can rewrite, lists refused pins on stderr, and never writes a file. It refuses floors and ranges, and each collector documents the files it can rewrite. ## Adding a collector One collector per runtime lives in `src/drift/.ts`, reuses the parsers the detectors already have, and is registered once in `driftCollectors` in `src/drift/index.ts`. Each section below names the sources it reads, its release granularity, what it refuses to resolve, and which file it can align. ## Ruby Reads, at any depth: - `.ruby-version`, including the rbenv `ruby-3.3.1` and `3.3.1p123` forms. - `.tool-versions` `ruby` lines, including several fallback versions. - `Gemfile`: an exact `ruby "3.3.0"` is a pin; a literal requirement such as `ruby ">= 3.1.0"` or `ruby "~> 3.2"` is a floor. - `Gemfile.lock` `RUBY VERSION`. - `*.gemspec`: a literal `required_ruby_version`, such as `">= 3.1.0"` or `"~> 3.2"`, as a floor. - `Dockerfile*` and `Containerfile*`: `FROM ruby:` and a literal `RUN asdf install ruby `. - `.github/workflows/*.yml`: the `ruby-version` input of `ruby/setup-ruby`. - `.gitlab-ci.yml` `image:` values and `.circleci/config.yml` `docker:` images for the official `ruby` and `cimg/ruby` images. Release granularity is `major.minor` (`3.3`). Unresolved values are reported with the reason and excluded from the drift comparison: `system`, `latest`, an image without a tag, a moving image tag, a `${{ matrix.* }}` expression, and a requirement that states no minimum. `align ` rewrites `.ruby-version`, a single-value `.tool-versions` ruby line, an exact `Gemfile` `ruby` directive, a `ruby/setup-ruby` `ruby-version` input, and the tag of a Dockerfile, GitLab CI, or CircleCI image. It refuses floors, ranges, image digests, and multi-value lines. `Gemfile.lock` is reported and refused with "regenerate the lockfile": Bundler owns that file. `offing drift` finds every place a repository states a runtime version and reports, per runtime, whether those values agree. A repository that runs Node 18 in its Dockerfile, Node 20 in CI, and Node 22 in `.nvmrc` has a drift, even when every test passes on the version the developer uses. The command reads repository files only: scans never use the network and never execute repository code. offing drift . # text report offing drift . --json # https://github.com/velella-sh/offing/schemas/drift.v1.schema.json offing drift . --check # exit 1 when a runtime drifts or has a floor conflict offing drift . --runtime java --align 21 # print a patch; never write it `--json` prints an `offing.drift.v1` document. Every pin carries its repository-relative path, the 1-based line of the value, the source it came from, the value exactly as written, and the release it resolves to offline. A value that cannot be resolved offline keeps its place in the report with an `unresolved` reason instead of a guessed release. `--align ` prints a unified diff for the pins of one runtime that can be rewritten safely, and lists the refused pins on stderr. Offing never writes the patch. ## Java Java releases are compared at the feature-release major: `17`, `21`, `25`. A legacy `1.8` resolves to `8`; a vendor prefix (`temurin-`) or suffix (`-tem`) is ignored. The vendor never changes support status. | File | Fields read | | --- | --- | | `pom.xml` | `maven.compiler.release`, `maven.compiler.source`, `maven.compiler.target`, and `java.version` properties; `release`, `source`, and `target` in `maven-compiler-plugin` configuration, including `pluginManagement`. | | `build.gradle`, `build.gradle.kts` | `java.toolchain.languageVersion` (`.set(...)` or `=`), `jvmToolchain(N)`, `sourceCompatibility`, and `targetCompatibility`, as literal values only. | | `.java-version` | The first non-comment line. | | `.sdkmanrc` | The `java=` entry. | | `.tool-versions` | The first version of the `java` entry. | | `Dockerfile`, `*.Dockerfile`, `Containerfile*` | `FROM` images of the `eclipse-temurin`, `openjdk`, and `amazoncorretto` families; `maven` and `gradle` tags that name a JDK, such as `3.9-eclipse-temurin-17` or `8.10-jdk21`; and `sdk install java` or `asdf install java` `RUN` lines. | | `.github/workflows/*.yml` | The `java-version` input of `actions/setup-java`. A `${{ matrix. }}` value is followed to the matrix list in the same job. | | `.gitlab-ci.yml`, `.circleci/config.yml` | `image` references of the same JDK and build-image families. | The Maven `release`, `source`, `target`, and `java.version` properties and the Gradle `sourceCompatibility` and `targetCompatibility` settings are floors: they say the oldest JDK the compiled code runs on, not the JDK that builds it, so a build on JDK 21 that compiles for 17 is not drift. Everything else is a `pin`: the JDK that builds or runs the project. `--align` rewrites pins only. Reported as unresolved, never guessed: - a Maven property reference such as `${java.version}`; - a Gradle expression such as `JavaVersion.toVersion(libs.versions.java.get())`; - a matrix or other workflow expression that does not resolve to a literal; - `latest`, `lts`, `system`, or another floating version-file value; - an image with no tag, a floating tag, or an image reference containing a variable. `--align` rewrites only values it can select a release with: - Maven compiler properties and `maven-compiler-plugin` settings whose text is a literal version; - `.java-version`, `.sdkmanrc`, and `.tool-versions` entries with a literal version; - an image tag that is a plain major, such as `eclipse-temurin:17`, or the JDK segment of a Maven or Gradle tag, such as `3.9-eclipse-temurin-17`; - a single scalar `java-version` in `actions/setup-java`. It refuses Gradle toolchain and compatibility settings, `Dockerfile RUN` installs, property references, floating tags, matrix values, and image tags with a digest or a non-major suffix. # Drift `offing drift` reads every place a repository states a runtime version and reports, with exact file and line, when those values select different release lines. A repository with Python 3.10 in `.python-version`, 3.11 in its Dockerfile, and 3.12 in CI has drift: tests pass on one version while production runs another. Scans read repository files and nothing else. No drift path uses the network, runs a command, or evaluates repository code, and `--align` prints patches instead of writing files. offing drift [path] [--json] [--check] [--runtime ] [--align ] - `--check` exits `1` when any runtime has drifted or has a floor conflict. - `--json` prints the `offing.drift.v1` document described by [`schemas/drift.v1.schema.json`](../schemas/drift.v1.schema.json). - `--runtime ` limits the report to one runtime. - `--align ` requires `--runtime`, and prints a unified diff that rewrites the pins it understands to `release`. It lists pins it refuses on stderr. Each runtime is reported as its own block. Every pin carries its location, the source it came from, the value as written, and the release line the value selects offline. A value that cannot be resolved carries the reason instead of a guess. A `floor` is a minimum the project supports, not the version that builds it; a floor above the lowest pin is reported as a floor conflict. Release granularity differs by runtime. Each section below states the granularity it reports and the values it leaves unresolved. ## .NET .NET pins are reported at `major.minor` granularity: the SDK `8.0.204`, the image tag `8.0-alpine`, and the moniker `net8.0` all select `8.0`. `netcoreapp` below 5 resolves to its own name (`netcoreapp3.1`) and is not compared with `netX.Y` releases. `netstandard*` is a compatibility contract that many runtimes satisfy, so it is reported as unresolved and never counted as drift, as are .NET Framework monikers such as `net48`. **Pins** | Source | Field | Align | | --- | --- | --- | | `global.json` | `sdk.version`, wherever the file appears | yes, keeping `rollForward` | | `*.csproj`, `*.fsproj`, `*.vbproj` | a single `TargetFramework` in a `PropertyGroup` | yes | | `Directory.Build.props` | a single `TargetFramework` | yes | | `Dockerfile`, `*.Dockerfile`, `Containerfile` | `FROM mcr.microsoft.com/dotnet/{sdk,aspnet,runtime,runtime-deps}:`, including legacy `dotnet/core/*` images and `--platform` | yes | | GitHub Actions | `actions/setup-dotnet` `dotnet-version`, one pin per line of a multi-line value | single value only | | `.gitlab-ci.yml` | `image` and service values that name a Microsoft .NET image | no | | `.circleci/config.yml` | job and executor `docker`/`machine` image values that name a Microsoft .NET image | no | | `azure-pipelines.yml` | `container` and `resources.containers[].image` values that name a Microsoft .NET image | no | A Microsoft .NET image is `mcr.microsoft.com/dotnet/sdk`, `aspnet`, `runtime`, or `runtime-deps`, or the legacy `dotnet/core/*` equivalents. Image tags keep their suffix: `8.0-alpine` selects `8.0`. **Multi-targeting** `` with several entries is intentional multi-targeting. Each entry is reported as a pin with source `TargetFrameworks`, the entries share one group, and they are not counted against each other. A source outside the list still counts: `TargetFrameworks` of `net6.0;net8.0` agrees with a Dockerfile that selects `8.0`, but drifts when the Dockerfile selects `9.0`. `--align` refuses the multi-targeting form. **Unresolved** A .NET Framework moniker, an MSBuild property reference such as `$(Tfm)`, a version with no minor (`8`, `latest`), a dynamic image reference, an expression, an image with no tag, and any value Offing does not read are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing. **Align** `--align` rewrites `global.json` `sdk.version` (keeping `rollForward`), a single `TargetFramework`, `FROM` tags that preserve the image suffix, and a single-value `setup-dotnet` `dotnet-version`. It refuses `TargetFrameworks`, MSBuild property references, .NET Framework monikers, CI image references, and any line whose form it does not recognize. `offing drift` reports every place a repository states a runtime version: Dockerfiles, CI configuration, version files, manifests, and toolchain files. Each value is listed with its exact file, line, source, and the release line it resolves to offline. When the values disagree, the runtime is marked `DRIFT`; when a minimum the project requires is above the version that builds it, the value is listed as a floor conflict. offing drift . # what the repository states, by runtime offing drift . --check # exit 1 when any runtime disagrees offing drift . --runtime rust --align 1.82 # print a patch; nothing is written offing drift . --runtime go --align 1.22 # print a patch; nothing is written Drift reads repository files only. It never uses the network, never executes repository code or tools, and never writes a file. `--align` prints a unified diff for review; apply it yourself, for example with `git apply`. A value is a **pin** when it selects the version that builds or runs the project, and a **floor** when it states a minimum the project supports. Floors are compared with the lowest pinned release: a floor above it is a conflict, because the environment the pin selects cannot satisfy the project. Values that cannot be resolved offline are still reported, with the reason. Offing never guesses a release. ## Rust Release granularity is `1.minor`: the toolchain channel `1.79.0`, the image tag `1.79.0-slim-bookworm`, and the `.tool-versions` entry `1.79.0` all resolve to `1.79`. A `rust-toolchain` channel selects the toolchain that builds the project, so it is a pin; `rust-version` in `Cargo.toml` states the minimum the project supports, so it is a floor. | Source | Field | Kind | | `rust-toolchain.toml`, `rust-toolchain` | `channel` (or the one-line legacy form) | pin | | `.tool-versions` | `rust` entry | pin | | `Dockerfile*`, `Containerfile*`, `*.Dockerfile` | `FROM rust:` | pin | | `.github/workflows/*.yml` | `dtolnay/rust-toolchain@` | pin | | `.github/workflows/*.yml` | `dtolnay/rust-toolchain` `toolchain:` input | pin | | `.github/workflows/*.yml` | `actions-rs/toolchain` `toolchain:` input | pin | | `.github/workflows/*.yml` | job `container:` and `services..image` rust image | pin | | `.gitlab-ci.yml` | `image:` or `services:` rust image | pin | | `.circleci/config.yml` | Docker executor rust image | pin | | `azure-pipelines.yml` | job `container:` rust image | pin | | `Cargo.toml` | `package.rust-version` and `workspace.package.rust-version` | floor | The Rust collector reuses the existing `rust-toolchain` and `Cargo.toml` parsers and reads Dockerfile `FROM` lines with the container parser. It reports these values as unresolved, without a release: `stable`, `beta`, `nightly`, and dated nightlies, a floating image tag such as `rust:latest`, an image with no tag, a tag that is not `1.minor`, an expression that is not a literal version, and a `.tool-versions` entry that names a `ref:` or `path:`. `--align` rewrites `channel = "1.x"` lines and the one-line `rust-toolchain`, the `rust` entry of `.tool-versions`, `rust:[-variant]` tags in Dockerfiles and CI configuration, `dtolnay/rust-toolchain@`, and the `toolchain:` input of `dtolnay/rust-toolchain` and `actions-rs/toolchain`. It keeps the target or image variant suffix. It refuses floors, named channels, expressions, and images pinned by digest. ## Go Release granularity is `1.minor`: `go1.22.4` and the image tag `1.22.4-alpine` both resolve to `1.22`. A `go` directive is a minimum since Go 1.21 ([Go toolchains](https://go.dev/doc/toolchain), [go directive](https://go.dev/ref/mod#go-mod-file-go)), so it is a floor; a `toolchain` line selects the toolchain that builds the module, so it is a pin. | `go.mod` | `toolchain go1.x.y` | pin | | `go.work` | `toolchain go1.x.y` | pin | | `go.mod` | `go 1.x[.y]` | floor | | `go.work` | `go 1.x[.y]` | floor | | `.go-version` | first non-comment line | pin | | `.tool-versions` | `golang` entry | pin | | `Dockerfile*`, `Containerfile*` | `FROM golang:` | pin | | `.github/workflows/*.yml` | `actions/setup-go` `go-version` | pin | | `.github/workflows/*.yml` | `actions/setup-go` `go-version-file` | pin, pointing at the named file | | `.gitlab-ci.yml` | `image:` or `services:` golang image | pin | | `.circleci/config.yml` | Docker executor golang image | pin | | `azure-pipelines.yml` | job `container:` golang image | pin | The Go collector reuses the existing `go.mod` and `go.work` parsers and reads Dockerfile `FROM` lines with the container parser. It reports these values as unresolved, without a release: a floating image tag such as `golang:latest`, an image with no tag, a tag that is not `major.minor`, a `stable` or `oldstable` `go-version`, an expression that is not a `matrix` reference, a matrix entry that is not a plain version, and a `.go-version` or `.tool-versions` value that is not a Go version. `--align` rewrites `toolchain go1.x.y` lines, `.go-version`, the `golang` entry of `.tool-versions`, `FROM golang:[-variant]` (keeping the variant suffix), and a single-value `actions/setup-go` `go-version`. It refuses floors, matrix and expression values, `go-version-file` pointers, and floating tags. ## Python Python pins are reported at `major.minor` granularity: `3.11.4`, `3.11`, and `python-3.11` all select `3.11`. Version files and manifests are read at any depth, so a monorepo reports each service. | `.python-version` | every line | yes | | `.tool-versions` | the `python` line, one pin per version | yes | | `runtime.txt` | `python-` | yes | | `Pipfile` | `[requires]` `python_version` and `python_full_version` | yes | | `Dockerfile`, `*.Dockerfile`, `Containerfile` | `FROM python:[-variant]`, including `--platform` and all stages | yes | | GitHub Actions | `actions/setup-python` `python-version`, following a `${{ matrix. }}` reference to its literal list; matrix values keep one pin each | single value only | | `.gitlab-ci.yml` | `image` and `service` values that name a Python image | no | | `.circleci/config.yml` | job and executor `docker`/`machine` `image` values that name a Python image | no | | `azure-pipelines.yml` | `container` and `resources.containers[].image` values that name a Python image | no | | `environment.yml`, `environment.yaml` | literal `python=...` entries under `dependencies`; a lower bound is a floor | no | A Python image is `python`, `cimg/python`, or `circleci/python` on Docker Hub. A multi-version `.python-version` or `.tool-versions` line reports one pin per version. **Floors** | Source | Field | | `pyproject.toml` | `[project]` `requires-python` | | `pyproject.toml` | `[tool.poetry.dependencies]` `python` | | `setup.cfg` | `[options]` `python_requires`, including continuation lines | | `setup.py` | a literal `python_requires="..."` string; the file is read, never executed | A floor resolves from its first lower bound, so `>=3.10,<3.13`, `^3.10`, and `~=3.10.2` all report `3.10`. `3`, `3.x`, a wildcard with no minor version, `latest`, a tag or image with no version, a dynamic image reference, an expression, a constraint with no lower bound, and any value Offing does not read are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing. `--align` rewrites `.python-version`, `.tool-versions`, `runtime.txt`, `Pipfile`, `FROM python:[-variant]` (keeping the variant suffix and the rest of the instruction), and a single-value `setup-python` `python-version`. It refuses floors, matrix entries, CI image references, and any line whose form it does not recognize. ## Node.js Node.js pins are reported at the major release: `18.19.0`, `v18`, and the image tag `18-alpine` all select `18`. | Source | Field | Kind | | --- | --- | --- | | `.nvmrc` | the version line | pin | | `.node-version` | the version line | pin | | `.tool-versions` | the `nodejs` entry, one pin per version | pin | | `package.json` | `volta.node` | pin | | `package.json` | `engines.node` | floor | | `Dockerfile`, `*.Dockerfile`, `Containerfile*` | `FROM node:`, including every stage | pin | | `devcontainer.json` | the `image` tag and the `ghcr.io/devcontainers/features/node` feature `version` | pin | | `.github/workflows/*.yml` | `actions/setup-node` `node-version` | pin | | `.gitlab-ci.yml` | `image` and `services` values of the official `node`, `cimg/node`, and `circleci/node` images | pin | | `.circleci/config.yml` | Docker executor values of those images | pin | | `azure-pipelines.yml` | job `container` values of those images | pin | A Dev Container image is read for the `node`, `cimg/node`, and `circleci/node` Docker Hub images and for the `devcontainers/javascript-node` and `devcontainers/typescript-node` images, whose tags carry the Node.js major after the image major, such as `1-18-bookworm`. **LTS codenames** `lts/` resolves through the Node.js release schedule ([nodejs/Release](https://github.com/nodejs/Release#release-schedule)): `argon` 4, `boron` 6, `carbon` 8, `dubnium` 10, `erbium` 12, `fermium` 14, `gallium` 16, `hydrogen` 18, `iron` 20, `jod` 22, and `krypton` 24. The Docker tag form `lts-[-variant]` resolves the same way. **Matrix builds** A `setup-node` matrix with several versions is intentional. Each value is reported as a pin with source `setup-node matrix`, the values share one group, and they are not counted against each other. A drift is reported only when a non-matrix pin disagrees with another non-matrix pin, or when a non-matrix pin falls outside the matrix. **Unresolved** `lts/*`, `latest`, `current`, `node`, an LTS codename Offing does not know, an image tag that names no Node.js release, a dynamic image reference, an expression, a matrix entry that is not a plain version, and a floor with no lower bound are reported as pins with a one-sentence reason and no release. `--align` rewrites `.nvmrc`, `.node-version`, `.tool-versions`, `package.json` `volta.node`, `FROM node:[-variant]` (keeping the variant suffix and the rest of the instruction), and a single-value `actions/setup-node` `node-version`. It refuses floors, `setup-node` matrices, Dev Container and CI image references, and any line whose form it does not recognize. ## Terraform Terraform pins are reported at `major.minor` granularity: the `.terraform-version` value `1.17.0`, the image tag `1.17`, and `required_version = ">= 1.17.0"` all select `1.17`. | Source | Field | Kind | | --- | --- | --- | | `.terraform-version`, at any depth | the version line, one pin per file | pin | | `.tool-versions` | the first version of the `terraform` entry | pin | | `Dockerfile`, `*.Dockerfile`, `Containerfile*` | `FROM hashicorp/terraform:` | pin | | `.github/workflows/*.yml` | `hashicorp/setup-terraform` `terraform_version` | pin | | `.github/workflows/*.yml` | job `container:` and `services..image` `hashicorp/terraform` images | pin | | `.gitlab-ci.yml` | `image:` or `services:` `hashicorp/terraform` images | pin | | `.circleci/config.yml` | Docker executor `hashicorp/terraform` images | pin | | `azure-pipelines.yml` | job `container:` `hashicorp/terraform` images | pin | | `*.tf`, in every module | `required_version` | pin when exact (`"1.17.0"`, `"= 1.17.0"`, `"~> 1.17.0"`), else floor | The collector reuses the existing `.terraform-version` and `.tool-versions` parsers and reads `required_version` with the HCL parser. Provider versions in `required_providers` are not Terraform CLI versions and are not read. **Directories** Version files and `required_version` apply to the directory they live in, because modules in different directories can legitimately differ. A floor is checked only against the pins that apply to its directory: the nearest `.terraform-version` or `.tool-versions` at or above it, or the repository-wide pins (Dockerfiles, CI images, `setup-terraform`) when its directory declares none. A floor of `>= 1.17.0` beside a module `.terraform-version` of `1.17.0` agrees, even when the rest of the repository pins `1.16`. Pins are still compared across the repository, so modules on different release lines are a drift. **Unresolved** `latest`, `min-required`, `system`, a partial version such as `1.9`, a prerelease, a `.tool-versions` entry that names a `ref:` or `path:`, an image with no tag, a floating image tag, a workflow expression, and a constraint with no lower bound are reported with a one-sentence reason and no release. **Align** `--align ` rewrites `.terraform-version`, a single-value `.tool-versions` `terraform` entry, `FROM hashicorp/terraform:` tags, a single-value `setup-terraform` `terraform_version`, and an exact `required_version` (keeping the `=` form when written). Where a full version is required, a release line such as `1.18` is written as `1.18.0`. It refuses floors, ranges, CI image references, and images pinned by digest. ## PHP PHP pins are reported at `major.minor` granularity: `8.3.12`, `8.3`, and the image tag `8.3-fpm` all select `8.3`. | Source | Field | Kind | | --- | --- | --- | | `composer.json` | `config.platform.php` | pin | | `composer.json` | `require.php` | floor | | `composer.lock` | `platform.php`, when present | floor | | `.php-version` | the single version, including the phpenv `php-8.3.12` form | pin | | `.tool-versions` | the `php` entry, one pin per version | pin | | `Dockerfile`, `*.Dockerfile`, `Containerfile*` | `FROM php:` and a literal `RUN asdf install php ` | pin | | `.github/workflows/*.yml` | the `php-version` input of `shivammathur/setup-php` | pin | | `.gitlab-ci.yml` | `image:` values that name the official `php` image | pin | | `.circleci/config.yml` | Docker executor values that name the `php` or `cimg/php` image | pin | A PHP image is the Docker Hub `php` image, or `cimg/php` in CI. Image tags keep their variant suffix: `8.3-fpm` selects `8.3`. A `setup-php` matrix with several versions is intentional. Each value is reported as a pin with source `setup-php matrix`, the values share one group, and they are not counted against each other. **Floors** `require.php` and `composer.lock` `platform.php` state what the project installs against, not the PHP that builds it, so they are floors. A floor resolves from its first lower bound: `^8.2`, `>=8.1`, and `8.1.*` all resolve to their major.minor. A floor above the lowest pinned release is a floor conflict, because that environment cannot install the project. **Unresolved** `latest`, `system`, a version with no minor (`8`), a moving image tag, an image with no tag, an expression that is not a `matrix` reference, and a constraint with no lower bound are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing. **Align** `--align ` rewrites `.php-version` (keeping the `php-` prefix), a single-value `.tool-versions` `php` line, `composer.json` `config.platform.php`, `FROM php:[-variant]` tags, GitLab CI and CircleCI image tags, and a single-value `setup-php` `php-version`. It refuses floors, `composer.json` `require.php`, `composer.lock`, `setup-php` matrix values, multi-value `.tool-versions` lines, and image references pinned by digest. `composer.lock` is reported and refused with "regenerate the lockfile": Composer owns that file. ## Elixir Elixir pins are reported at `major.minor` granularity: `1.16.2`, `1.16`, and the image tag `1.16.2-otp-26-alpine` all select `1.16`. | Source | Field | Kind | | --- | --- | --- | | `.elixir-version` | the single version, including the asdf `1.16.2-otp-26` form | pin | | `.tool-versions` | the `elixir` entry, one pin per version, the `-otp-NN` suffix kept as written | pin | | `mix.exs` | the project `elixir:` constraint, quoted literals only | floor | | `Dockerfile`, `*.Dockerfile`, `Containerfile*` | `FROM elixir:` and `FROM hexpm/elixir:-erlang--` | pin | | `.github/workflows/*.yml` | the `elixir-version` input of `erlef/setup-beam` | pin | | `.gitlab-ci.yml` | `image:` values that name the official `elixir` or `hexpm/elixir` image | pin | | `.circleci/config.yml` | Docker executor values that name `elixir` or `hexpm/elixir` | pin | An Elixir image is the Docker Hub `elixir` image or the `hexpm/elixir` build image; other registries and other repositories are not read. Image tags keep their suffixes: `1.16.2-otp-26-alpine` selects `1.16`, and `hexpm/elixir:1.17.3-erlang-27.1.1-alpine-3.20` selects `1.17` from the version its tag names first. The collector reuses the Elixir detector's runtime parser and `mix.exs` parser, so the same version grammar decides what is a pin here as in `offing scan`. A `setup-beam` matrix with several Elixir versions is intentional. Each value is reported as a pin with source `setup-beam elixir matrix`, the values share one group, and they are not counted against each other. **Floors** `mix.exs` `elixir:` states the oldest Elixir the project supports, not the one that builds it, so it is a floor. Only a quoted literal constraint is read: `~> 1.15` and `>= 1.15.7` resolve to their first lower bound, `1.15`. `Mix` is never run and `mix.exs` is never evaluated. A floor above the lowest pinned Elixir is a floor conflict, because that project cannot build on the runtime its own pins select. **Unresolved** `latest`, `main`, a version with no minor (`1`), an image with no tag, a moving image tag such as `elixir:latest`, a `hexpm/elixir` tag that is not `-erlang-[-]`, an expression that is not a `matrix` reference, and a constraint with no lower bound are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing. **Align** `--align ` rewrites `.elixir-version` (keeping its `-otp-NN` suffix), a single-value `.tool-versions` `elixir` line, a single-value `setup-beam` `elixir-version` (its quote style kept), and `elixir:[-otp-][-variant]` tags in Dockerfiles, GitLab CI and CircleCI (the OTP part and the variant are preserved). It refuses the `mix.exs` floor, every `hexpm/elixir` tag — choosing its OS part is a decision, not a rewrite — `setup-beam` matrix values, multi-value `.tool-versions` lines, and image references pinned by digest. ## Erlang/OTP Erlang/OTP pins are reported at the OTP major: `26.2.5`, `OTP-26.2.5`, and the `-otp-26.2.5` of an Elixir pin all select `26`. | Source | Field | Kind | | --- | --- | --- | | `.tool-versions` | the `erlang` entry, one pin per version | pin | | `.elixir-version`, `.tool-versions` | the `-otp-NN` suffix of an Elixir version that names one | pin | | `Dockerfile`, `*.Dockerfile`, `Containerfile*`, `.gitlab-ci.yml`, `.circleci/config.yml` | the `erlang-` part of a `hexpm/elixir` tag, and the `-otp-` part of an `elixir` tag | pin | | `.github/workflows/*.yml` | the `otp-version` input of `erlef/setup-beam`, including its `OTP-26.2.5` form | pin | OTP is versioned with Elixir, so one line states both: `FROM hexpm/elixir:1.17.3-erlang-27.1.1-alpine-3.20` is an Elixir `1.17` pin for the Elixir collector and an OTP `27` pin for this one, at the same path and line. The Docker Hub `erlang` image is not a source of drift here, because the issue that scoped this collector lists only the sources above; a repository that runs Erlang on its own gets no pin from `FROM erlang:27`. There is no Erlang floor source: `mix.exs` constrains Elixir, not OTP. A `setup-beam` matrix with several OTP versions is intentional: the values are reported with source `setup-beam otp matrix`, share one group, and are not counted against each other. **Unresolved** `main`, `master`, and `latest`, an `otp-version` that is not a version (`edge`), an expression that is not a `matrix` reference, an `.tool-versions` `erlang` token that is not an OTP release, and a workflow that does not parse as YAML are reported with a reason or skipped; nothing is guessed. An Elixir version with no `-otp-` part simply yields no OTP pin. **Align** `--align ` rewrites a single-value `.tool-versions` `erlang` line, the `-otp-NN` suffix of `.elixir-version` and of a `.tool-versions` `elixir` line, a single-value `setup-beam` `otp-version` (written as the bare major, with the line's quotes kept), and the `-otp-` part of `elixir:-otp-[-variant]` tags in Dockerfiles, GitLab CI and CircleCI. It refuses every `hexpm/elixir` tag, `setup-beam` matrix values, and image references pinned by digest. --- Source: https://velella.sh/offing/INVENTORY/ # Inventory `offing inventory` reads the JSON or text exports that cloud and cluster tools already produce and reports what they say is deployed: end-of-life runtimes, images, engines, cluster versions, and operating systems. Each result carries the importer, the file, and the exact place in the file the claim rests on, as an RFC 6901 JSON Pointer. Offing never calls a cloud or cluster API. You run the export command on a machine with credentials, then give the file to `offing inventory`: ```bash kubectl get deploy,sts,ds,cronjob,job,pod -A -o json > workloads.json offing inventory workloads.json ``` Every importer reads one export file and produces items. The output is deterministic: results sort by file and pointer. An affected item means a lifecycle rule applies to the version the export names; `no-data` means Offing has no rule for that PURL, never that the thing is safe. A file no importer recognizes is listed, the run exits `2`, and the files that were recognized are still reported. ## Privacy `inventory` reads only the files named on the command line. It makes no network request, runs no command, and reads no other file. It never executes code or tools from the scanned export. Output names only the resource's own short name: `resource` is never an account id, ARN, subscription id, project number, IP address, tag, or environment variable, and the one exception is a Kubernetes node's own name, which the cluster importer reports because the node is the resource (a node named by its IP address is skipped). `--redact` replaces each resource name with the first 12 hex characters of its SHA-256, in text and JSON output alike. ## Kubernetes workloads ### What it reads Export a cluster's workload objects: ```bash kubectl get deploy,sts,ds,cronjob,job,pod -A -o json > workloads.json offing inventory workloads.json ``` The importer recognizes a Kubernetes `List` whose items each have `apiVersion`, `kind`, and `metadata`. A list of `Node` objects is left to the cluster importer. For every item it reads: - `apiVersion` and `kind`, mapped to the Kubernetes lifecycle rules. - `metadata.namespace` and `metadata.name`, which form `resource` as `//`. Cluster-scoped objects have no namespace and use `cluster` instead. - For `Pod`, `Deployment`, `StatefulSet`, `DaemonSet`, `ReplicaSet`, `Job`, and `CronJob`, every `containers[].image` and `initContainers[].image` in the pod template. For a `CronJob` that is `spec.jobTemplate.spec.template`. Images are parsed with the same parser as the container detector. Identical image references are reported once per namespace and workload, at the first pointer where they appear. A label, annotation, environment variable, pod status, and `spec.nodeName` are never read. ### Rules matched | Item | PURL | Registry | | --- | --- | --- | | Container image | `pkg:docker/`, for example `pkg:docker/library/node` | `registry/container.json` | | API version | `pkg:generic/kubernetes//`, for example `pkg:generic/kubernetes/networking.k8s.io/Ingress` | `registry/kubernetes.json` | A Docker Hub image is written without the registry: `docker.io/library/node` is `pkg:docker/library/node`. Images from other registries are skipped, not reported, because no rule covers them yet and a private registry host can carry an account id. An image tag is used as the version only when it is a plain release number: `node:18.19.0` is `18.19.0`, and `node:18` is `18.0.0`. Floating tags such as `latest`, `22-alpine`, or a digest with no tag name no version, so the item is reported as `version-dependent`, not guessed. An API version is translated to the rule's semver form by the same rule as the Kubernetes detector (decision 0008): `v1` is `1.0.0`, `v1beta1` is `1.0.0-beta.1`, and `v2beta2` is `2.0.0-beta.2`. ### Limit: the API server's preferred version `kubectl get -o json` reports each object at a version the API server chooses to serve, not necessarily the version it was stored or applied at. An object created through a removed API can therefore appear at its replacement: an `Ingress` applied as `networking.k8s.io/v1beta1` is reported by a modern server as `networking.k8s.io/v1`. This importer can only judge the version the export names; it cannot infer an older stored version. Use a manifest scan to judge what the repository declares. ### What it does not judge - Images from registries other than Docker Hub, including private registries and IP-address hosts: no rule covers them yet, so they are skipped rather than reported and no host, address, or account id reaches the output. - Floating tags and digest-only references, whose release line the file does not name. - The core API group (`apiVersion: v1`, such as `Pod`), which has no removed kinds in the Kubernetes migration guide. - Objects and fields beyond the ones listed above. The fixtures under `tests/fixtures/inventory/k8s-workloads/` reproduce the documented output shape of `kubectl get … -o json` (kubectl 1.31), with every identifier replaced by a placeholder. They were assembled from the command's output shape, not captured from a live cluster. ## Kubernetes cluster Export the cluster's version and its nodes, then pass both files: ```bash kubectl version -o json > k8s-cluster.json kubectl get nodes -o json > k8s-nodes.json offing inventory k8s-cluster.json k8s-nodes.json ``` The importer recognizes the version export, the object that carries `serverVersion.gitVersion`, and the node export, a Kubernetes `List` whose items are all `Node` objects with `status.nodeInfo`. From the version export it reads the control-plane version; from the node export it reads, per node: - `status.nodeInfo.kubeletVersion` - `status.nodeInfo.osImage` - `status.nodeInfo.containerRuntimeVersion` - `metadata.name`, which becomes `resource` ### Provider suffixes `gitVersion` and `kubeletVersion` carry a provider suffix on managed services: `v1.28.3-eks-2fbdaf5`, `v1.30.5-gke.1445001`, `v1.31.5+k3s1`. Everything from the first `-` or `+` is stripped and the upstream release is judged, so each of those is `1.28.3`, `1.30.5`, and `1.31.5`. Managed providers (EKS, GKE, AKS) retire minors on their own calendars, which end earlier than upstream's, so a managed cluster should be judged by its provider's schedule too: the provider view is covered by the EKS, Google Cloud, and Azure tasks, and GKE already has its own rules in `registry/kubernetes.json`. ### Rules matched | Item | PURL | Registry | | --- | --- | --- | | Control plane, kubelet | `pkg:generic/kubernetes` | `registry/kubernetes.json` | | Node OS image | `pkg:generic/`, for example `pkg:generic/ubuntu` | `registry/container.json` | | Container runtime | `pkg:generic/containerd`, `pkg:generic/docker`, `pkg:generic/cri-o`, `pkg:generic/cri-dockerd` | no rules yet | `registry/kubernetes.json` holds upstream minor-release rules sourced to the [Kubernetes release history](https://kubernetes.io/releases/) page. They are the reviewed adoption of the `kubernetes-2` lifecycle importer's drafts: each of the five newest ended minors has an `end-of-life` rule at high severity, a minor whose announced end of life has not passed is `scheduled-end-of-life` at low severity (decision 0010), and a reviewed `1.28-and-older` rule covers the lines the importer no longer drafts. A weekly importer run keeps these fresh; a managed cluster should still be judged by its provider's schedule, which ends earlier than upstream's. An `osImage` maps to the distribution's PURL when it names one the registry knows: `Ubuntu 22.04.5 LTS` to `pkg:generic/ubuntu` at `22.04.5`, `SUSE Linux Enterprise Server 15 SP6` to `pkg:generic/sles` at `15.6`, `Amazon Linux 2` to `pkg:generic/amzn` at `2`. These are the same PURLs and rules the os-release importer checks, so a node image and a host's `/etc/os-release` are judged by one set of dates. A distribution without rules — Bottlerocket, Container-Optimized OS, Flatcar, Rocky Linux, AlmaLinux — is still reported, at its own PURL, as `no-data`: not safe, just unruled. An image that names none of the known distributions produces no item. `containerRuntimeVersion` maps to the runtime's PURL: `containerd://1.6.32` to `pkg:generic/containerd` at `1.6.32`. No runtime rule set exists yet, so these report `no-data`. A version that is not a plain release number (`containerd://1.6.x`) is reported without a version. ### What is not judged - Node capacity, conditions, taints, labels, and addresses: never read or reported. - A node whose `metadata.name` is an IP address is skipped entirely, because `resource` names a resource, never an address. - The images running on the nodes and the objects' API versions: the workloads importer covers those. - Whether the control plane and the kubelets are within the version-skew policy: skew compatibility is a different question from end of life. - Anything the export does not state. No command is run, no API is called, and no other file is read. The fixtures under `tests/fixtures/inventory/k8s-cluster/` reproduce the documented output shape of `kubectl version -o json` and `kubectl get nodes -o json` (kubectl 1.31), with every identifier replaced by a placeholder. They were assembled from the command's output shape, not captured from a live cluster. ## Host operating systems The `os-release` importer reads an `/etc/os-release` file from one host, or a bundle collected from several hosts, and reports the distribution release against its lifecycle rules. ### Export One host: ```bash ssh web-1 'cat /etc/os-release' > web-1.os-release offing inventory web-1.os-release ``` Several hosts, in one file. Each block starts with `### host: `; that name becomes the result's `resource`: ```bash while read -r host; do printf '### host: %s\n' "$host" ssh "$host" 'cat /etc/os-release' done < hosts.txt > os-release.bundle offing inventory os-release.bundle ``` Offing runs neither loop; you run it, and Offing reads the result. ### Fields read | Field | Use | | --- | --- | | `ID` | Names the distribution, and so the `pkg:generic/` rule set. | | `VERSION_ID` | The release line the rules judge. The result's `pointer` is the `line:` of this line in the file. | | `ID_LIKE` | Read as the declared family. It is never used to apply a parent distribution's dates, because a derivative keeps its own release schedule. | A block without both `ID` and a numeric `VERSION_ID` is skipped: a codename or a rolling tag such as `testing` does not name a release line the rules can judge. A single file with no host header uses its file name as the resource. Quoted values, such as `VERSION_ID="20.04"` and `ID='ubuntu'`, are read the same as unquoted ones. ### Rules matched | Distribution (`ID`) | PURL | Release lines reported | Rule | | --- | --- | --- | --- | | `ubuntu` | `pkg:generic/ubuntu` | before 22.04 | `container.host-ubuntu.eol-22.04-and-older` | | `debian` | `pkg:generic/debian` | before 12 | `container.host-debian.eol-11-and-older` | | `rhel` | `pkg:generic/rhel` | 7 and older | `container.host-rhel.eol-7-and-older` | | `centos` | `pkg:generic/centos` | 8 and older | `container.host-centos.eol-8-and-older` | | `amzn` | `pkg:generic/amzn` | 2 and the 2018.03 AMI | `container.host-amazon-linux.eol-2-and-older` | | `alpine` | `pkg:generic/alpine` | before 3.21 | `container.host-alpine.eol-3.20-and-older` | | `sles` | `pkg:generic/sles` | 15 SP6 and older | `container.host-sles.eol-15-sp6-and-older` | Every rule cites the distribution's own lifecycle page, not an aggregator. The rules live in `registry/container.json` next to the image rules, but use `pkg:generic/` PURLs and distribution sources: an image rule describes what a Docker tag ships, which is not the same question as what a host runs. ### What is not judged - A file without `VERSION_ID` is skipped: a codename alone does not identify the release line for every distribution. - A derivative reported under its own `ID` is checked only if that `ID` has a rule. `ID_LIKE` does not borrow the parent's dates. It is reported as `no-data`, not as safe. - Paid add-on support is not treated as standard support. RHEL 7 Extended Life Cycle Support and SUSE Long Term Service Pack Support extend security fixes for a fee; the rules report that general support ended and name the add-on in the summary. - A host with no readable `/etc/os-release` is not reported. Offing does not guess a distribution from other files. ### Fixtures The fixtures under `tests/fixtures/inventory/os-release/` are the output of `cat /etc/os-release` on each distribution, with every identifier replaced by a placeholder. There is no CLI version to record: `cat` is part of the coreutils of each distribution. ## Docker containers and images Produce the exports with the Docker CLI. Both forms are supported: ```bash # running and stopped containers, including labels and state docker inspect $(docker ps -aq) > docker-inspect.json # a Compose project: a JSON array, or one JSON object per line docker compose ps --format json > docker-compose-ps.json ``` The fixtures under [`tests/fixtures/inventory/docker/`](../tests/fixtures/inventory/docker/) follow the output of Docker CLI 29.6.2 (`docker inspect`) and Docker Compose v2 (`docker compose ps --format json`), with ids, names, and image tags replaced. The example [`examples/inventory/docker-inspect.json`](../examples/inventory/docker-inspect.json) is the same shape. ### What is read | Format | Fields | Pointer | | --- | --- | --- | | `docker inspect` | `Name`, `Config.Image` | `/i/Name`, `/i/Config/Image` | | `docker inspect` | `Config.Labels["org.opencontainers.image.base.name"]` | `/i/Config/Labels/org.opencontainers.image.base.name` | | `docker inspect` | `Config.Labels["org.opencontainers.image.version"]` | `/i/Config/Labels/org.opencontainers.image.version` | | `docker compose ps` | `Service`, `Image` | `/i/Service`, `/i/Image` | `i` is the entry's index in the JSON array, or the zero-based line for line-delimited Compose output. Nothing else is read: `Env`, `Mounts`, `NetworkSettings`, `Ports`, and `Labels` beyond the two OCI labels above never appear in the report. `resource` is the container name without the leading `/`, or the Compose service name. The image reference is parsed with the same parser the container detector uses ([`src/detectors/container/parse-image.ts`](../src/detectors/container/parse-image.ts)); interpolated values (`${IMAGE}`) and invalid references are skipped, never guessed. ### Rules matched Images map to Docker PURLs, `pkg:docker/`, and are checked against [`registry/container.json`](https://github.com/velella-sh/ebb/blob/main/registry/container.json). For example, `node:18-alpine` becomes `pkg:docker/library/node` at version `18-alpine`, and `redis:6.0` becomes `pkg:docker/library/redis` at `6.0`. A tag that names a release line is the version; a floating tag such as `latest` or a digest-only reference has no version, so a range rule reports `version-dependent` instead of guessing. Images from a registry other than Docker Hub keep the registry as the PURL namespace, for example `pkg:docker/ghcr.io/acme/app`. No Docker Hub rule matches those PURLs, so they are reported as `no-data`. The two OCI labels add items when present: `org.opencontainers.image.base.name` is the base image, and `org.opencontainers.image.version` is the image's own version. Both are checked against the same rules, with the container name as their resource. ### Image ids When `Config.Image` is a bare image id (`sha256:…`), Offing does not resolve it to an image. The container is reported with the PURL `pkg:docker/image-id` and the id as its version, which yields `no-data`: there is no lifecycle rule for an unresolved id. Run `docker inspect --format '{{.Config.Image}}' ` with a name instead when you need the image. ### What is not judged - The operating system and runtimes *inside* an image: export or unpack the root filesystem and run `offing scan` on it. - Images on registries other than Docker Hub, and Microsoft .NET images on `mcr.microsoft.com`: no rule is applied. - Environment variables, mounts, published ports, and network settings: never read or reported. - Whether a container is running: stopped containers are reported the same way. ## Report shape The JSON report is `offing.inventory.v1`, validated by [`schemas/inventory.v1.schema.json`](../schemas/inventory.v1.schema.json). `results` holds one entry per item and check; `unrecognized` lists the files no importer recognized. Items are sorted by file and pointer, so the same exports produce the same report. A Package URL with no rule is reported as `no-data`; when a PURL has rules but none covers the version, the item is `not-affected`. Neither status is a statement that the version is safe. A rule for an announced date that has not happened yet is a `scheduled-end-of-life` finding at low severity, so it does not fail a `high` gate. ## Importers | Importer | Reads | Export command | | --- | --- | --- | | `aws-rds` | AWS RDS instances, Aurora clusters, and ElastiCache cache clusters | `aws rds describe-db-instances`, `aws rds describe-db-clusters`, `aws elasticache describe-cache-clusters` | | `k8s-cluster` | Kubernetes control-plane and kubelet versions, node OS images, and container runtimes | `kubectl version -o json`, `kubectl get nodes -o json` | | `terraform-state` | Terraform state from `terraform show -json` or a raw `terraform.tfstate` v4 file | `terraform show -json` | ## AWS RDS, Aurora, and ElastiCache ### Produce the export Use the AWS CLI with JSON output and pass the file to `offing inventory`: ```bash aws rds describe-db-instances --output json > rds-instances.json aws rds describe-db-clusters --output json > rds-clusters.json aws elasticache describe-cache-clusters --output json > cache-clusters.json offing inventory rds-instances.json rds-clusters.json cache-clusters.json ``` The fixtures under `tests/fixtures/inventory/aws-rds/` are sanitized samples of these three documents: every account id, ARN, endpoint, and subnet id is a placeholder, and only the fields below are kept. They were assembled from the published output shape of `aws-cli` during review rather than captured from a live account. ### Fields read | Document | Array | Engine field | Version field | Resource field | | --- | --- | --- | --- | --- | | `describe-db-instances` | `DBInstances` | `Engine` | `EngineVersion` | `DBInstanceIdentifier` | | `describe-db-clusters` | `DBClusters` | `Engine` | `EngineVersion` | `DBClusterIdentifier` | | `describe-cache-clusters` | `CacheClusters` | `Engine` | `EngineVersion` | `CacheClusterId` | Everything else in the document is ignored: ARNs, endpoints, reader endpoints, hosted zone ids, subnet groups, VPC ids, security groups, tags, and parameter groups. An entry without a string `Engine`, `EngineVersion`, and resource field is skipped, never guessed. ### Engines and PURLs | AWS engine | Provider PURL | Upstream PURL | | --- | --- | --- | | `postgres` | `pkg:generic/aws-rds/postgres` | `pkg:generic/postgresql` | | `mysql` | `pkg:generic/aws-rds/mysql` | `pkg:generic/mysql` | | `aurora-postgresql` | `pkg:generic/aws-aurora/postgresql` | `pkg:generic/postgresql` | | `aurora-mysql`, `aurora` | `pkg:generic/aws-aurora/mysql` | none (see below) | | `redis` | `pkg:generic/aws-elasticache/redis` | `pkg:generic/redis` | Each recognized object produces two items when the version is also an upstream version, so the report shows both the provider's schedule and the upstream engine's schedule. AWS standard support and extended support dates differ from upstream end of life, and the rules are kept separate for that reason. Aurora MySQL is the exception: its `EngineVersion` embeds the Aurora version, as in `8.0.mysql_aurora.3.10.0`, which is not an upstream MySQL release. Only the Aurora item is emitted rather than comparing that string to upstream MySQL rules. Unknown engines are skipped: `mariadb`, `sqlserver-*`, `oracle-*`, `db2-*`, `memcached`, and `valkey` have no reviewed rule in this importer yet, so they produce no item and no claim. ### Rules matched The rules live in [`registry/database.json`](https://github.com/velella-sh/ebb/blob/main/registry/database.json) under the provider PURLs, with the AWS release calendar as their source. "End of standard support" is recorded with the registry's `end-of-support` event; an announced date that is still in the future is recorded as `scheduled-end-of-life` at low severity until it passes. The summary of each rule says whether RDS Extended Support or ElastiCache Extended Support may keep the version running for a fee. | Service | Versions with a rule (as of 2026-10-30) | | --- | --- | | RDS for PostgreSQL | 9.6, 10, 11, 12, 13 (ended); 14 (scheduled 2027-02-28) | | RDS for MySQL | 5.7, 8.0 (ended) | | Aurora PostgreSQL | 11, 12, 13 (ended); 14 (scheduled 2027-02-28) | | Aurora MySQL | 1 (5.6), 2 (5.7) (ended) | | ElastiCache Redis OSS | 4, 5 (ended 2026-01-31); 6 (scheduled 2027-01-31) | A version outside these ranges is not reported as supported. When the provider PURL has rules for other versions, the item is `not-affected`: the check message says that rules exist and none covers this version. When the PURL has no rules at all, it is `no-data`. Neither status is a statement that the version is safe. This includes RDS for PostgreSQL 15 and later, RDS for MySQL 8.4 and later, Aurora PostgreSQL 15 and later, Aurora MySQL 3 and 8.4, and Redis OSS 7.x. Their announced end-of-standard-support dates are more than twelve months past the review date, so no rule is recorded yet under decision [0010](decisions/0010-scheduled-end-of-life.md); a later review adds them when the dates come within the window. ### What is not judged The importer does not judge instance classes, storage, IOPS, parameter groups, patch levels within a supported minor version, certificate authorities, Multi-AZ or read-replica topology, backup retention, encryption, or licensing. It does not judge MariaDB, SQL Server, Oracle, Db2, Memcached, or Valkey. It does not verify that an export is complete or current. It does not read an AWS account, role, or region, and it cannot see an object that the export omits. ## Export files Each section below documents one importer: the exact command that produces the export, the fields Offing reads, the lifecycle rules it matches, and what it does not judge. Fixtures live under `tests/fixtures/inventory/`, with every identifier replaced by a placeholder; the command and CLI version that produced them are recorded in the section. ## AWS Lambda ```bash aws lambda list-functions --output json > lambda.json offing inventory lambda.json ``` `list-functions` paginates. With pagination on, the CLI prints one JSON page after another; `offing` accepts the single page a one-page account prints as well as the array of pages a scripted caller collects. The fixtures under `tests/fixtures/inventory/aws-lambda/` reproduce the documented output of `aws lambda list-functions --output json` from AWS CLI v2, with every identifier replaced by a placeholder; they were not captured against a live account, so no CLI version is recorded. **Fields read** | Field | Use | | --- | --- | | `Functions[].FunctionName` | `resource` and nothing more. | | `Functions[].Runtime` | `version`, and the runtime identifier in `pkg:generic/aws-lambda-runtime/`. | | `Functions[].PackageType` | `Image` means the function runs a container image, which has no managed runtime, so it is skipped. | Nothing else is copied: not `FunctionArn`, `Role`, environment variables, tags, account id, code, or architecture. **Rules matched** `registry/serverless.json`, the `pkg:generic/aws-lambda-runtime/` PURLs. Every runtime the [AWS Lambda runtimes page](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html) gives a deprecation date for has a rule: a runtime the page lists under Deprecated runtimes is `end-of-life` at `high`, and a runtime the page lists as supported with a projected deprecation date is `scheduled-end-of-life` at `low` with that date as its deadline. The same rules back the Serverless detector, so one lifecycle fact reaches a declaration and a live function alike. **Not judged** - Container-image functions (`PackageType: "Image"`): their runtime lives in the image, not in the function's configuration. Rebuild the image from a newer base image instead. - Runtimes AWS has not dated — today `nodejs26.x` and `python3.15`, which the page lists as public preview and not scheduled — are reported as `no-data`, which is not a statement that they are safe. - Function versions, aliases, `$LATEST`, code, layers, architecture, and memory: none of them changes the runtime lifecycle answer. - Functions that a different export would show: this importer reads only the file you pass, and only the functions in it. ## AWS EKS clusters and node groups ```bash aws eks describe-cluster --name prod-payments > cluster.json aws eks describe-nodegroup --cluster-name prod-payments --nodegroup-name workers > nodegroup.json offing inventory cluster.json nodegroup.json ``` Each command prints one document: `describe-cluster` prints `{"cluster": …}` and `describe-nodegroup` prints `{"nodegroup": …}`. A script that collects several outputs into one JSON array is read as well, so `jq -s '.[] | {cluster: .}'` over a list of cluster exports works; an array is read only when every element is one of the two document shapes. **Fields read** | Export | Fields | PURL | Version | | --- | --- | --- | --- | | `aws eks describe-cluster` | `cluster.name`, `cluster.version` | `pkg:generic/amazon-eks` | `version` | | `aws eks describe-nodegroup` | `nodegroup.clusterName`, `nodegroup.nodegroupName`, `nodegroup.version` | `pkg:generic/amazon-eks` | `version` | | `aws eks describe-nodegroup` | `nodegroup.amiType` | `pkg:generic/amzn` | the Amazon Linux release the AMI type is built on | `resource` is the cluster name for a cluster and `/` for a node group; a node group whose export carries no `clusterName` is named by its own name alone. A pointer marks the exact field the version came from — `/cluster/version`, `/nodegroup/amiType` — with `/` in front for an array, so `/1/nodegroup/version` is the `version` of the second entry. A version is read only in the shape EKS itself writes: `1.31` or `1.31.4`. A `v` prefix or an `-eks-…` build suffix is the kubelet string a node reports, not the cluster's release line, so such a value is skipped rather than guessed; run `kubectl get nodes -o json` and the Kubernetes cluster importer for those. `aws eks list-clusters` and `aws eks list-nodegroups` print names only, and `aws eks describe-cluster-versions` prints the versions EKS offers rather than the one deployed: none of them is read. **Amazon Linux AMI types** `amiType` names the Amazon EKS optimized AMI the nodes run, and each type is built on a known Amazon Linux release: | `amiType` | Item | | --- | --- | | `AL2_x86_64`, `AL2_x86_64_GPU`, `AL2_ARM_64` | `pkg:generic/amzn` at `2` | | `AL2023_x86_64_STANDARD`, `AL2023_ARM_64_STANDARD`, `AL2023_x86_64_NEURON`, `AL2023_x86_64_NVIDIA`, `AL2023_ARM_64_NVIDIA` | `pkg:generic/amzn` at `2023` | The item is judged by the same host-OS rules a server's `/etc/os-release` is judged by, so an AL2 node group reports the Amazon Linux 2 end of support and an AL2023 node group reports `not-affected` — which says nothing about the packages inside the AMI. `BOTTLEROCKET_*`, `WINDOWS_*`, and `CUSTOM` produce no Amazon Linux item: no reviewed rule judges them, so the node group's operating system is skipped rather than guessed. `releaseVersion` (`AL2023_x86_64_STANDARD_20260918`) is an AMI build string and `platformVersion` (`eks.3227`) is the EKS platform, neither of which has a lifecycle of its own, so neither is read. **Rules matched** - `registry/kubernetes.json`: the `pkg:generic/amazon-eks` minor-version rules, sourced to the [Amazon EKS version lifecycle page](https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions.html). A version past end of standard support but still inside its 12 months of extended support is `end-of-support` at `medium` with that end date as its deadline; `1.30` and earlier, which the calendar no longer lists, are `end-of-life` at `high`; an announced end of standard support that has not passed yet is `scheduled-end-of-life` at `low` ([decision 0010](decisions/0010-scheduled-end-of-life.md)), which is why `1.34`, `1.35`, and `1.36` carry rules and `1.37` does not yet — its date, 2027-12-01, is more than twelve months out. - `registry/container.json`: the `pkg:generic/amzn` rules for a node group's AMI type. `registry/terraform.json` carries the same EKS facts as its own rules for the Terraform detector, which reads `cluster_version` in `*.tf`; the Terraform state importer reads `registry/kubernetes.json`, so a state file's `aws_eks_cluster` and a live `describe-cluster` export are judged by the same rules. **What is not judged** - Self-managed nodes, Fargate pods, and any node outside a managed node group: `aws eks describe-nodegroup` only describes managed node groups. Use `kubectl get nodes -o json` for what is actually running. - `cluster.addons[]` versions, `accessConfig`, `encryptionConfig`, `outpostConfig`, and upgrade policies. - `endpoint`, `certificateAuthority.data`, `arn`, `roleArn`, `resourcesVpcConfig`, `subnets`, `securityGroupIds`, `remoteAccess.ec2SshKey`, tags, and labels: never read, never emitted. - An EKS release no rule covers yet — today `1.37` — which reports `no-data`. `no-data` is never a statement that the version is supported. **Fixtures** `tests/fixtures/inventory/aws-eks/` holds one sanitized sample per document type, plus `rejects/` for the shapes listed above, with every account id, ARN, endpoint host, certificate, subnet, VPC and security group id, SSH key name, and tag value replaced by a placeholder. `examples/inventory/aws-eks.json` is a runnable sample. The shapes follow the documented output of AWS CLI v2 rather than a capture from a live account, so no CLI version is recorded. ## Google Cloud ```bash gcloud functions list --format=json > functions.json gcloud app versions list --format=json > app-engine-versions.json gcloud container clusters list --format=json > gke-clusters.json gcloud run services list --format=json > cloud-run-services.json offing inventory functions.json app-engine-versions.json gke-clusters.json cloud-run-services.json ``` **Fields read** | Export | Fields | PURL | | --- | --- | --- | | `gcloud functions list --format=json` | `name`, `runtime` (1st gen) or `buildConfig.runtime` (2nd gen) | `pkg:generic/google-cloud-functions-runtime/` | | `gcloud app versions list --format=json` | `name`, `runtime` | `pkg:generic/google-appengine-runtime/` | | `gcloud container clusters list --format=json` | `name`, `currentMasterVersion`, `currentNodeVersion` | `pkg:generic/google-gke` | | `gcloud run services list --format=json` | `metadata.name`, `spec.template.spec.containers[].image` | `pkg:docker/library/` | `name` supplies the resource: the last path segment, never the project id or number. A Cloud Functions or App Engine runtime item reports the runtime as part of PURL, so the pointer marks the exact field the runtime was read from. A GKE cluster produces one item for the control-plane version and, when it differs, one for the node version; `releaseChannel` does not change the verdict, because the rules state both the end of standard support and the end of extended support. Cloud Run has no managed language runtime. The importer reads only container images and only those that name a public Docker Hub base image (`node:20`, `python:3.12-slim`, or `library/node:20`): an image with a registry host or a namespace (`gcr.io/…`, `us-docker.pkg.dev/…`, `bitnami/node`) is skipped, as is an image with no tag, a digest-only reference, or a tag that does not start with a digit. A short image name maps to `pkg:docker/library/` with the tag as the version. **Rules matched** - `registry/serverless.json`: Google Cloud Functions and App Engine runtime rules, sourced to Google's runtime support schedules. - `registry/kubernetes.json`: GKE minor-version rules, sourced to the GKE release schedule. - `registry/container.json`: base-image rules for Cloud Run images. **What is not judged** - Runtime *generations* and stack images (`google-22`, `google-24-full`); only the runtime ID is checked. - Operating-system packages inside a function or container image. - Image digests, private images, and image names that are not public base images. - Anything not written literally in the export: a reference, a variable, or a value the export does not include is not resolved. **Fixtures** `tests/fixtures/inventory/gcp/` holds one sanitized sample per document type, and `examples/inventory/` holds two runnable samples. Each follows the JSON the commands above print, reduced to the fields this importer reads, with every project id, number, service account, URL, IP address, and environment value replaced by a placeholder. No `gcloud` CLI version is recorded because the contributor who added this importer had no Google Cloud project to capture exports from; the shapes follow the documented JSON output of the commands above rather than a recorded response. ## Azure ```bash az webapp list -o json > webapps.json az functionapp list -o json > functionapps.json az aks list -o json > aks-clusters.json offing inventory webapps.json functionapps.json aks-clusters.json ``` **Fields read** | Export | Fields | PURL | | --- | --- | --- | | `az webapp list -o json` | `name`, `kind`, `siteConfig.linuxFxVersion` or `siteConfig.windowsFxVersion` | `pkg:generic/azure-app-service/` | | `az functionapp list -o json` | the same, plus `siteConfig.FUNCTIONS_EXTENSION_VERSION` | `pkg:generic/azure-app-service/` and `pkg:generic/azure-functions` | | `az aks list -o json` | `name`, `kubernetesVersion`, `agentPoolProfiles[].orchestratorVersion` | `pkg:generic/azure-aks` | `name` supplies the resource: the app or cluster name, never the subscription id, the resource group id, a host name, or an ARM `id` path. **Stack references** `linuxFxVersion` and `windowsFxVersion` name the managed stack and its version in one string. The prefix selects the stack and the part after the first `|` is read as the version, stopping at the first `-`: | Reference | Item | | --- | --- | | `NODE\|18-lts` | `pkg:generic/azure-app-service/node` at `18` | | `PYTHON\|3.8` | `pkg:generic/azure-app-service/python` at `3.8` | | `DOTNETCORE\|6.0` | `pkg:generic/azure-app-service/dotnet` at `6.0` | | `DOTNET-ISOLATED\|7.0` | `pkg:generic/azure-app-service/dotnet-isolated` at `7.0` | | `JAVA\|11-java11` | `pkg:generic/azure-app-service/java` at `11` | | `PHP\|8.0` | `pkg:generic/azure-app-service/php` at `8.0` | A reference with a third part (`PYTHON\|3.11\|3.0`) gives the same version. `DOCKER\|` is a custom container with no managed stack, an empty value, a prefix this importer has no rules for, a reference with more than three parts (`JAVA\|8\|TOMCAT\|9.0`, where the app-server release is what a rule would need), and an app with no `siteConfig` are all skipped rather than guessed. `az webapp list` and `az functionapp list` print the `SiteResource`, whose `siteConfig` the App Service API omits from a normal read because it can hold secrets. When the export has no `siteConfig`, read each app's configuration and keep its name and kind: ```bash az webapp list -o tsv --query '[].{group:resourceGroup,name:name,kind:kind}' \ | while read -r group name kind; do az webapp config show -g "$group" -n "$name" -o json \ | jq --arg name "$name" --arg kind "$kind" '{name: $name, kind: $kind, siteConfig: .}' done \ | jq -s '.' > webapps.json offing inventory webapps.json ``` `az aks list` returns the cluster versions directly, so no follow-up call is needed. **Functions runtime** The Functions runtime version is an app setting in Azure, and app settings are never read: they carry connection strings and keys. An item is therefore produced only for an export that writes the value straight into `siteConfig`, never for one that carries it as a `siteConfig.appSettings` entry, and never for a setting named in an app whose `kind` does not include `functionapp`. Both spellings of the value are read: `~4` becomes version `4` and a pinned build such as `2.0.12345` keeps its build. **AKS versions** A cluster produces one item for its control plane (`kubernetesVersion`) and one for each agent pool version that differs from a version already reported for that cluster (`agentPoolProfiles[].orchestratorVersion`), so a pool that matches its control plane is not counted twice. **Rules matched** - `registry/serverless.json`: the `pkg:generic/azure-app-service/` and `pkg:generic/azure-functions` rules, sourced to Azure's [language runtime support policy](https://learn.microsoft.com/en-us/azure/app-service/language-support-policy) and [Functions runtime version page](https://learn.microsoft.com/en-us/azure/azure-functions/functions-versions), each citing the community schedule Azure follows for that runtime. - `registry/kubernetes.json`: the `pkg:generic/azure-aks` minor-version rules, sourced to the [AKS supported Kubernetes versions page](https://learn.microsoft.com/en-us/azure/aks/supported-kubernetes-versions). A version past its 12-month support window is `end-of-support` at `medium`; one past opt-in long-term support is `end-of-life` at `high`; an announced window end that has not passed yet is `scheduled-end-of-life` at `low`. **What is not judged** - `agentPoolProfiles[].osSku` and `windowsVersion`, and any node operating system or package inside a node image: the AKS rules judge the Kubernetes version only. Scan a node root filesystem export for its packages. - `siteConfig.appSettings` and every other app setting, including a `FUNCTIONS_WORKER_RUNTIME` or a `FUNCTIONS_EXTENSION_VERSION` that only lives there. - Host names, IPs, URLs, `serverFarmId`, SKU, location, tags, `resourceGroup`, and ARM `id` paths. - Custom-container apps, and a Java app server (Tomcat, JBoss) named in a stack reference: no rule is applied to it. - A stack version with no rule yet — today for example `NODE|24` or `JAVA|11` — which reports `not-affected`. That is not a statement that the version is supported; it says no Azure rule covers it. **Fixtures** `tests/fixtures/inventory/azure/` holds one sanitized sample per document type, with every subscription id, resource group id, ARM path, host name, IP address, DNS prefix, SSH key, service principal, and app-setting value replaced by a placeholder. `examples/inventory/azure-webapps.json` and `examples/inventory/aks.json` are runnable samples. No Azure CLI version is recorded because the contributor who added this importer had no Azure subscription to capture exports from; the shapes follow the documented JSON output of the commands above rather than a recorded response. ## Terraform state The `terraform-state` importer reads the values Terraform recorded for deployed resources. It accepts two shapes: ```bash # the state JSON `terraform show -json` prints terraform show -json > state.json # or the raw state file itself offing inventory state.json terraform.tfstate ``` The first shape is state JSON: `format_version` plus `values.root_module`. The second is the raw state file: `version: 4` plus `resources`. Plan JSON (`planned_values.root_module`) is not read, because it describes a proposed change rather than what is deployed. The importer does not read `*.tf` source files; the Terraform detector already does that. **Fields read** Every module under `values.root_module`, including `child_modules` at any depth, and every resource instance is walked. In raw state, every entry of `resources` and every instance of `instances` is walked. Data sources and deposed objects are skipped. | Resource type | Attribute | PURL | Version | | --- | --- | --- | --- | | `aws_lambda_function` | `runtime` | `pkg:generic/aws-lambda-runtime/` | the runtime ID | | `aws_db_instance`, `aws_rds_cluster`, `aws_elasticache_cluster`, `aws_elasticache_replication_group` | `engine`, `engine_version` | the AWS engine table below | `engine_version` | | `aws_eks_cluster` | `version` | `pkg:generic/amazon-eks` | `version` | | `google_container_cluster` | `master_version` | `pkg:generic/google-gke` | `master_version` | | `google_cloudfunctions_function` | `runtime` | `pkg:generic/google-cloud-functions-runtime/` | none: the runtime is part of the PURL | | `google_cloudfunctions2_function` | `build_config[0].runtime` | `pkg:generic/google-cloud-functions-runtime/` | none: the runtime is part of the PURL | | `azurerm_kubernetes_cluster` | `kubernetes_version` | `pkg:generic/azure-aks` | `kubernetes_version` | | `azurerm_linux_function_app` | `site_config[0].application_stack` | `pkg:generic/azure-app-service/` | the stack version | Nothing else in the state is copied. ARNs, resource ids, endpoints, provider configurations, outputs, tags, environment variables, and Terraform or provider versions are never read or reported. A value that is missing, empty, not a string, or not a release-shaped value is skipped, never guessed. **Resource and pointer** `resource` is `.`, with `[index]` for `count` and `["key"]` for `for_each`, such as `aws_lambda_function.worker[0]` or `aws_lambda_function.api["green"]`. A nested resource keeps the same form; the module path is in the JSON Pointer, not in `resource`. Every pointer resolves to the exact string the item's version came from: `/values/root_module/resources//values/` for state JSON, or `/resources//instances//attributes/` for raw state, with `child_modules` segments in between. **Engines and PURLs** The engine mapping is the one the AWS RDS importer uses, so a deployed engine and a described engine are checked against the same rules: | AWS engine | Provider PURL | Upstream PURL | | --- | --- | --- | | `postgres` | `pkg:generic/aws-rds/postgres` | `pkg:generic/postgresql` | | `mysql` | `pkg:generic/aws-rds/mysql` | `pkg:generic/mysql` | | `aurora-postgresql` | `pkg:generic/aws-aurora/postgresql` | `pkg:generic/postgresql` | | `aurora-mysql`, `aurora` | `pkg:generic/aws-aurora/mysql` | none | | `redis` | `pkg:generic/aws-elasticache/redis` | `pkg:generic/redis` | Unknown engines, including `mariadb`, `sqlserver-*`, `oracle-*`, `db2-*`, `memcached`, and `valkey`, produce no item and no claim. The Linux Function App stack reads the managed version fields of `application_stack` and maps each one to its own item: | Field | PURL | | --- | --- | | `dotnet_version` | `pkg:generic/azure-app-service/dotnet` | | `dotnet_isolated_version` | `pkg:generic/azure-app-service/dotnet-isolated` | | `java_version` | `pkg:generic/azure-app-service/java` | | `node_version` | `pkg:generic/azure-app-service/node` | | `python_version` | `pkg:generic/azure-app-service/python` | | `powershell_core_version` | `pkg:generic/azure-app-service/powershell` | A leading `~` in a Node.js version is Azure's release-line notation, so `~18` is reported at version `18`. `use_custom_runtime` and a `docker` image mean the runtime is not a managed stack, so they produce no item. **Rules matched** - `registry/database.json`: RDS, Aurora, and ElastiCache engine rules, the same rules the AWS RDS importer uses. - `registry/kubernetes.json`: GKE, AKS, and EKS minor-version rules. - `registry/serverless.json`: AWS Lambda, Google Cloud Functions, and Azure App Service stack and Functions runtime rules. `pkg:generic/amazon-eks` has rules in both `registry/kubernetes.json`, which this importer reads, and `registry/terraform.json`, which the Terraform source detector reads, so a cluster recorded in state and a `cluster_version` written in `*.tf` are judged by the same calendar. An EKS version no `registry/kubernetes.json` rule covers reports `no-data`. `no-data` is never a statement that the version is safe. **What is not judged** - Terraform plan JSON, `*.tf` source, module outputs, and provider configurations: this importer reads state values only. - Deposed instances: only current instances are reported. - Resource types and attributes beyond the table above, including EKS node groups, RDS proxy endpoints, and functions deployed outside the listed resource types. - Whether the state is current, whether it matches the configuration, or whether a remote backend is reachable. Offing reads only the file you pass and never runs Terraform. **Fixtures** `tests/fixtures/inventory/terraform-state/` holds one sample of each format, including nested modules and `count` and `for_each` instances, with every account id, ARN, subscription id, project number, hostname, and environment value replaced by a placeholder. `examples/inventory/terraform-state.json` is a runnable sample in the `terraform show -json` shape. They were assembled from the documented state shape of Terraform 1.9 rather than captured from a live state; no Terraform CLI version is recorded. --- Source: https://velella.sh/offing/playbooks/agent/ # Playbook: agent-driven migrations A start-to-finish guide for letting a coding agent (Claude Code, Codex, Cursor, Copilot, Gemini CLI, Kilo Code) migrate one end-of-life or deprecated dependency, runtime, or GitHub Action, while Offing supplies the facts and the proof. The split of responsibility is the point. Offing decides **what** is affected and **whether it is fixed**. The agent decides **how** to change the code. A human decides **whether to merge**. ## Before you start - Install the guardrail so the agent checks before it adds anything: `npx skills add velella-sh/offing`, or connect the local MCP server ([MCP.md](../MCP.md)). Either path needs no account or API key. - Work on a branch with a clean tree. One finding per branch keeps the diff reviewable and the verification unambiguous. - Run the project's own tests once before changing anything. A migration cannot be verified against a suite that was already failing. ## The loop Every migration is the same four steps. The agent should run them in order and stop if one fails. ### 1. Discover ```bash npx @velella/offing scan . --json ``` Pick **one** finding and copy its exact `id`. Do not let the agent batch several findings into one change. For a large result, `--format compact` fits an agent's context window, and `scan --write-status` writes `OFFING_STATUS.md` so an agent that cannot call tools can still read the repository's lifecycle state. ### 2. Explain ```bash npx @velella/offing explain '' . --json ``` Read the rule, the exact location, and the authoritative source. If the agent cannot point to the source, it should not proceed: a lifecycle claim without a source is not a reason to change code. ### 3. Migrate ```bash npx @velella/offing context '' . --json ``` The migration packet ([MIGRATION_PACKETS.md](../MIGRATION_PACKETS.md)) lists the declaration, every usage, behavioral constraints, ordered steps, and suggested verification commands. The agent follows the steps and touches only the affected files the packet names. Offing never edits files and never runs the suggested commands; the agent runs them under the host's normal permission prompts, after reading them. ### 4. Verify ```bash npx @velella/offing scan . --json ``` The migration is done only when **both** hold: the project's tests pass, and a fresh scan no longer reports that rule at that location. Through MCP, `verify_migration` does the second check in the session that saw the finding. An unknown or missing finding ID after a rescan is not proof of success; the agent should compare rule and path, not just the ID. ## Worked examples on real repositories These come from the pinned public corpus in `benchmarks/public-repositories/`, so each location is one Offing reports at that commit. **A Python floor that reached end of life: `psf/requests`.** At commit `611c616`, line 17 of `pyproject.toml` reads `requires-python = ">=3.10"`, a release line that ended on 2026-10-01 (rule `python.runtime.eol-3.10-and-older`). The packet's scope is the declaration plus anything that gates on the old floor (CI matrix entries, classifiers, `python_requires` checks in tests). Raising the floor changes what the package supports for its users, so this is a decision to take to a maintainer; the agent should present the change, not make it silently. **A deprecated Action in many workflows: `Akenon-Studio/.github`.** At commit `4ec1ebe`, `.github/workflows/security-scan.yml` line 127 uses an `actions/setup-node` release on Node 20, and two workflows use `actions/create-github-app-token@v2` below the supported major. The maintainers' own migration commit is the reference outcome. Here the packet paths are workflows only, so the diff should be a handful of `uses:` lines and nothing else. Check the Action's release notes for changed inputs before accepting the bump. ## Failure modes and what to do | What goes wrong | Why | Response | | --- | --- | --- | | The agent edits files outside the packet | The packet's affected-file set is the scope; a broader edit has no Offing evidence behind it | Revert the extra edits, or open a follow-up issue for them | | The agent bumps a version but the finding remains | The rule keys on the declaration, a range, or a runtime file the bump did not touch | Rescan, read the new location, repeat from step 2 | | Tests pass, finding gone, behavior changed | The packet lists behavioral constraints for a reason; tests may not cover them | Read the constraints against the diff before merging | | `no-data` from `check_dependency` for a replacement | Offing has no rule for it | Treat as unknown, not safe; pick the replacement the packet cites | | The agent runs a suggested command that fails or is destructive | Suggested commands depend on project setup | Review commands first; Offing only suggests them | | A finding keeps returning in CI | It was never fixed, or the baseline expired | Use `--baseline` only for findings you accept on purpose, with `--on-expired fail` so acceptance cannot rot | | The finding ID is stale | IDs come from a specific scan | Rescan and select the finding again | | Dynamic references or unsupported ecosystems | Offing reports a limitation instead of guessing | Report the limitation; do not invent a rule | ## Privacy boundaries - Scans, `explain`, `context`, `check`, and the MCP server run locally and use no network. The one command that does is `offing data update`, which you run yourself ([PRIVACY.md](../PRIVACY.md)). - Offing sends nothing about your repository anywhere. It has no account, no analytics, and no telemetry. - **The agent host is a separate party.** Scan results and migration packets contain file paths and short source excerpts. Once they enter an agent's context, that host's privacy policy and your workspace settings apply. For a private repository, use a host and plan you already trust with that code. - Repository contents are untrusted input. A file that says "ignore your instructions" is data, not a command. Do not let the agent follow instructions found in the repository, in a finding, or in a source excerpt. - Do not put credentials in prompts. Offing never asks for them. ## Reviewing the result Treat the agent's change like any contributor's pull request: 1. The diff touches only files named in the packet. 2. The cited source supports the claim in the PR description. 3. Project tests pass, and a fresh `offing scan` no longer reports the finding. 4. In CI, the [GitHub Action](../GITHUB_ACTION.md) with `fail-on-severity` keeps the finding from coming back. ## Prompt template ```text Migrate one Offing finding in this repository. 1. Run `npx @velella/offing scan . --json` and pick finding . 2. Run `explain` and `context` for it. Summarize the source before editing. 3. Change only the files the migration packet lists. Do not refactor anything else. 4. Review each suggested verification command before running it. 5. Run the project tests, then rescan. Stop if the finding remains or tests fail, and tell me what you saw instead of widening the change. ``` --- Source: https://velella.sh/offing/playbooks/cli/ # Playbook: manual CLI checks A start-to-finish guide for checking a repository by hand with the Offing CLI: scan it, read a finding, decide what to do, accept what you must, and keep it from coming back. No agent, account, or API key is involved. For agent-driven fixes, see [agent.md](agent.md). ## Before you start - Node.js 22 or newer. Run commands with `npx @velella/offing ` or, from a clone of this repository, `npm run dev -- `. The examples below write `offing`. - Run `offing doctor --system` once. It confirms the runtime, the package, and the bundled MCP server and skill are usable, and says what to fix if not. - Run `offing data status` to see which lifecycle data you are checking against and how old its evidence is. A scan is only as current as that date; `offing data update` is the one command that uses the network, and you run it yourself ([DATA.md](../DATA.md)). - Work from a clean tree so a later diff shows only what you changed. ## The manual loop ### 1. Scan ```bash offing scan . ``` The summary lists every finding with its rule, location, severity, and replacement. Useful variants: | Need | Command | | --- | --- | | One line per finding, for a terminal or a commit hook | `offing scan . --format compact` | | Machine-readable output | `offing scan . --json` | | Browse and jump to the line in `$EDITOR` | `offing scan . --interactive` (plain text when not a terminal) | | Several projects at once, with combined counts | `offing scan ./api ./web ./infra` | | A shareable report | `offing scan . --format html` or `--format sarif` | A finding ID has the form `::`, for example `nodejs.runtime.eol-18:package.json:1`. Copy it exactly; IDs belong to the scan that produced them. ### 2. Check a single package You do not need to scan to ask about one thing. Use a Package URL: ```bash offing check pkg:npm/request@2.88.2 offing check pkg:github/actions/setup-node@v3 ``` `check` exits `1` when a rule at or above `--fail-on` (default `high`) applies and `0` otherwise. Three results look alike at a glance and mean different things: | Status | Meaning | What to do | | --- | --- | --- | | `affected` | A reviewed rule applies, with a source | Read it; plan a replacement | | `version-dependent` | The package has rules for some versions only | Re-run with a version | | `no-data` | Offing has no rule for it | Treat as unknown, **not** safe | ### 3. Explain ```bash offing explain 'nodejs.runtime.eol-18:package.json:1' . ``` Read the rule, the exact location, and the authoritative source link. Open the source yourself. If the finding looks wrong (the line is not what the rule describes, or the source does not support the claim), stop and run `offing report-issue '' . --output issue.md` to produce a ready-to-file report instead of working around it. ### 4. Get the migration context ```bash offing context 'nodejs.runtime.eol-18:package.json:1' . ``` The packet lists the declaration, every usage, behavioral constraints, ordered steps, and suggested verification commands ([MIGRATION_PACKETS.md](../MIGRATION_PACKETS.md)). Offing never edits files and never runs those commands; you read each one and run it yourself. ### 5. Fix, then re-scan Make the change, run the project's own tests, then: ```bash offing scan . ``` You are done when the tests pass **and** that rule no longer appears at that location. A changed finding ID is not proof of a fix: line numbers move, so compare the rule and path. ## Accepting a finding on purpose Some findings cannot be fixed today (a customer pins you to an old runtime, a vendor has not shipped the replacement). Record that decision instead of ignoring the output: ```bash offing baseline . --output offing-baseline.json offing scan . --baseline offing-baseline.json --on-expired fail ``` Findings in the baseline are suppressed and counted (`Baseline: 4 findings suppressed`). Suppressions expire; `--baseline-warn-days ` warns before they do, and `--on-expired fail` makes the scan exit `1` once one has, so acceptance cannot quietly become permanent. Commit the baseline file so the decision is reviewable. Do not baseline a finding you have not read. ## Scanning as of a date ```bash offing scan . --as-of 2027-07-01 ``` `--as-of ` answers "will this repository be clean on 1 July?". Every announced end of life (a `scheduled-end-of-life` rule) whose deadline is on or before that day is reported as ended, at end-of-life severity, so `--fail-on high` fails on what will break by then. Finding IDs do not change, so a baseline still matches. `--today` is an alias, and with `--baseline` the same day also decides which suppressions have expired. The result carries `asOf`. The scan itself still reads no clock and no network. `--min-support-days ` warns before a line expires instead of after: a finding whose deadline falls within the next `n` days (from today, or from `--as-of`) is raised to `medium`, so the default `--fail-on high` gate stays green while the warning shows up. It is off unless given, and the same window is the `min-support-days` Action input. This is not a forecast: `--as-of` projects the findings you have onto one day, while `offing forecast` lists what becomes end of life in the next 6 and 12 months. ## Forecasting what breaks next ```bash offing forecast . # the next 12 months offing forecast . --months 6 --json offing forecast . --as-of 2027-01-01 # count from another day ``` `forecast` lists the dated end-of-life events in the window, grouped by month, and shows where your repository is affected. Text output shows only events that touch a finding; `--json` (`ebb.forecast.v1`) lists every dated event, with an empty `findings` list for those that do not affect you. Events without a date are never listed or estimated, and the same date always gives the same output. `--months` takes 1 to 36 and defaults to 12. The scan reads no network. ## Gating a push or CI ```bash offing scan . --fail-on high # exit 1 when a finding is high or critical offing init --hooks --dry-run # preview a pre-push hook for this repository ``` ```bash offing init --personal --dry-run # preview a global hook for every repository ``` Without `--fail-on`, `scan` always exits `0`. Use `--dry-run` first to see exactly what `init` would write. Hooks never block a push when Offing is missing or a scan errors for another reason ([HOOKS.md](../HOOKS.md)). For CI, use the [GitHub Action](../GITHUB_ACTION.md). For a repository you want to watch over time: `offing watch add ~/code/my-app`, then `offing watch --weekly` prints only what changed since the last run. ## Worked examples on real repositories These come from the pinned public corpus in `benchmarks/public-repositories/`, so each location is one Offing reports at that commit. **A Node.js range that includes ended releases: `expressjs/express`.** At commit `a419aa7`, `package.json` line 83 declares an `engines.node` range that also allows Node.js 18, 19, and 20, and `.github/workflows/ci.yml` line 49 tests a matrix starting at 18. A scan reports one finding per ended release line at the same declaration (`nodejs.runtime.eol-18`, `-19`, `-20`). Read them as one decision, not three: raising the floor changes which Node.js versions the package supports for its users, so `explain` first, then take it to a maintainer rather than editing the range on your own. **A package that is only affected sometimes.** `offing check pkg:npm/` without a version can answer `version-dependent`. That is the CLI telling you the answer depends on the version in your lockfile. Read the version from the lockfile, not from `package.json`, and check again with it. ## Failure modes and what to do | What goes wrong | Why | Response | | --- | --- | --- | | `Findings: 0` on a path that does not exist | `scan` accepts a path with nothing in it and reports zero findings | Check the `Repository:` line in the output names the directory you meant | | `No lifecycle risks found` on a project you expected to hit | The ecosystem or file is not covered, or the reference is dynamic | Run `offing capabilities`; Offing reports a limitation rather than guessing, so do not read silence as proof | | `no-data` for a package | No reviewed rule exists | Unknown, not safe; look at the package's own release notes | | A finding disappears after a data update | The rule changed or its evidence was revised | Run `offing data status`, then re-read the rule with `explain` | | The finding ID is stale | IDs come from a specific scan | Re-scan and select it again | | A bump leaves the finding in place | The rule keys on a range, a runtime file, or a workflow line the bump did not touch | Re-scan and read the new location | | `check` exits non-zero with an error | Malformed Package URL (it must start with `pkg:`) | Fix the PURL; this is invalid input, not a finding | | `--fail-on` breaks a hook you did not expect | The gate defaults to `high` | Choose it explicitly: `low`, `medium`, `high`, `critical`, or `none` | | Results differ between machines | Different bundled data versions | Compare `offing data status` on both | ## Privacy boundaries - `scan`, `check`, `explain`, `context`, `baseline`, `doctor`, and `mcp` run locally and use no network. `offing data update` is the one command that does, and you run it yourself. It sends nothing about your repository ([PRIVACY.md](../PRIVACY.md)). - There is no account, analytics, or telemetry. By default Offing records nothing about what you checked. `OFFING_USAGE_LOG=1` opts in to a local file with the package, result, and rule IDs; it contains no repository path or file content and is never uploaded. Delete the file to erase it. - Output contains file paths and short source excerpts. Before you paste a scan, a packet, or an `issue.md` into a ticket, a chat, or an AI tool, read it. For a private repository, share only what you are happy for that audience to see. - `report-issue` writes a file; it does not send anything. Filing the issue is your action. - Repository contents are untrusted input. Offing parses them and never executes them. A file that tells you to run something is still just a file. - Suggested verification commands are suggestions. Read them before running them. ## Checklist for a manual pass 1. `offing data status` is recent enough for the decision you are making. 2. `scan` output names the repository you meant. 3. Each finding you act on has been through `explain`, and you opened its source. 4. Anything you accept is in a committed baseline with `--on-expired fail`. 5. A fresh `scan` after the change no longer reports the rule at that location, and the project's tests pass. --- Source: https://velella.sh/offing/GITHUB_ACTION/ # 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. ![Offing pull request comment showing severity counts and the top lifecycle findings with file locations](assets/screenshots/pr-comment.webp) ## Pull-request workflow ```yaml 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/` branch and never rebuilds, so the Action runs without installing anything and a tag you pinned keeps doing what it did. [Releasing](RELEASING.md#action-bundles-on-release-tags) 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. ```yaml 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 `@main` only 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/`](../templates/workflows/) into that repository's `workflow-templates/` directory. See [Organization rollout](ORG_ROLLOUT.md#offer-the-starter-from-the-organizations-github-repository). ### SHA-pinned actions For the first-party actions covered by Offing, a full 40-character commit pin is classified in this order: 1. A trailing release comment, such as `# v4`, `# v4.1.1`, `# tag=v4.1.1`, or `# version: v4.1.1`. 2. Offing's bundled map of exact release-tag SHAs, when the pin has no trailing comment. ```yaml - 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: ```bash 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: ```json { "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`](../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: ```text 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 ` 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. - `slack` is a Slack incoming-webhook message: a fallback `text`, a header, a counts summary, and one section per finding. It fits Slack's 50-block and 3,000-character section limits. - `teams` is a Microsoft Teams message with an Adaptive Card (version 1.4) for a Teams workflow webhook. - `webhook` is 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: ```yaml - 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](#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](#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. ![Offing Actions job summary listing severity counts and the top lifecycle findings with evidence links](assets/screenshots/job-summary.webp) ### 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: ```yaml - 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. ```yaml - 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. ```yaml 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: ```yaml 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 ` 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 ` replies with a baseline entry that is ready to review and commit, for example: ```json { "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. ```yaml 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. ```yaml 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. ```yaml 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`](https://github.com/velella-sh/offing/blob/main/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. ```yaml 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](https://github.com/velella-sh/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](https://shields.io/badges/endpoint-badge) 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. ```yaml 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`: ```markdown [![Offing lifecycle findings](https://raw.githubusercontent.com/OWNER/REPO/offing-badge/offing-badge.svg)](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: ![GitHub code scanning alert opened from Offing SARIF for a deprecated package, with replacement and verification steps](assets/screenshots/sarif-alert.webp) ```yaml 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: ```bash 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. --- Source: https://velella.sh/offing/playbooks/github/ # Playbook: GitHub Actions A start-to-finish guide for running Offing in GitHub Actions: add the Action to a pull request, read what it reports, accept what you must on purpose, and keep the gate honest over time. For a repository you check by hand, see [cli.md](cli.md). For agent-driven fixes, see [agent.md](agent.md). ## Before you start - Confirm your workflow runs on a runner that still has the Node.js and action runtimes you use. Offing flags those too, so a workflow that installs an end-of-life runtime shows up in its own scan. - Pick a version tag, such as `velella-sh/offing@v0.1.1`, and read the inputs table in [GITHUB_ACTION.md](../GITHUB_ACTION.md#inputs-and-outputs). Every input is optional; the defaults gate on `high` and upload the JSON report. - Decide what the team accepts before the first run. Existing findings should go into a committed baseline, not into a gate that is switched off. - Work on a branch. Run the Action on a pull request first, read the annotations, then make it required. ## The pull-request loop ### 1. Add the workflow ```yaml 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 ``` `contents: read` is the only permission the scan needs. The scan reads the checked-out files and bundled data; it makes no network requests and sends no repository contents to an Offing service. Pin a full commit SHA instead of a tag only when your threat model requires immutable references. A SHA pin with a trailing `# vX.Y.Z` comment is classified from that comment; a SHA without one is looked up in the bundled map, and an unknown one is reported as `unknown-sha` rather than guessed ([GITHUB_ACTION.md](../GITHUB_ACTION.md#sha-pinned-actions)). ### 2. Read the annotations High and critical findings become error annotations on the file and line; medium findings become warnings; low and informational findings become notices. The job summary lists the counts per severity, the ten most severe findings, and the age of the lifecycle data. A real finding from a public repository, at a pinned commit of `axios/axios`: ```text nodejs.runtime.legacy:.github/workflows/release-branch.yml:72 critical Node.js 22 LTS or later ``` The matrix on that line installs Node.js 12 with `actions/setup-node`. Run the same explanation locally before you change anything: ```bash offing explain 'nodejs.runtime.legacy:.github/workflows/release-branch.yml:72' . ``` The output names the rule, the exact location, and the authoritative source. Open the source yourself. Then read the migration packet with `offing context` and make the change in the workflow, not in the scan. Actions themselves are judged too. `offing check pkg:github/actions/setup-node@v3` reports that release as high, because it predates the action's Node.js 24 runtime, and names `actions/setup-node@v5` or later as the replacement. Bump the action, re-run the workflow, and confirm the annotation is gone. ### 3. Fix, then re-run Change the workflow, let the project's own CI run, then confirm the rule no longer appears at that location on the pull request. Compare the rule and the path, not only the finding ID: line numbers move when a workflow is edited. ## Accepting a finding on purpose Some findings cannot be fixed in this pull request: a matrix that a downstream consumer still tests, or a vendor action that has not shipped its replacement. Record the decision in the repository: ```bash offing baseline . --output .offing-baseline.json ``` Commit the file so the acceptance is reviewable. Each entry can carry an `owner`, a `reason`, and an `expires` date. Add them for every accepted finding; a suppression without an owner is one nobody will revisit. ```bash offing scan . --baseline .offing-baseline.json --on-expired fail ``` With `on-expired: fail`, the job fails once a suppression's `expires` day has passed, so acceptance cannot quietly become permanent. `baseline-warn-days` (default 14) puts the upcoming expiries in the job's diagnostics before that happens. Do not add a finding to the baseline that you have not read. ## Scheduled reminders A pull-request check only reports what a change touches. A runtime that ends next quarter will not fail anyone's pull request until it is too late. The repository's [lifecycle-watch example](../../examples/lifecycle-watch.yml) runs weekly, scans with `min-support-days`, and keeps one issue up to date with every line whose support ends within the window. It needs `contents: read` and `issues: write`, and it sets `fail-on-severity: none` so the schedule itself never fails. ## Failure modes and what to do | What goes wrong | Why | Response | | --- | --- | --- | | The job fails with no finding in the summary | `baseline` is set to a path that does not exist, which fails the Action on purpose | Commit the baseline file, or remove the input | | `pr-comment` does nothing | The comment needs `pull-requests: write` and the token to post it | Add the permission, or leave `pr-comment` off and read the annotations | | `upload-sarif` fails | Code scanning needs `security-events: write` | Add the permission, or keep `upload-sarif` at `false` | | Results differ between a runner and a laptop | Different bundled data versions | Compare the data date in the job summary with `offing data status` locally | | `unknown-sha` on an action pin | The SHA is not in the bundled release-tag map | Pin with a trailing `# vX.Y.Z` comment, or check the action by hand; it is not a lifecycle finding and does not gate the job | | A new scan fails a workflow nobody touched | A runtime or action reached its end-of-life deadline | That is the point of the gate. Fix it in a pull request, or accept it in the baseline with an owner and a date | ## Privacy and permissions - Only `contents: read` is required. Add `pull-requests: write` only for `pr-comment: true`, and `security-events: write` only for `upload-sarif: true`. - The JSON report is uploaded as an Actions artifact for 14 days when `upload-report` is `true`. It can contain repository paths, matched source excerpts, dependency versions, and lifecycle evidence. Anyone with access to the repository's Actions artifacts can read it, so turn off `upload-report` for sensitive repositories, or restrict who can read artifacts. - Scan output also appears in job summaries and PR comments. Read it before enabling `pr-comment` on a public repository. - The scan runs on the code in the pull request. Offing parses repository files and never executes them, but the workflow itself runs whatever the pull request changes, so keep `pull_request_target` out of workflows that check out untrusted code. ## Checklist for a new repository 1. The workflow sets `permissions:` explicitly, with `contents: read` as the base. 2. The first run is on a pull request, and every annotation has been opened or explained. 3. Existing findings are in a committed baseline, each with an `owner`, a `reason`, and an `expires` date. 4. `on-expired: fail` is set, so an expired acceptance fails the job. 5. The scheduled reminder workflow is running, if the repository ships on a long cycle. 6. A pull request that fixes a finding shows the annotation gone, and the project's own tests pass. --- Source: https://velella.sh/offing/ORG_ROLLOUT/ # Organization rollout This guide takes one person from an unconfigured organization to a weekly lifecycle report for every repository in it. It is written to be followed in order: 1. Create a read-only token. 2. Add the organization-scan workflow. 3. Accept existing findings with a baseline. 4. Read the reports the workflow leaves behind. 5. Send the recurring digest to Slack or Teams. 6. Offer the per-repository starter from the organization's `.github` repository. 7. Open adoption pull requests at scale (optional). The scan itself stays local. The organization-scan workflow lists repositories and downloads artifacts through the GitHub API; each scan checks out one repository and reads its files and Offing's bundled data, with no network access. Per-repository adoption uses one `uses:` line that calls the reusable scan workflow. > **Version.** The reusable workflows and the `org:rollout` tool first ship in `v0.8.0`. The examples below call `@v0.8.0`. Until that tag exists, call `@main` only to try a workflow, then replace it with the release tag before relying on it. ## Before you start You need: - An organization owner to approve a fine-grained token, or a repository admin for the caller repository to add its secrets. The same person often fills both roles. - One repository in the organization to host the caller workflow. A small operations or `.github` repository is a good home. - GitHub Actions enabled in that repository. - Optional: admin access to the Slack workspace or Teams channel that should receive the digest. - Optional, for the local baseline and rollup commands: Node.js 22 or newer. The caller repository is scanned like every other repository in the organization. It is not special. ## 1. Create a read-only organization token The workflow needs a token that can list the organization's repositories and read each repository's contents. It does not need write access to anything, and no scan ever receives the token. ### Fine-grained token (recommended) Create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new): 1. Set **Resource owner** to the organization. 2. Set **Repository access** to **All repositories**. A token scoped to selected repositories cannot list the whole organization. 3. Under **Repository permissions**, set **Contents** to **Read-only** and leave **Metadata** at its mandatory **Read-only** value. 4. Set an expiration you are willing to rotate. The workflow fails when the token expires, so put the rotation date on a calendar. 5. Create the token, approve it if the organization requires approval, and copy the value once. ### Classic token If your organization still uses classic tokens, create one with: | Repository visibility | Scopes | | --- | --- | | Private repositories included | `repo`, `read:org` | | Public repositories only | `public_repo`, `read:org` | `read:org` lets the token list the organization. `repo` (or `public_repo`) lets it read repository contents for the checkout. ### Store and verify it Store the token as an Actions secret in the caller repository. `gh secret set` prompts for the value, so the token does not land in shell history: ```bash gh secret set OFFING_ORG_TOKEN --repo your-org/your-ops-repo ``` Before relying on it, check that the token can see the repositories you expect. This prints the number of active, non-archived repositories: ```bash GH_TOKEN=github_pat_your_token \ gh api --paginate "/orgs/your-org/repos?per_page=100" \ --jq '[.[] | select(.archived == false and .disabled == false)] | length' ``` A `404` usually means the token cannot see the organization; a `403` usually means the organization has not approved the token or it lacks a required scope. If you omit the `github-token` secret, the workflow falls back to the caller's `GITHUB_TOKEN`. That works only when the caller's token can already read every repository, which is uncommon for an organization-wide list. Prefer the dedicated token. ## 2. Add the caller workflow Add one workflow to the caller repository. It calls [`.github/workflows/org-scan.yml`](../.github/workflows/org-scan.yml), which lists the organization's repositories and scans each one in a bounded matrix through the first-party Offing Action. The Action version follows the workflow tag you call, so pinning the workflow pins the scanner. Create `.github/workflows/offing-org-scan.yml`: ```yaml name: Offing organization scan on: schedule: - cron: "0 4 * * 1" workflow_dispatch: permissions: contents: read # Only needed for the Slack/Teams digest. Remove it if you do not post one. actions: read jobs: org-scan: uses: velella-sh/offing/.github/workflows/org-scan.yml@v0.8.0 with: organization: your-org fail-on-severity: high max-jobs: 10 secrets: github-token: ${{ secrets.OFFING_ORG_TOKEN }} slack-webhook-url: ${{ secrets.OFFING_SLACK_WEBHOOK }} # optional teams-webhook-url: ${{ secrets.OFFING_TEAMS_WEBHOOK }} # optional ``` Commit it, then run it once by hand: ```bash gh workflow run offing-org-scan.yml --repo your-org/your-ops-repo gh run list --repo your-org/your-ops-repo --workflow offing-org-scan.yml --limit 1 gh run watch --repo your-org/your-ops-repo ``` The first run has three visible parts: - **List repositories.** One job calls `GET /orgs//repos`, skips archived and disabled repositories, and prints how many it found. - **Scan .** One matrix job per repository checks out its default branch and runs the Offing Action. Progress and annotations are per repository, so a failure names the repository. - **Send digest.** After the matrix finishes, one job builds a rollup and posts it to the configured webhooks. It is skipped when neither webhook secret is set. ### Inputs | Input | Default | Meaning | | --- | --- | --- | | `organization` | caller repository owner | The GitHub organization to scan. It must be an organization; user accounts are not supported. | | `fail-on-severity` | `high` | Lowest severity that fails one repository's scan job: `none`, `info`, `low`, `medium`, `high`, or `critical`. | | `max-jobs` | `10` | Maximum number of repositories scanned in parallel. Concurrency stays bounded while the matrix scales with the repository count. | For the first inventory run, set `fail-on-severity: none`. The workflow then finishes successfully even when repositories have findings, which makes the artifacts and digest easy to inspect. Tighten it to `high` once the backlog is triaged, or move the gate to per-repository pull-request workflows with baselines (see [step 3](#3-accept-existing-findings-with-a-baseline)). A scan job fails when a repository has findings at or above `fail-on-severity`, so the calling workflow fails if any repository crossed the gate. Matrix jobs do not cancel each other (`fail-fast: false`), so every repository is still scanned and every report is still uploaded. ### If you only want an inventory Set `fail-on-severity: none` and leave the schedule as it is. The run is a weekly inventory: reports and the digest are produced, but no repository fails the workflow. Add per-repository pull-request workflows (see [step 3](#3-accept-existing-findings-with-a-baseline)) when you want a gate that blocks new findings. ## 3. Accept existing findings with a baseline A baseline records findings you have reviewed and accepted. It is usually a file committed in each repository. Findings in the baseline are suppressed; findings that are not in it are reported and gated normally. Run this in the repository whose findings you are accepting: ```bash npx @velella/offing scan . --fail-on none npx @velella/offing baseline . --output .offing-baseline.json ``` The second command writes one entry per current finding and keeps the file's history when it already exists, so running it again updates the file instead of starting over: ```text Wrote 5 findings to /path/to/repository/.offing-baseline.json No earlier baseline to compare with. ``` Review the file, then commit it: ```bash git add .offing-baseline.json git commit -m "chore: accept current Offing findings" ``` A scan that uses the baseline now hides exactly those findings: ```bash npx @velella/offing scan . --baseline .offing-baseline.json --fail-on high ``` ```text Findings: 0 ... Baseline: 5 findings suppressed, 0 expiring within 14 days, 0 expired. ``` ### Add an owner, a reason, and an expiry Every entry can carry optional context. These fields never change what is suppressed, but they make the acceptance reviewable and let it expire: ```json { "fingerprint": "6ca0234cda96c4bf931e7686f5a74bff85d0063cb143d25ec64136c9102343d3", "ruleId": "nodejs.runtime.eol-18", "path": "package.json", "owner": "platform-team", "reason": "Upgrade tracked in PLAT-12", "created": "2026-10-29", "expires": "2027-01-31" } ``` `owner` and `reason` are non-empty text up to 500 characters. `created` and `expires` are `YYYY-MM-DD` calendar dates, and `expires` cannot be before `created`. Expired suppressions stop hiding their finding; a scan also warns 14 days before an expiry unless you change the window: ```bash npx @velella/offing scan . --baseline .offing-baseline.json --baseline-warn-days 30 ``` `--on-expired resurface` (the default) reports the finding again after its expiry day. `--on-expired fail` reports it and also fails the scan. See the [baseline format](GITHUB_ACTION.md#pull-request-workflow) for the full field reference. ### Wire the baseline into the per-repository workflow The baseline only takes effect where a workflow passes it to the Action or the reusable workflow. Prefer one `uses:` line that calls the reusable workflow: ```yaml jobs: lifecycle: uses: velella-sh/offing/.github/workflows/offing-reusable.yml@v0.8.0 with: baseline: .offing-baseline.json fail-on-severity: high ``` Or pass the same inputs on the Action step directly: ```yaml - uses: velella-sh/offing@v0.8.0 with: baseline: .offing-baseline.json fail-on-severity: high ``` The organization-wide scan workflow (`org-scan.yml`) does not pass a `baseline` input: it scans each repository with the Action's defaults and reports every finding. Use it with `fail-on-severity: none` as the inventory and digest, and use the per-repository workflow above for the pull-request gate. This is also the workflow created by the adoption pull requests in [step 7](#7-open-adoption-pull-requests-at-scale); add the `baseline` line to that file when you accept its first report. A shared baseline can be combined with the repository's own on the command line: ```bash npx @velella/offing scan . \ --baseline ../org-config/offing-baseline.json \ --baseline .offing-baseline.json ``` `--baseline` is repeatable on the CLI. The Action input takes one path, so keep the repository's own file as the one the workflow reads and merge shared entries into it, or check out the shared file in the workflow and point `baseline` at that path. ## 4. Read the report artifacts Each scan job writes a versioned JSON report and uploads it as an artifact named `offing-report-`. Artifacts are retained for 14 days. In GitHub, open the run from **Actions → Offing organization scan**, scroll to **Artifacts**, and download the artifact for one repository. It contains `offing-report.json`. The run's job summary also shows the counts per severity, the ten most severe findings, and a link to the evidence for each one. From a terminal, download every report from a run and merge them into one organization view: ```bash gh run list --repo your-org/your-ops-repo --workflow offing-org-scan.yml --limit 1 gh run download --repo your-org/your-ops-repo \ --pattern 'offing-report-*' --dir org-reports npx @velella/offing rollup org-reports/*/offing-report.json ``` The rollup reads the saved reports and nothing else, so it uses no network and does not touch the repositories. It prints totals, then counts grouped by repository, product, and owner: ```text Offing rollup: 7 findings across 2 repositories By repository: sample-api: 5 (2 high, 3 medium; nearest deadline 2020-02-11) sample-web: 2 (2 high; nearest deadline 2024-07-24) By product: nodejs: 3 (3 medium) in sample-api github-actions: 2 (2 high) in sample-api, sample-web npm: 2 (2 high) in sample-api, sample-web By owner: unowned: 7 (4 high, 3 medium) in sample-api, sample-web ``` The repository name comes from the artifact directory: `org-reports/offing-report-sample-api/offing-report.json` becomes `sample-api`. A repository with no matching artifact is absent, not counted as clean. ### Add owners Create an `owners.json` that maps repository names to teams, then pass it to the rollup: ```json { "sample-api": "platform", "sample-web": "frontend" } ``` ```bash npx @velella/offing rollup org-reports/*/offing-report.json --owners owners.json ``` A repository missing from the file is grouped under `unowned`. This is the same grouping the Slack and Teams digest uses. ### Read the JSON directly The report is `offing.scan.v1` (schema: [`schemas/scan.v1.schema.json`](../schemas/scan.v1.schema.json)). Each finding names its rule, severity, exact location, deadline when the source gives one, replacement, and evidence: ```json { "schemaVersion": "offing.scan.v1", "findings": [ { "ruleId": "github-actions.checkout.node20", "severity": "high", "location": { "path": ".github/workflows/ci.yml", "line": 7 }, "deadline": "2026-09-23", "replacement": "actions/checkout@v5 or later", "sources": [{ "title": "GitHub changelog", "url": "https://github.blog/..." }] } ] } ``` Suppressed findings are absent from the report. A scan that used a baseline adds a `suppressions` object that says how many it hid and which entries are expiring or expired. ### Optional: publish one page The same rollup renders as a self-contained HTML page: ```bash npx @velella/offing rollup org-reports/*/offing-report.json \ --owners owners.json --html > org-report.html ``` [`templates/rollup/`](../templates/rollup/) packages that command as a GitHub Pages workflow. It runs after a successful organization scan, downloads the same artifacts, and publishes the page without a server. It only runs when the scan succeeds, so use `fail-on-severity: none` if you want the page even while the backlog is ungated. ### Optional: developer portals To show the same lifecycle risk in Backstage, Grafana, Port, or Cortex — from one offline `export --format portal` catalog — see [Developer portals](PORTALS.md). ## 5. Set up the recurring digest The schedule in [step 2](#2-add-the-caller-workflow) is the recurrence. This step connects it to a chat channel. After the scan jobs finish, the reusable workflow downloads the run's `offing-report-*` artifacts and renders the same text as `offing rollup`. It lists the total findings across every repository by severity, repository, product, and owner, with each repository's nearest deadline. It posts even when some scan jobs failed, so a red run does not silence the weekly numbers. Enable one or both webhooks by adding the secrets and passing them from the caller: ```yaml jobs: org-scan: uses: velella-sh/offing/.github/workflows/org-scan.yml@v0.8.0 with: organization: your-org secrets: github-token: ${{ secrets.OFFING_ORG_TOKEN }} slack-webhook-url: ${{ secrets.OFFING_SLACK_WEBHOOK }} teams-webhook-url: ${{ secrets.OFFING_TEAMS_WEBHOOK }} ``` The caller also needs `actions: read`, because the digest downloads the artifacts of its own run. Without that permission the notify job cannot read the artifacts; the scan jobs are unaffected. If neither webhook secret is set, the notify job is skipped entirely and no outbound request is made. ### Set up the Slack webhook 1. Open [api.slack.com/apps](https://api.slack.com/apps) and create (or choose) an app for the channel that should receive digests. 2. Under **Features → Incoming Webhooks**, enable incoming webhooks and click **Add New Webhook to Workspace**. 3. Select the target channel and confirm. Slack shows a URL of the form `https://hooks.slack.com/services/T…/B…/…`. 4. In GitHub, open **Settings → Secrets and variables → Actions** in the repository that calls the org-scan workflow. 5. Create a secret named `OFFING_SLACK_WEBHOOK` and paste the webhook URL as its value. Treat the URL as a credential: anyone who has it can post to the channel. 6. Pass it to the workflow with `secrets: slack-webhook-url: ${{ secrets.OFFING_SLACK_WEBHOOK }}`. ### Set up the Teams webhook Teams uses Workflow webhooks, not the legacy Incoming Webhook connector, which Microsoft is retiring. The Workflow webhook produces a URL compatible with Offing's Adaptive Card payload. 1. In the Teams channel that should receive digests, open **… → Workflows**. 2. Search for **Post to a channel when a webhook request is received** and select it. 3. Follow the setup wizard. Teams generates a URL of the form `https://prod-XX.westeurope.logic.azure.com/…`. 4. In GitHub, create a secret named `OFFING_TEAMS_WEBHOOK` and paste the URL. 5. Pass it to the workflow with `secrets: teams-webhook-url: ${{ secrets.OFFING_TEAMS_WEBHOOK }}`. ### Secrets table | Secret | Required | Meaning | | --- | --- | --- | | `slack-webhook-url` | No | Incoming-webhook URL. When set, a Slack digest is posted after every scan. | | `teams-webhook-url` | No | Workflow-webhook URL. When set, a Teams digest is posted after every scan. | Both secrets are optional and independent. You can enable Slack, Teams, both, or neither. ### Test the digest Run the workflow by hand and confirm the message arrives in the channel: ```bash gh workflow run offing-org-scan.yml --repo your-org/your-ops-repo ``` The workflow truncates the digest to Slack's 3,000-character section limit, adding a `…truncated` marker. Keep the channel to the summary and use the artifacts or the [rollup page](#optional-publish-one-page) for the full list. ## 6. Offer the starter from the organization's `.github` repository So each repository can add Offing from the Actions **New workflow** gallery instead of pasting YAML by hand, put the starter in the organization's [`.github` repository](https://docs.github.com/en/actions/sharing-automations/creating-workflow-templates-for-your-organization): 1. Create `your-org/.github` if it does not exist. 2. Copy both files from [`templates/workflows/`](../templates/workflows/) into that repository: ```text workflow-templates/offing.yml workflow-templates/offing.properties.json ``` 3. Commit them on the default branch. The next time someone opens **Actions → New workflow** in a repository of the organization, **Offing lifecycle scan** appears in the list. The starter is one job with one `uses:` line. It calls [`.github/workflows/offing-reusable.yml`](../.github/workflows/offing-reusable.yml) on pull requests, pushes to the default branch, a weekly schedule, and `workflow_dispatch`, with `contents: read` only: ```yaml name: Offing on: pull_request: push: branches: [$default-branch] schedule: - cron: "0 4 * * 1" workflow_dispatch: permissions: contents: read jobs: lifecycle: uses: velella-sh/offing/.github/workflows/offing-reusable.yml@v0.8.0 ``` `$default-branch` is GitHub's placeholder; it becomes the repository's real default branch when someone creates the workflow from the gallery. Until `v0.8.0` is tagged, replace `@v0.8.0` with `@main` in the copy under `workflow-templates/`, then pin the release tag before relying on it. To accept existing findings later, add `with: baseline: .offing-baseline.json` on that job (see [step 3](#3-accept-existing-findings-with-a-baseline)). Inputs, outputs, and the permission boundary are documented under [Reusable workflow](GITHUB_ACTION.md#reusable-workflow-one-uses-line). ## 7. Open adoption pull requests at scale The organization-scan workflow covers every repository on a schedule. To put a pull-request workflow in every repository without waiting for each team to open the gallery, Offing can open the adoption pull requests for you. Offing lands best as one reviewed change per repository. The rollout tool (`tools/org-rollout.ts`) turns that into a single step: it lists the target repositories, shows exactly what it would change, and only opens pull requests when you explicitly ask for it. Nothing is ever pushed directly to a default branch, and scans stay offline: the tool's own network calls are GitHub API calls, never repository analysis. Run it from a checkout of this repository: ```bash npm ci npm run org:rollout -- --org my-org # print the plan, change nothing npm run org:rollout -- --org my-org --apply # open one pull request per repository npm run org:rollout -- --repos acme/api,acme/web # a specific repository list instead of an org ``` `--apply` needs `GITHUB_TOKEN` or `GH_TOKEN` with write access to the target repositories. A token with read-only access can still run the dry run. ### What it does For every target repository (non-archived, not a fork, with a default branch) it: 1. Checks whether any existing workflow under `.github/workflows/` already invokes `velella-sh/offing`. If one does, the repository is skipped, so reapplication can never stack duplicate workflows. 2. Resolves the action version to pin. Pass `--action-ref v0.8.0` for a fixed version; otherwise the tool pins the repository's latest release tag. 3. Prints the plan: each repository with `pull-request` or `skip`, the totals, and the full workflow file that would be added. **Without `--apply` this is the whole run.** 4. With `--apply`, and a token with write access, it opens one pull request per repository. The pull request creates the branch, adds `.github/workflows/offing.yml` (the workflow in [GitHub Action](GITHUB_ACTION.md)), and opens the pull request against the default branch. The generated workflow runs the scan on pull requests, on a weekly schedule (`--schedule`, default `0 4 * * 1`), and on demand via `workflow_dispatch`. It needs no configuration: read-only permissions, no secrets, findings as annotations, and a `high` severity gate. To accept existing findings, commit the baseline from [step 3](#3-accept-existing-findings-with-a-baseline) and add the `baseline` input to the generated workflow in the same pull request: ```yaml - uses: velella-sh/offing@v0.8.0 with: baseline: .offing-baseline.json fail-on-severity: high ``` Without the input, the Action reports every finding even though the baseline file is committed. ### Options | Option | Default | Meaning | | --- | --- | --- | | `--org ` | — | Target every qualifying repository of this organization. | | `--repos ` | — | Comma-separated `owner/name` list instead of an organization. | | `--apply` | off | Open the pull requests. Without it, only the plan is printed. | | `--schedule ` | `0 4 * * 1` | Cron expression for the weekly scan. | | `--action-ref ` | latest release | Action version to pin. | | `--branch ` | `offing/lifecycle-scan` | Rollout branch name. A branch left behind by an earlier run is reused. | ### Design rules - **Reviewed, never silent.** A rollout starts as a printed plan; opening pull requests is a deliberate `--apply`. Closing a pull request opts that repository out. - **Idempotent in review.** Re-running the dry run after some merges only plans the repositories still missing the workflow. - **Failures are reported, not hidden.** A repository that cannot take its pull request (no token, branch conflict) is listed at the end with its GitHub API error and the run exits nonzero; the rest still complete. ## Limits GitHub Actions caps a matrix at 256 jobs, so an organization with more than 256 scannable repositories (archived and disabled repositories are skipped) needs call sites split into batches; call the workflow once per sub-organization. Archived and disabled repositories are skipped; forks and internal repositories are scanned unless the token cannot read them. Artifacts are retained for 14 days. Download or roll up a run's reports before they expire if you need a longer history, or publish the [rollup page](#optional-publish-one-page) from a workflow that runs alongside the scan. ## Troubleshooting | Symptom | Likely cause | | --- | --- | | `List repositories` fails with `404` | The token cannot see the organization. Check its resource owner, repository access, and organization approval. | | `List repositories` fails with `403` | The token is missing `contents`/`repo` or `read:org`, or the organization restricts fine-grained tokens. | | A scan job cannot check out a repository | The token cannot read that repository's contents. Give it **All repositories**, not a selected list. | | The digest is skipped | Neither `slack-webhook-url` nor `teams-webhook-url` was passed. | | The digest job fails at `Download scan reports` | The caller did not grant `actions: read`. The reusable workflow cannot elevate its own permissions. | | Committed baselines have no effect | The workflow did not pass `baseline`. Add it to the per-repository Action step. The organization scan does not pass one. | | The workflow reports `v0.8.0` not found | The release is not tagged yet. Use `@main` to try it, then pin the release tag. | ## Privacy and permissions | What | Where it runs | Network | | --- | --- | --- | | List repositories | GitHub Actions, caller repository | `GET /orgs//repos` | | Scan one repository | GitHub Actions, checked-out repository | None. Files and bundled data only. | | Upload reports | GitHub Actions artifacts | Normal Actions artifact storage, 14 days | | Rollup digest | GitHub Actions, notify job | Reads local artifacts, then posts to the configured webhook | | `offing baseline`, `offing rollup` | Your machine or CI | None. Local files only. | Only `contents: read` is needed for the scans. Add `actions: read` for the digest. The dedicated token is used only by the list job; it is never passed to a scan. Offing does not send repository contents to an Offing service, and the report can contain repository-relative paths, matched excerpts, dependency versions, and lifecycle evidence, so apply normal artifact access controls. --- Source: https://velella.sh/offing/GITLAB/ # GitLab Offing ships a GitLab CI/CD component, [`templates/offing.yml`](../templates/offing.yml). It is published in the [CI/CD Catalog](https://gitlab.com/explore/catalog/velella-sh/offing). It adds one job that scans the checked-out repository, keeps the JSON report as a job artifact, and fails the pipeline at a severity gate. Its inputs mirror the [GitHub Action](GITHUB_ACTION.md): `path`, `baseline`, `baseline_warn_days`, `on_expired`, `min_support_days`, and `fail_on_severity` have the same meanings and defaults. ## Add the job On GitLab.com, include the Catalog release. Pin the component to a plain semver Catalog version (no `v` prefix); the component's `version` input then defaults to the matching `offing` npm package: ```yaml include: - component: $CI_SERVER_FQDN/velella-sh/offing/offing@0.7.1 inputs: fail_on_severity: high ``` That include was verified from a clean public project: the job pulled `node:24-trixie-slim`, installed `@velella/offing@0.1.0` with `npx`, wrote `offing-report.json` and `gl-code-quality-report.json`, and passed at the default `high` gate with no findings. Set `baseline` only when you already have a baseline file committed (path relative to `path`). On a self-managed instance, host a copy of the component and replace the project path: ```yaml include: - component: $CI_SERVER_FQDN///offing@ inputs: fail_on_severity: high ``` ## Permissions The job needs nothing beyond the default pipeline permissions: - It reads the files GitLab checks out for the job. It uses a shallow clone (`GIT_DEPTH: "1"`). - It uses no access token, project or group variable, or secret, and does not call the GitLab API. - It writes two files, kept as job artifacts for 14 days even when the gate fails: the JSON report (`offing-report.json` unless you set `report_name`) and a [Code Quality](https://docs.gitlab.com/ci/testing/code_quality/) report (`gl-code-quality-report.json` unless you set `code_quality_report_name`), which GitLab shows as findings in merge requests. The same report is available locally with `offing scan --format codequality`. - It never edits the repository, opens a merge request, or runs a migration command. ## Network behavior Scans make no network requests: the detectors read repository files and the lifecycle data bundled in the package. The job still needs the network twice, before the scan starts: | When | What | Where it goes | | --- | --- | --- | | The runner starts the job | Pulls the `image` input (default `node:24-trixie-slim`) | Your runner's container registry | | The job runs `npx` | Downloads `offing@` and its dependencies | The npm registry your runner is configured for | Neither request carries repository contents. Repository contents and findings stay on the runner; the report leaves it only as a GitLab job artifact. ## Self-managed GitLab - **Hosting the component.** Put the component in a project on your instance, mirroring `templates/offing.yml`, and reference it with `$CI_SERVER_FQDN` as above. Components and `$[[ inputs.* ]]` interpolation need a GitLab version that supports CI/CD components; check your instance's release notes. - **No access to Docker Hub.** Mirror a Node.js 22 or newer image into your own registry and pass it as `image`. - **No access to the public npm registry.** Point npm at your internal registry or proxy, for example with an `NPM_CONFIG_REGISTRY` CI/CD variable, and make sure it serves `offing` at the pinned version. - **Air-gapped runners.** Bake the package into the image and set `version` to match. The job's `npx --yes` call then finds it locally, and the scan itself already needs no network. - **Lifecycle data.** The job does not run `offing data update`, so it never fetches newer rules from GitHub. To change the rules a pipeline uses, change the `version` input. See [Lifecycle data](DATA.md). ## Gate and baseline The job exits with the gate's status after writing the report, so the report is available when the job fails. Accept existing findings with a baseline committed to the repository, and set `baseline` to its path relative to `path`; the format, expiry warnings, and `on_expired` behavior are the same as in the [GitHub Action](GITHUB_ACTION.md). See also [Privacy](PRIVACY.md). --- Source: https://velella.sh/offing/playbooks/gitlab/ # Playbook: GitLab pipelines A start-to-finish guide for running Offing in a GitLab pipeline: add the component, read what it reports, gate merges on severity, accept known findings with a baseline, and run it on a self-managed instance. For the component's inputs, permissions, and network behavior in full, see [GITLAB.md](../GITLAB.md). For fixing a finding by hand or with an agent, see [cli.md](cli.md) and [agent.md](agent.md). ## Before you start - Find the GitLab version of your instance. CI/CD components and `$[[ inputs.* ]]` interpolation need a release that supports them. On GitLab.com they are available. - The component is not in the CI/CD Catalog yet. Host a copy of `templates/offing.yml` in a GitLab project of your own, on GitLab.com or on a self-managed instance, and reference it as `$CI_SERVER_FQDN///offing@`. Section 5 covers the self-managed details. - Confirm the runner can pull a Node.js 22 or newer image and reach an npm registry that serves `offing`. The job needs both before the scan starts. - Run the scan once from a workstation before you gate on it, so you see the findings you are about to inherit. `npx @velella/offing scan . --json` is the same scan the job runs. ## 1. Add the job Add the component to `.gitlab-ci.yml`: ```yaml include: - component: $CI_SERVER_FQDN///offing@ inputs: fail_on_severity: high ``` Pin `` to a release. The component's `version` input decides which `offing` package the job runs, and it defaults to the version the component shipped with. Pinning the component and leaving the default in place keeps the rules you tested and the rules the pipeline uses the same. To change the rules a pipeline uses, change the version, not the data: the job does not run `offing data update`. The job runs in the `test` stage by default. Set `stage` if your pipeline names its stages differently. ## 2. Read the result The job writes two files and keeps them as artifacts for 14 days, even when the gate fails: | File | Input that names it | What it is for | | --- | --- | --- | | `offing-report.json` | `report_name` | The full JSON report. Use it for audits and scripts. | | `gl-code-quality-report.json` | `code_quality_report_name` | The GitLab Code Quality report. GitLab shows its findings in the merge request. | Open the merge request's Code Quality widget first. Each finding names its rule and file. Then open the job log: the human-readable summary lists the same findings with their replacements. To reproduce a result locally, use the same version and gate as the job: ```bash npx --yes @velella/offing@ scan . --fail-on high ``` A local run and a pipeline run disagree only when the repository, the version, or the gate differs. If they disagree, compare the three before you assume a bug. ## 3. Gate the merge on severity `fail_on_severity` is the lowest severity that fails the job. It defaults to `high`. The options are `none`, `info`, `low`, `medium`, `high`, and `critical`. Start with the default. Move to `high` once the existing findings are either fixed or accepted in a baseline. Moving to `medium` or lower fails on findings a team may not yet have a plan for, so do it only after the backlog is clear. When the job fails, the exit status is the gate's, and the reports are still there. Read the Code Quality widget, then pick the path: - **Fix it by hand.** Follow [cli.md](cli.md) from the explain step. - **Hand it to an agent.** Follow [agent.md](agent.md): one finding per branch, verified by the project's tests and a fresh scan. - **It is wrong.** Do not work around it. Produce the report with `offing report-issue '' . --output issue.md` and file it. ## 4. Accept known findings with a baseline A baseline is a file committed to the repository that lists findings you have accepted, each with an expiry date. Set `baseline` to its path relative to `path`: ```yaml include: - component: $CI_SERVER_FQDN///offing@ inputs: baseline: .offing-baseline.json baseline_warn_days: "14" on_expired: fail ``` - `baseline_warn_days` warns when a suppression expires within that many days. - `on_expired` decides what happens after the date. `resurface` reports the finding again. `fail` also fails the job, so an accepted risk cannot quietly stay accepted past its date. The baseline format and expiry behavior are the same as in the [GitHub Action](../GITHUB_ACTION.md). Review every baseline change in the merge request like any other code change: a new suppression is an accepted risk, and it should carry a reason and a date a person chose. To warn before a finding's support ends, set `min_support_days`. Leave it empty to report only what the data already marks. ## 5. Run it on a self-managed instance Self-managed runners often cannot reach Docker Hub or the public npm registry. Work through these, in order: 1. **Host the component.** Copy `templates/offing.yml` into a project on your instance, then reference it as in step 1, with `$CI_SERVER_FQDN///offing@`. 2. **Mirror the image.** Copy a Node.js 22 or newer image into your registry and set the `image` input to it. 3. **Point npm at your registry.** Set `NPM_CONFIG_REGISTRY` as a CI/CD variable so `npx` finds `offing` at the pinned version on your proxy. 4. **Or bake the package in.** On an air-gapped runner, put `offing` in the image and set `version` to match. The job's `npx --yes` call then uses the local copy. The scan itself never needed the network. Keep the component version and the package version identical. A mismatch runs a rule set you did not choose. ## Failure modes | Symptom | Likely cause | What to do | | --- | --- | --- | | Job fails before any scan output | The runner cannot pull `image`, or `npx` cannot download the package | Mirror the image or configure the npm registry. Step 5. | | No report artifact after a failed job | The job stopped before the scan ran, so nothing was written | Read the job log from the top. Reports exist only once the scan has run. | | The job passes, but a `.gitlab-ci.yml` image is end-of-life | Image values in CI files are reported as unclassified, never as findings | Treat it as unknown, not safe. Check the image with `offing check` or read [COMPATIBILITY.md](../COMPATIBILITY.md). | | A baseline entry does not suppress a finding | The `baseline` path is relative to `path`, not the repository root, or the finding's rule or location changed | Check the path in the job log. Re-run the scan to get the current finding ID. | | A suppression fails the job after its date | `on_expired: fail` is working as designed | Fix the finding, or extend the suppression with a reason in a reviewed merge request. | ## Privacy boundaries - The job uses no access token, project or group variable, or secret, and makes no GitLab API call. It never edits the repository or opens a merge request. - The scan makes no network requests. The only network use is the image pull and the npm download, and neither carries repository contents. - The reports leave the runner only as job artifacts. They name the files and lines where findings occur, so treat them as project metadata: anyone who can read the job's artifacts can read them. Check the project's visibility settings before you rely on the report staying private. - Artifacts expire after 14 days. Download anything you need to keep. ## Check it works Before you rely on the gate, run the job against a repository with a known finding, such as a dependency on a package or runtime that is past end of life. Then confirm two things: - The Code Quality widget in the merge request lists that finding. - The job's exit status matches the gate: it fails when a finding is at or above `fail_on_severity`, and passes when none is. --- Source: https://velella.sh/offing/HOOKS/ # Hooks Offing ships a Git pre-push hook that scans before you push, edit hooks for several agent hosts that check the files an agent just wrote, a Claude Code `PreToolUse` hook that checks packages before an agent installs them, a Cursor project rule, and a GitHub Copilot instructions file that tell the agent to check a dependency before it is added. All of them stay offline and use only the lifecycle rules bundled with Offing. ## Edit hooks `offing hook ` is how an agent host asks Offing about a file it just wrote. The host sends its own hook payload as JSON on stdin; Offing runs one offline scan of the repository, keeps the findings in that file at or above the severity gate (default `medium`, or `--fail-on none` to report nothing), and answers in the protocol the host understands. A hook only prints and exits: it never edits files. A hook must never break the agent's session. An unknown host, a malformed payload, an event that is not a file write or edit, a scan error, and a scan that takes longer than ten seconds all exit `0` with no output. `offing init --hooks-for ` writes the host's configuration so the host calls the command after an edit; each host has its own page, listed below. Each host has its own page under [`docs/hooks/`](https://github.com/velella-sh/offing/tree/main/docs/hooks), so a new host adds a page and one line here: - [Claude Code edit hook](hooks/claude-code.md) - [Cline edit hook](hooks/cline.md) - [Codex CLI edit hook](hooks/codex.md) - [GitHub Copilot edit hook](hooks/copilot.md) - [Cursor edit hook](hooks/cursor.md) - [Devin Local edit hook](hooks/devin.md) - [Gemini CLI edit hook](hooks/gemini.md) ## Git pre-push `offing init --hooks` installs a Git pre-push hook that scans the repository before every push and blocks it when a finding reaches a severity you choose. It uses only local files; the scan never uses the network. ```bash npx @velella/offing init --hooks --dry-run # show the hook first npx @velella/offing init --hooks # install it npx @velella/offing init --agents --hooks # also add the agent instructions ``` ### What the Git hook does It runs `offing scan . --fail-on --format compact`. The default gate is `high`; choose another with `--fail-on low|medium|high|critical`. Only findings at or above the gate block the push (exit code 1). If Offing is not installed in the project, or a scan fails for another reason, the push is not blocked. `scan --fail-on ` works on its own too, for example in CI. Without it, `scan` exits 0 as before. ### Safe to run again The hook holds one block between `# >>> offing pre-push >>>` and `# <<< offing pre-push <<<`. Running `init --hooks` again leaves a current block unchanged, and rewrites only that block when the gate changes. It creates `.git/hooks/pre-push` (honoring `core.hooksPath`) as an executable script. Offing never overwrites a hook it did not write. If `pre-push` already exists without the block, the command stops and prints the line to add to it yourself. ### Husky and Lefthook When a `.husky/` folder or a `lefthook.yml` exists, that tool owns the hooks, so Offing writes nothing and prints what to add: ```bash # .husky/pre-push npx --no-install offing scan . --fail-on high --format compact ``` ```yaml # lefthook.yml pre-push: commands: offing: run: npx --no-install offing scan . --fail-on high --format compact ``` ### The pre-commit framework Teams that use [pre-commit](https://pre-commit.com) add Offing with three lines of config. Pin `rev` to a release tag so everyone runs the same scanner: ```yaml # .pre-commit-config.yaml repos: - repo: https://github.com/velella-sh/offing rev: v0.1.0 hooks: - id: offing ``` The repository defines two hooks in `.pre-commit-hooks.yaml`, both `language: node`: | Hook | Runs | | ----------------------------- | -------------------------------------------------------------------------- | | `offing` | On every commit. | | `offing-changed-manifests` | Only when a manifest, lockfile, or CI workflow file is part of the commit. | Both run `offing scan . --fail-on high --format compact`, so a commit is blocked by a `high` or `critical` finding. The scan reads the whole repository, because a finding depends on more than the staged files, and it stays offline. To change the gate, override `entry` in your config: ```yaml hooks: - id: offing-changed-manifests entry: offing scan . --fail-on critical --format compact ``` Use `SKIP=offing git commit` to skip only Offing, or `git commit --no-verify` to skip every hook. ### Bypass the Git hook ```bash git push --no-verify # skip every hook for this push OFFING_SKIP=1 git push # skip only Offing ``` To remove the hook, delete the marked block (or the file, if it holds only that block). ### One setup for every repository `offing init --personal` protects every repository on your machine. It writes `~/.config/offing/config.json` (honoring `XDG_CONFIG_HOME`) and a pre-push hook in `~/.config/offing/git-hooks/`, then runs `git config --global core.hooksPath` to point Git at it. ```bash npx @velella/offing init --personal --dry-run # show what would be written npx @velella/offing init --personal # ask, then write npx @velella/offing init --personal --yes # confirm without asking ``` It asks before writing anything; without a terminal it needs `--yes`. `--fail-on ` sets the gate (default `high`). Running it again changes nothing when the setup is current. A global hooks path replaces each repository's own `.git/hooks`, so the personal hook runs the repository's own `pre-push` afterwards, if it has one. Offing stops without changes when Git already uses a different global hooks path, or when the hook file there is not its own. Bypass and removal work as above; to remove it, run `git config --global --unset core.hooksPath`. ## Claude Code pre-install The Claude Code hook runs before Bash tool calls. When the command is an `npm`, `pnpm`, `yarn`, `pip`, or `uv` install that names packages, each package is checked with `offing check` (in parallel). If Offing's lifecycle rules mark it as deprecated or end-of-life, the install is denied and Claude sees the reason, replacement, and evidence. Installs with no package arguments (for example `npm install` or `pip install -r requirements.txt`), packages with no rule, check failures, and check timeouts do not block — a timeout fails open so the install can proceed. This is a best-effort guardrail, not a security boundary. The hook inspects the top-level Bash command and splits on `&&`, `||`, `;`, `|`, `&`, and newlines; nested invocations such as `bash -c "npm i request"` are not inspected. ### Install 1. Copy the hook script into the project: ```bash mkdir -p .claude/hooks cp integrations/claude-code/pre-install-guard.mjs .claude/hooks/ ``` From a clone of this repository the path above works as written. From a release checkout, take the same file from the tree at that tag. 2. Merge the PreToolUse entry from `integrations/claude-code/hooks.json` into `.claude/settings.json` (or `~/.claude/settings.json` for every project): ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/pre-install-guard.mjs\"", "timeout": 60 } ] } ] } } ``` The hook pins `npx --yes @velella/offing@` to the same release as this repository. The first run may download that package from the network; once it is cached, each check and the lifecycle scan itself stay offline. Set `OFFING_CHECK_COMMAND` to another Offing command when you want a local build, for example a JSON array of argv: `["node","/path/to/dist/src/cli.js"]`. ### Opt out ```bash OFFING_SKIP=1 # skip only this Offing hook for the session OFFING_SKIP=true # same ``` Only the values `1` and `true` skip the hook (`OFFING_SKIP=0` does not). Or set `"disableAllHooks": true` in `.claude/settings.json` (or pass `--settings '{"disableAllHooks": true}'` for one run) to turn off every Claude Code hook, including this one. To remove the hook permanently, delete its entry from settings and the copied script under `.claude/hooks/`. ## Cursor rules Cursor reads project rules from `.cursor/rules/`, and a rule with `alwaysApply: true` is included in every chat. Offing ships the rule in two places, byte for byte identical, kept in sync by a test in this repository: `integrations/cursor/offing.mdc` for the install below, and `examples/agents/cursor/.cursor/rules/offing.mdc` in the complete host example verified in Cursor 3.25.9 on macOS (see that README for what the host actually did). The rule is an instruction, not an enforcement point: nothing blocks an edit the way the Claude Code hook can. The check itself stays offline, whether it goes through MCP or the CLI as below; only a first uncached `npx` run reaches the network to fetch the pinned package. ### Install 1. Copy the rule into the project from a clone of this repository (from a release checkout, take the same file from the tree at that tag): ```bash mkdir -p .cursor/rules cp integrations/cursor/offing.mdc .cursor/rules/ ``` Cursor reads only rules with the `.mdc` extension, so the extension is not optional. `offing setup` writes the same text into an existing `.cursor/rules/offing.mdc` when it detects a Cursor configuration; it asks before touching a file it did not write. 2. To have the agent call the MCP tool, follow `examples/agents/cursor/README.md` to add the `offing` server to `.cursor/mcp.json` and enable it in Cursor. Without MCP the rule says what to run instead: `npx @velella/offing check `. ### What the rule does Before Cursor's agent adds a package, runtime, or GitHub Action, or picks one to replace another, it calls `check_dependency` with a Package URL such as `pkg:npm/request@2.88.2` or `pkg:github/actions/checkout@v4`. When the status is `affected`, it prefers the replacement the finding names and cites the evidence links; `version-dependent` means it checks again with the exact version it intends to use, and `no-data` means Offing has no rule for that package, not that it is safe. The rule tells the agent not to use Offing for security audits or for routine upgrades the project already uses. ## GitHub Copilot instructions Copilot Chat and the Copilot coding agent read `.github/copilot-instructions.md` from the repository root and include it in every session. Offing ships the file in two places, byte for byte identical, kept in sync by a test in this repository: `integrations/copilot/copilot-instructions.md` for the install below, and `examples/agents/copilot/.github/copilot-instructions.md` in the complete host example — that example's text is what was already checked in Copilot Chat in Agent mode (VS Code 1.142.0 on macOS, 2026-10-19; see that README for what the agent did). The Copilot coding agent's MCP path has not been checked in the host yet, and the example README says so. The file is an instruction, not an enforcement point: nothing blocks an edit the way the Claude Code hook can. The check itself stays offline, whether it goes through MCP or the CLI as below; only a first uncached `npx` run reaches the network to fetch the pinned package. ### Install 1. Copy the instructions into the project from a clone of this repository (from a release checkout, take the same file from the tree at that tag). The target name is fixed: Copilot reads only `.github/copilot-instructions.md`. ```bash mkdir -p .github cp integrations/copilot/copilot-instructions.md .github/copilot-instructions.md ``` If the project already has a Copilot instructions file, run `offing setup` instead of copying over it: when it detects a Copilot configuration it adds the Offing section to the existing `.github/copilot-instructions.md`, keeps everything else byte for byte, and asks before touching a file it did not write. 2. To have Copilot call the MCP tool, follow `examples/agents/copilot/README.md`: add the `offing` server to `.vscode/mcp.json` for Copilot Chat, or paste `coding-agent-mcp.json` under the repository's Copilot coding agent settings. Without MCP the instructions say what to run instead: `npx @velella/offing check `. ### What the instructions do Before Copilot's agent adds a package, runtime, or GitHub Action, or picks one to replace another, it calls `check_dependency` with a Package URL such as `pkg:npm/request@2.88.2` or `pkg:github/actions/checkout@v4`. When the status is `affected`, it prefers the replacement the finding names and cites the evidence links; `version-dependent` means it checks again with the exact version it intends to use, and `no-data` means Offing has no rule for that package, not that it is safe. The instructions tell the agent not to use Offing for security audits or for routine upgrades the project already uses. ## A digest for your personal projects `offing watch` re-scans the projects you register and prints only what is new since the last digest, so a new end-of-life date shows up once. ```bash npx @velella/offing watch add ~/code/my-app # register a project npx @velella/offing watch list # show registered projects npx @velella/offing watch # re-scan and print what changed npx @velella/offing watch --weekly # do nothing unless a week has passed since the last digest npx @velella/offing watch remove ~/code/my-app # stop watching ``` The project list and what each digest already reported live in `~/.config/offing/watch.json` (honoring `XDG_CONFIG_HOME`). A finding is reported again only when its deadline changes. A project that cannot be scanned is named in the digest and keeps its history. Scans stay offline. For a weekly reminder, run it from cron or a login script, for example `0 9 * * 1 npx @velella/offing watch --weekly`. `--json` prints an `offing.watch.v1` document. --- Source: https://velella.sh/offing/CAPABILITIES/ # Capability manifest and auto-discovery contract Offing publishes one deterministic capability manifest for humans, agents, and integrations: ```bash offing capabilities --json ``` The output uses the `offing.capabilities.v1` contract. Its JSON Schema is [`schemas/capabilities.v1.schema.json`](../schemas/capabilities.v1.schema.json), and a complete committed example is [`examples/capabilities.json`](../examples/capabilities.json). ## What the manifest describes - the Offing package and product version; - supported, experimental, and unsupported capabilities; - supported detector ecosystems and their maturity; - CLI, MCP, Skill, plugin, and CI surfaces and their entry points; - the shared capability identifiers available through each surface; and - compatibility rules for consumers reading newer manifests. ## Detector maturity Each ecosystem carries a `maturity` field next to its existing `status`: | `maturity` | Shown in the docs as | What it promises users | | --- | --- | --- | | `experimental` | Experimental | The detector runs and is usable. Findings, rule ids, and unclassified output may change between releases without notice. | | `beta` | Beta | Findings were measured against independent ground truth. Rule and finding ids are listed in the changelog when they change. | | `stable` | Supported | The same promise as beta on a larger and more varied corpus, as part of the documented product contract. | `maturity` is the detector's `level` in [`benchmarks/thresholds.json`](../benchmarks/thresholds.json), which is the single source of truth for [decision 0013](decisions/0013-detector-maturity.md). It is not a second, independent judgment. An ecosystem's `maturity` is the lowest level among the detectors listed in its `detectorIds`. When several detectors serve one ecosystem, the ecosystem is only as mature as its least mature detector. Today `nodejs` and `npm` both list the `npm` detector, so both take its level. A contract test fails when the manifest disagrees with `benchmarks/thresholds.json`. The existing `status` field keeps its meaning and is not replaced: it says whether the ecosystem is a supported product surface, while `maturity` records the evidence level of the detectors behind it. A reader that ignores unknown fields can keep using `status` exactly as before; `maturity` is an additive optional field in the v1 manifest. Capability identifiers describe behavior, not transport. For example, `repository.scan` is the same capability when reached through the CLI, local MCP server, Agent Skill, experimental host plugin, or CI. Integrations should compare identifiers rather than infer behavior from command or tool names. ## Forward compatibility Consumers of a v1 manifest must follow the compatibility object included in the document: 1. Ignore fields they do not understand within schema major version 1. 2. Ignore unknown capability and surface identifiers rather than failing the whole manifest. 3. Treat any unlisted capability or ecosystem as unsupported. Absence never means experimental or supported. 4. Reject an unknown schema major version until the consumer explicitly supports it. 5. Use each entry's `status`; do not promote `experimental` or `unsupported` behavior based on a matching entry-point name. New optional fields, identifiers, and surfaces using the defined statuses can be added in a compatible v1 minor manifest update. Removing or redefining an existing identifier, changing required field meaning or status vocabulary, or weakening the rules above requires a new schema major version. ## JSON output schemas and policy for adding fields Every command supporting `--json` (or `--format json`) produces output that strictly conforms to its published JSON Schema in `schemas/*.v1.schema.json` (such as `scan.v1.schema.json`, `check.v1.schema.json`, `finding.v1.schema.json`, `context.v1.schema.json`, `doctor.v1.schema.json`, and `data-status.v1.schema.json`). Because scripts, agents, and CI automations depend on exact JSON structures, Offing locks all published schemas with strict contract tests: 1. **Strict property validation:** Output schemas enforce `additionalProperties: false` across all object definitions. CLI output must not contain properties undeclared in the schema. 2. **Deliberate schema updates:** Adding a field to any `--json` output is never done implicitly. It requires an explicit, reviewed update to the relevant JSON Schema under `schemas/`, defining the property's type, constraints, and intent. 3. **Additive changes in v1:** New optional fields can be introduced in the current `v1` major schema release without breaking backward-compatible consumers. 4. **Breaking changes require a schema bump:** Removing existing properties, renaming fields, or altering requiredness / types requires a new schema major version (such as `v2`). 5. **Contract test enforcement:** Snapshot tests in `tests/schema-snapshots.test.ts` validate real execution output for every command against its schema and assert that any undeclared property triggers schema validation failure. ## Stability and privacy The command reads a packaged constant and performs no repository scan, network request, telemetry, or configuration write. Identical Offing versions produce byte-for-byte identical JSON output. The package's contract tests validate both the CLI output and committed example against the public JSON Schema and ensure every surface references registered capability identifiers. ## Exit codes and error codes Every CLI failure belongs to one class. The class decides the exit code, and with `--json` (or `--format json`) the same class is written to stdout as an `offing.error.v1` document, so an agent can branch without parsing messages. | Class (`error.code`) | Exit code | Meaning | | --- | --- | --- | | *(none)* | 0 | The command succeeded. | | `policy` | 1 | A gate you configured failed: `check` found an affected dependency at or above `--fail-on`, `doctor` reported a failed check, or `--on-expired fail` met an expired suppression. | | `usage` | 2 | The command line is wrong: unknown command, flag, or value, or a missing argument. | | `data` | 3 | The input is well formed but the data is not there: a finding ID that was not found, a pinned data version that is not available, or a data update that was refused. | | `parse` | 4 | An input could not be parsed: an invalid Package URL, an unreadable or unsupported baseline, or an `AGENTS.md` section that is damaged. | | `internal` | 5 | Offing failed unexpectedly. This is a bug; please report it. | ```json { "schemaVersion": "offing.error.v1", "error": { "code": "usage", "reason": "unknown-command", "exitCode": 2, "message": "Unknown command: frobnicate" } } ``` Each failure also carries one stable reason code, written as `error.reason` in the error document. | Reason (`error.reason`) | Class | Raised when | | --- | --- | --- | | `policy-failed` | `policy` | A configured gate failed. No error document is written. | | `invalid-argument` | `usage` | A flag, value, or operand is wrong or missing. | | `invalid-config` | `usage` | The repository configuration file cannot be read or is invalid. | | `invalid-data-version` | `usage` | `--data-version` is not a data version. | | `unknown-command` | `usage` | The command name is not one Offing has. | | `scaffold-failed` | `usage` | `new-rule` or `new-detector` could not create its files: an invalid id, a taken name, an existing file, or no contributor checkout. | | `finding-not-found` | `data` | A finding ID matches no finding in the scan. | | `finding-not-observed` | `data` | A finding ID was not observed earlier in this MCP session. | | `data-unavailable` | `data` | A pinned data version or bundle is not available or does not verify. | | `data-update-failed` | `data` | `offing data update` could not finish. | | `invalid-purl` | `parse` | A Package URL is invalid. | | `invalid-agent-file` | `parse` | An `AGENTS.md` section is damaged. | | `invalid-sbom` | `parse` | An SBOM is not valid CycloneDX or SPDX. | | `invalid-input` | `parse` | Another input could not be parsed. | | `scan-memory-exceeded` | `internal` | The scan reached its heap limit. | | `internal` | `internal` | Offing failed unexpectedly. | Reason codes are stable: one is never renamed or moved to another class. The source is `ERROR_CODES` in `src/offing-error.ts`. The message is also written to stderr in every mode. A `policy` failure is a verdict on a result that was already printed, so stdout stays that single result document and no error document is added. The set of classes and their exit codes is stable; new classes would be added with new numbers, never by renumbering. --- Source: https://velella.sh/offing/MIGRATION_PACKETS/ # Migration packets `offing context ` converts one scan finding into a portable plan for a human or coding agent. Add `--json` for the versioned `offing.context.v1` representation. ```bash npx @velella/offing scan . --json npx @velella/offing context '' . --json ``` ## Packet contents - The complete lifecycle finding and authoritative sources. - The declaration file plus exact import, call, and configuration usages. - Rule-specific behavioral and compatibility constraints. - A warning when the replacement needs a newer Node.js or Python than the repository pins in `.nvmrc`, `.node-version`, `.python-version`, or `.tool-versions`. - Ordered migration steps resolved to declaration, usage, or all affected paths. - Suggested ecosystem verification commands and a final Offing re-scan. Built-in registry rules carry reviewed migration guidance next to their lifecycle evidence. This keeps the scan engine and CLI independent of ecosystem-specific migration knowledge. External detectors can provide the same optional guidance through the detector SDK; otherwise Offing emits a conservative generic packet. ## Safety and determinism Offing generates context but never edits files or executes verification commands. Packet generation is local, deterministic for a scan result, and does not require an LLM or network access. The runtime validator checks more than field types: packet sources and usages must equal the finding evidence, affected files must be derived from those locations, step IDs and commands must be unique, and every step path must stay inside the affected-file set. The JSON contract is published at `schemas/context.v1.schema.json`. Suggested commands may require project-specific setup and should be reviewed before execution. A migration is complete only after the relevant repository tests pass and a fresh Offing scan no longer reports the finding. --- Source: https://velella.sh/offing/PLAN/ # Migration plans `offing plan` turns a scan into waves of independent migration tasks. Each task names the files it is expected to edit (its touch set), a suggested branch, and the finding to migrate. Tasks in the same wave never share a touched path, so a host can start them as parallel agent sessions without two agents editing the same file. ```bash # From a clone of Offing: npm ci npm run build demo="$(node dist/tools/examples.js demo)" npx @velella/offing plan "$demo" npx @velella/offing plan "$demo" --json npx @velella/offing plan "$demo" --wave 0 ``` The JSON form is the versioned `offing.plan.v1` document. The local MCP tool `plan_migrations` returns the same object. Offing builds the plan and stops: it does not create branches, worktrees, or agent sessions, and it never runs a migration command. ## Touch sets A task's touch set is the repository-relative paths a migration for that finding is expected to change: the finding's declaration file, every usage the scan recorded, and — for a dependency finding — the lockfile that resolves the same manifest. Two findings that share a lockfile therefore conflict, even when their declaration lines differ. ## Waves Waves are a greedy colouring of those touch sets. Tasks are ordered by severity (highest first) then id; each task joins the first wave that does not already hold a conflicting path, or starts a new wave when every existing wave conflicts. Waves run in ascending index order. Inside one wave the tasks are independent, so a coordinating host can hand them out at the same time. ## Dispatch without Offing running anything The example at [`examples/plan-dispatch/dispatch.mjs`](../examples/plan-dispatch/dispatch.mjs) reads `offing plan --json` from stdin and, for each task, prints a `git worktree add` command plus the name of the `migrate-finding` prompt. It prints only; it never runs a command. ```bash npm ci npm run build npm run --silent example -- demo plan --json | node examples/plan-dispatch/dispatch.mjs ``` A host that wants parallel sessions creates each printed worktree itself, then runs the `migrate-finding` prompt in that tree with the task's finding id. See [Local MCP server](MCP.md) for the prompt and [Migration packets](MIGRATION_PACKETS.md) for the context each session receives. --- Source: https://velella.sh/offing/UPGRADE_CHECK/ # Upgrade check `offing upgrade-check --to [path] [--json]` answers, offline and from files already in the repository, whether a project can move to a target runtime. It lists each blocker, warning, and unknown with the exact lockfile or manifest entry behind it. Agents get the same answer through the `check_upgrade` MCP tool. ```bash offing upgrade-check --to php@8.4 . offing upgrade-check --to nodejs@22 . --json ``` The text output starts with the verdict, then one line per issue: ```text php@8.4: blocked (1 blockers, 0 warnings, 0 unknown) composer.lock:9 acme/http-client@2.1.0 acme/http-client@2.1.0 requires php ^7.4, which excludes PHP 8.4. [lockfile: require.php: ^7.4] ``` The JSON output is an `offing.upgrade.v1` document that conforms to [`schemas/upgrade.v1.schema.json`](../schemas/upgrade.v1.schema.json). Exit codes: `1` when the verdict is `blocked`, `0` for `ready` and `unknown`, `2` for bad input. An analyzer only runs when the repository has the files it reads. Each issue names its analyzer, its level (`blocker`, `warning`, or `unknown`), the affected subject, the path and line, and the evidence value exactly as written. - `blocker`: the project will not install or run on the target. - `warning`: it works but is deprecated on the target. - `unknown`: the evidence needed to judge it is missing. ## What it cannot know - **Native extensions and build-time requirements.** A package may fail to build on the target for reasons its metadata does not state. - **Packages without declared support ranges.** If a package never declares which runtime it supports, the analyzer reports `unknown` or nothing, never `ready` by assumption. - **Code paths outside the source.** Dynamic loading, `eval`, generated code, and behavior that only appears at runtime are not visible here. - **Your CI images and operating system.** The runtime inside a container image or on a runner is not part of this check. - **Unresolved dependencies.** When a lockfile does not record a support range for a package, no analyzer can judge that package. - **Anything at install time.** Offing never runs a package manager, Compiler, or test suite, and no scan uses the network. An `unknown` verdict means Offing found no evidence for that runtime in this repository. It is not a statement that the upgrade is safe. The worked commands below read the examples under `examples/upgrade/`. Their manifests and lockfiles are committed with scanner-neutral names ([decision 0027](decisions/0027-fixtures-invisible-to-scanners.md)), so `dist/tools/examples.js` copies an example to a temporary directory, restores the real names there, and runs the command inside it. Run `npm run build` first; nothing is written to the repository. ## Composer The Composer analyzer (`composer-lock`) runs for `php` targets when `composer.lock` exists. **Files and fields** - `composer.lock` `packages[*].require.php` — a `blocker`. - `composer.lock` `packages-dev[*].require.php` — a `warning`. - `composer.lock` top-level `platform.php` — a `blocker`. This is the platform requirement the lockfile was resolved against. - `composer.lock` top-level `platform-overrides.php` — a `blocker`. - `composer.json` `require.php` — a `blocker`, with evidence kind `manifest`. Each issue carries the constraint exactly as written (for example `^7.4` or `>=8.1 <8.5`) and the line of the field it came from. Package subjects are `@`; platform subjects are `platform.php` and `platform-overrides.php`; the project's own constraint uses the subject `composer.json`. **Version syntax** The matcher understands Composer's constraint syntax: - caret ranges: `^1.2.3`, `^0.3`, `^0.0.3` - tilde ranges: `~1.2`, `~8.4.0` - comparisons: `>=`, `>`, `<=`, `<`, `!=`, `<>`, `==`, `=` - wildcards: `*`, `1.*`, `1.0.x` - hyphen ranges: `1.0 - 2.0` - OR: `||` and `|` - AND: space- or comma-joined terms, such as `>=8.1 <8.5` or `>=8.1, <8.5` - pre-release suffixes: `8.4.0RC1`, `8.4.0-beta1` A target written as a release line (`8.4`) is tested as both `8.4.0` and `8.4.99`, so an upper bound written as `<8.5` is not mistaken for a blocker. A target written with three or more parts (`8.4.0`) is tested as written. **What it reports as `unknown`** - A `require.php` value that is not a constraint this matcher understands, such as a truncated OR (`^8.4 ||`) or a value containing an unevaluated `${...}` expression. A missing `require.php`, a `*` constraint, or a constraint that includes the target produces no issue. Composer itself is never run, and the lockfile is parsed as untrusted JSON. ## Go modules and vendored requirements Analyzer id `go-modules`; runtime `go`. It applies when the repository root has a `go.mod`. **Files and fields it reads** - `go.mod`: the `go` directive and its line. - `go.work`: the `go` directive and its line, when the workspace file is in the repository root. - `vendor/modules.txt`: every `# ` header and the `## explicit; go ` (or `## go `) annotation that follows it. Package lines are ignored. - The bundled `registry/go.json` rules for `pkg:generic/golang`. **Version syntax it understands** Release numbers as Go writes them: `1.23`, `1.23.0`, and a leading `go` (`go1.23.0`) are accepted and compared part by part. A target that is not a release number, such as `nightly`, is reported as `unknown`; it is never compared. **What it reports** - `blocker`, `manifest`: the target is below the `go` directive of `go.mod` or `go.work`. Since Go 1.21 the `go` line is a required minimum, not advice: a Go toolchain refuses a module that declares a newer Go version. The evidence is the directive value as written, at its line. - `blocker`, `lockfile`: the target is below the `go` version `go mod vendor` recorded for a dependency in `vendor/modules.txt`. The subject is `@` and the evidence is the annotation line exactly as written. - `warning`, `rule`: the target itself matches a bundled lifecycle rule for Go, so it is deprecated or end-of-support. The evidence is the rule id. - `unknown`: `vendor/modules.txt` is absent, or records no `go` version. Dependencies' Go requirements are not in the repository without vendoring, so Offing reports `unknown` for them instead of assuming they are compatible. It does not run `go mod tidy` or any Go command to find out. **Sources** - [Go Modules Reference: `go` directive](https://go.dev/ref/mod#go-mod-file-go): the `go` line is a minimum required Go version since Go 1.21, and must be greater than or equal to the `go` line of every dependency. - [Go Modules Reference: `go.work` files](https://go.dev/ref/mod#go-work-file-go): the workspace `go` directive names the toolchain version the workspace is written for. - [Go Modules Reference: vendoring](https://go.dev/ref/mod#vendoring): `go mod vendor` writes `vendor/modules.txt` and records the `go` version from each dependency's `go.mod` since Go 1.17. - [Go toolchains](https://go.dev/doc/toolchain): how the `go` directive selects a toolchain. ## Dart and Flutter Applies when `pubspec.lock` exists. Runtimes: `dart` and `flutter`. | File | Field | What it means | | --- | --- | --- | | `pubspec.lock` | `sdks.dart` | The Dart SDK bound shared by every resolved package: the intersection of their individual bounds. | | `pubspec.lock` | `sdks.flutter` | The Flutter SDK bound shared by every resolved package. | | `pubspec.yaml` | `environment.sdk` | The Dart SDK bound the project itself declares. | | `pubspec.yaml` | `environment.flutter` | The Flutter SDK bound the project itself declares. | A `dart@x` target is checked against the Dart bounds and a `flutter@x` target against the Flutter bounds; the other runtime's bounds are not consulted. A target outside a `pubspec.lock` bound is a `blocker` with subject `resolved package set` and `kind: "lockfile"`: the resolved set would not install. A target outside a `pubspec.yaml` bound is a `blocker` with subject `environment.sdk` or `environment.flutter` and `kind: "manifest"`. When neither file records a bound for the target runtime, one `unknown` issue is reported; it names the absent `sdks` key as evidence. A target with fewer than three components is checked as `x.0.0` or `x.y.0`, so `dart@3.5` is checked as `3.5.0`. Version syntax: a constraint is `any` or one or more space-joined terms, each `^`, `>=`, `<=`, `>`, `<`, or a bare version followed by `x.y.z` and an optional pre-release or build suffix. Caret follows pub's rules: `^1.2.3` means `>=1.2.3 <2.0.0`, `^0.2.3` means `>=0.2.3 <0.3.0`, and `^0.0.3` means `>=0.0.3 <0.0.4`. A bound the parser cannot read (a template, `||` range, or missing components) is treated as missing evidence, so the answer is `unknown` rather than `ready`. The parsers are the ones the Dart detector uses: `src/detectors/dart/parse-pubspec-lock.ts` and `parse-pubspec.ts`. ```bash node dist/tools/examples.js upgrade/dart-sdk upgrade-check --to dart@3.5 ``` ```text blocked: dart@3.5 (1 blocker, 0 warnings, 0 unknown) pubspec.lock:12 resolved package set The resolved package set requires Dart >=3.6.0 <4.0.0, which excludes 3.5.0. [lockfile >=3.6.0 <4.0.0] ``` ## npm lockfile Runtime: `nodejs`. Applies when `package-lock.json` or `npm-shrinkwrap.json` exists with `lockfileVersion` 2 or 3. A version 1 lockfile is reported as one `unknown` issue because it does not record package `engines`. - Files read: `package-lock.json` and `npm-shrinkwrap.json`. Each issue names the file it came from. - Fields read: `lockfileVersion`; for each `packages["node_modules/..."]` entry, `version`, `engines.node`, `dev`, and `devOptional`. - Version syntax: semver ranges in `engines.node`, tested with `semver.satisfies` and `includePrerelease: false`. A bare target major such as `22` is tested as `22.0.0`; the bundled lifecycle data does not list per-minor Node.js releases. - Levels: `blocker` for a production dependency whose range excludes the target; `warning` for an entry marked `dev: true` or `devOptional: true`; `unknown` for an `engines.node` value that is not a valid semver range. - Evidence: `engines.node: `, at the line of the `engines` key. - Deduplication: identical name, version, and range across nested paths keep the first path. ```bash node dist/tools/examples.js upgrade/npm-lock upgrade-check --to nodejs@22 ``` ```text nodejs@22: blocked (1 blockers, 0 warnings, 0 unknown) package-lock.json:16 sharp@0.30.7 sharp@0.30.7 declares engines.node ">=14.0.0 <17", which excludes Node.js 22. [lockfile: engines.node: >=14.0.0 <17] ``` ## pnpm lockfile Runtime: `nodejs`. Applies when `pnpm-lock.yaml` exists with `lockfileVersion` 6.x or 9.x. - Files read: `pnpm-lock.yaml`. In 6.x, `packages` entries carry `engines`, `dev`, and `devOptional`. In 9.x, `engines` lives under `packages`, while the dev flag is resolved from `importers` and `snapshots`. - Fields read: `lockfileVersion`; for each `packages` entry, the key (`@`, with an optional leading `/` and an optional peer suffix) and `engines.node`; in 6.x, `dev` and `devOptional`; in 9.x, the `dependencies`, `optionalDependencies`, and `devDependencies` of every `importers` entry, and the `dependencies` and `optionalDependencies` of every `snapshots` entry. - Version syntax: semver ranges in `engines.node`, tested with `semver.satisfies` and `includePrerelease: false`. A bare target major such as `22` is tested as `22.0.0`; the bundled lifecycle data does not list per-minor Node.js releases. - Levels: `blocker` for a production dependency whose range excludes the target; `warning` for a dev dependency (`dev: true` or `devOptional: true` in 6.x, or reachable only from a dev dependency in 9.x); `unknown` for an `engines.node` value that is not a valid semver range. - Evidence: `engines.node: `, at the line of the `engines` key. - Deduplication: identical name, version, and range across peer variants keep the first line. ## Poetry (`poetry.lock`) Applies when `poetry.lock` exists. Runtime: `python`. Files and fields it reads: - Every `[[package]]` entry in `poetry.lock`: `name`, `version`, `python-versions`, and `category` (Poetry 1.1) or `groups` (later Poetry versions). Both group forms are handled. - The project's own `[tool.poetry.dependencies]` `python` constraint in `pyproject.toml`, as a string or as a table with `version`. Version syntax it understands: PEP 440 specifiers `>=`, `<=`, `<`, `>`, `==` (including `.*`), `!=`, and `~=`, joined with commas for AND; Poetry's `^` (caret), `~` (tilde), and `||` (OR) forms; and `*`, which matches every version. Comparisons follow PEP 440 ordering, including pre-releases, post-releases, development releases, and epochs. What it reports: - `blocker` for a main-group package whose `python-versions` excludes the target release, and for a project-level `python` constraint that excludes it. - `warning` for a package in the `dev` or any other non-main group whose `python-versions` excludes the target. - `unknown` for a `python-versions` value or project constraint the matcher cannot parse. Evidence is the value as written: `python-versions: ` with `kind: "lockfile"` at that field's line, or `python: ` with `kind: "manifest"` at the `python` line in `pyproject.toml`. The subject is `@`, or `python` for the project constraint. Example: ```text blocked poetry.lock:12 requests@2.28.0 requests@2.28.0 declares python-versions ">=3.7,<3.11", which excludes Python 3.13. [lockfile: python-versions: >=3.7,<3.11] pyproject.toml:9 python The project's [tool.poetry.dependencies] python constraint ">=3.8,<3.12" excludes Python 3.13. [manifest: python: >=3.8,<3.12] ``` ## uv and PDM (`uv.lock`, `pdm.lock`) Applies when `uv.lock` or `pdm.lock` exists. Runtime: `python`. Each issue names the file it came from. Files and fields it reads: - The top-level `requires-python` in `uv.lock`: the project's own range. - Every `[[package]]` entry in `uv.lock`: `name`, `version`, `resolution-markers`, `sdist`, and the python and ABI tags of each wheel name. - The `[metadata]` `requires_python` in `pdm.lock`, when present. - Every `[[package]]` entry in `pdm.lock`: `name`, `version`, `requires_python`, and `groups`. Version syntax it understands: PEP 440 specifiers `>=`, `<=`, `<`, `>`, `==` (including `.*`), `!=`, and `~=`, joined with commas for AND, plus `||` alternatives; the same comparisons in `resolution-markers` clauses on `python_version` and `python_full_version`; and wheel python tags that name a minor version (`cp39`, `py38`, `cp39-abi3`). A wheel tagged `abi3` allows every release at or above its tag. Markers that mention other variables (`sys_platform`, `platform_system`) do not bound Python. What it reports: - `blocker` when the project's own range (top-level `requires-python` in `uv.lock`, `[metadata]` `requires_python` in `pdm.lock`) excludes the target, when a uv package has no resolution that admits the target, and when a `default`-group PDM package's `requires_python` excludes it. - `warning` for a PDM package in the `dev` or any other non-`default` group whose `requires_python` excludes the target. `uv.lock` has no groups. - `unknown` for a range, marker, or wheel tag the matcher cannot parse, and for an invalid TOML file. A uv package is reported only when every one of its resolutions states a Python bound and every bound excludes the target. Wheel tags are read only when the entry has no sdist, and a package with no bound at all is reported as nothing: wheel availability is never treated as evidence. Evidence is the value as written: `requires-python: ` or `requires_python: ` for the project range, `resolution-markers: ` for an excluding resolution, `wheels: -, ...` for version-specific wheels, and `requires_python: ` for a PDM package, all with `kind: "lockfile"` at that field's line. The subject is `@`, or `python` for the project range. Example: ```text python@3.13: blocked (1 blockers, 1 warnings, 0 unknown) uv.lock:7 twisted@24.3.0 twisted@24.3.0 resolves only for Python versions matching "python_full_version < '3.13'", which excludes Python 3.13. [lockfile: resolution-markers: python_full_version < '3.13'] pdm.lock:4 black@24.3.0 black@24.3.0 is in the dev group and its requires_python ">=3.9,<3.13" excludes Python 3.13. [lockfile: requires_python: >=3.9,<3.13] ``` ## Python standard library Applies when the repository has at least one Python source file. - **Reads:** every `.py` file the usage analysis reads (the same traversal as a scan: `.git`, virtual environments, build output, and dependency directories are skipped, as are files over 1 MB) and the module-import and symbol rules in `registry/python.json` whose package URL is `pkg:generic/python`. - **Version syntax:** PEP 440 ranges from the rule's `vers`, compared with the target release, as in `python@3.13`. - **Reports:** every `import module` and `from module import name` of a module removed by the target, and every use of a removed or deprecated function that the symbol rules describe. A `removed` rule whose range includes the target is a blocker; a `deprecated` rule whose range includes the target is a warning. Each issue is one usage location; the subject is the module or symbol and the evidence is the rule id. - **`unknown`:** this analyzer reports none. A rule whose range does not include the target, or a target release the PEP 440 comparison cannot read, produces no issue. ## Node.js core APIs Analyzer id `node-apis`; runtime `nodejs`. Applies when the repository's JavaScript or TypeScript source uses an API described by a `nodejs.symbol.*` rule, found with the same parser-backed usage analysis the npm detector uses. - **Reads:** every JavaScript and TypeScript source file the usage analysis reads (the same traversal as a scan: `.git`, dependency and build directories, and files over 1 MB are skipped) and the `nodejs.symbol.*` rules in `registry/nodejs.json` whose package URL is `pkg:generic/nodejs`. - **Version syntax:** semver ranges from the rule's `vers`, compared with the target release, as in `nodejs@24`. A rule whose range does not include the target produces no issue. - **Reports:** every use of an API the target removed or deprecated. A `removed` rule whose range includes the target is a `blocker`; a `deprecated` rule whose range includes the target is a `warning`. Each issue is one usage location; the subject is the API name and the evidence is the rule id. - **Levels come from rule data only:** the `event` and `vers` fields decide. Summaries and titles are never parsed for versions, so a rule that mentions a runtime-deprecation version in its text counts as a warning from that version only when the range is encoded in `vers`. - **`unknown`:** this analyzer reports none. A target release the semver comparison cannot read, or a source file without a matched symbol, produces no issue. The removal versions in the registry are sourced to the official [Node.js deprecations page](https://nodejs.org/api/deprecations.html). ## Target lifecycle Analyzer id `target-lifecycle`; runtimes `nodejs`, `python`, `go`, `java`, `dotnet`, `ruby`, `php`, `dart`. Applies when the repository pins one of these runtimes in a file this analyzer can read; a repository without any such pin is not judged by it. **The target itself** The target release is looked up with `lookupProduct` against the bundled registry, and only the runtime's own lifecycle rules (`nodejs.runtime.*`, `python.runtime.*`, `go.runtime.*`, `java.jdk.*`, `dotnet.runtime.*`, `ruby.runtime.*`, `php.runtime.*`, `dart.sdk.*`) decide; module or platform rules that share the product never flag the release itself. - A target that is end-of-life, removed, or out of support — or whose dated end has already passed — is a `blocker`: "the target itself is end-of-life; choose ". The suggested line is the rule's `replacement` from the bundled data; nothing is ever fetched. - A target whose dated end falls within 180 days of the day checked is a `warning` with the deadline and the same suggestion. - The evidence is the rule id; the issue is located at the repository's first pin of that runtime, so the report shows where the choice has to be made. **Files that must change** Every pin of the target runtime that differs from the target is listed as a `warning` with subject `pin to update`, at its exact path and line; a pin that already names the target is not listed. - **Reads:** `.nvmrc`, `.node-version`, `.python-version` (version line, moving names such as `lts/*` are skipped); `.ruby-version`, `.php-version`, `.java-version`, and `global.json` through the existing runtime-pin parsers; every `.tool-versions` line of a known tool (a `path:` or `ref:` entry names no version and is skipped); `FROM` images of runtime repositories in any `Dockerfile`, `Containerfile`, or named variant (depth- and count-bounded walk, symlinks and build directories skipped); and `actions/setup-{node,python,ruby,java,dotnet,go,dart}` version inputs in `.github/workflows/*.yml|yaml`, scalar or list, parsed as data and never evaluated. - **Version syntax:** `semver.coerce` normalizes what a pin stands for (`20`, `v20.11`, `18.19.0`, `node:20-alpine`, `8.0.x`, `temurin-22`); the comparison respects the precision the target was written with, so the target `24` matches any `24.x` pin while the target `24.12` requires that release line. - **`unknown`:** this analyzer reports none. A runtime with no pins in the repository simply does not apply, and a target the bundled data does not flag produces only the pin list. ```bash offing upgrade-check --to nodejs@18 tests/fixtures/upgrade/target-lifecycle/example ``` The example repository pins Node.js 20 in `.nvmrc`, a `Dockerfile`, and `.tool-versions`, so the end-of-life target is blocked with the suggestion and all three pins are listed. See [tests/fixtures/upgrade/target-lifecycle/example](../tests/fixtures/upgrade/target-lifecycle/example). --- Source: https://velella.sh/offing/RECEIPTS/ # Verification receipts A verification receipt is the deterministic record of what one change did to a repository's lifecycle findings: which a before/after comparison resolved, which remain, and which the change introduced. Offing builds it by comparing two scans. It never edits files and never runs a verification command. ```bash offing receipt [path] --before [--format markdown|json] [--fail-on-introduced] ``` The default format is Markdown meant for a pull request body. `--format json` (or `--json`) prints the versioned `offing.receipt.v1` document. With `--fail-on-introduced`, the command exits `1` when the after scan reports findings the before scan did not. Through the local MCP server, `verify_migration` can return the same receipt and `receipt_markdown` when the session already holds a before scan. See [Local MCP server](MCP.md). ## Reproduce one from `examples/demo` Work on a copy of the demo so the repository tree stays unchanged. Scan first, save the report, remove the deprecated `request` dependency and its import, then ask for a receipt against the saved before scan: ```bash npm ci npm run build demo="$(node dist/tools/examples.js demo)" node dist/src/cli.js scan "$demo" --json > /tmp/offing-demo-before.json ``` In the copy, clear the `request` dependency and replace its usage: ```json { "name": "offing-demo", "private": true, "engines": { "node": ">=18" }, "dependencies": {} } ``` ```js export function fetchStatus(url) { return fetch(url).then((response) => response.status); } ``` Write those contents to `package.json` and `src/client.js`, then: ```bash node dist/src/cli.js receipt "$demo" --before /tmp/offing-demo-before.json ``` That produces the Markdown below. Add `--format json` for the structured document. ## Example Markdown receipt Real output from the steps above (Markdown escapes punctuation in titles and paths so the block stays paste-safe): ````markdown ## Offing receipt: Partially resolved | Resolved | Remaining | Introduced | | --- | --- | --- | | 1 | 4 | 0 | ### Resolved | Severity | Finding | Location | | --- | --- | --- | | high | request is deprecated | package\.json:8 | ### Remaining | Severity | Finding | Location | | --- | --- | --- | | high | Unsupported actions/checkout runtime | \.github/workflows/ci\.yml:9 | | medium | Node\.js 18 is end\-of\-life | package\.json:5 | | medium | Node\.js 19 is end\-of\-life | package\.json:5 | | medium | Node\.js 20 is end\-of\-life | package\.json:5 | ### Introduced _None._ ### Suggested commands ``` git diff --check # Validate the edited workflow diff. node --version # Confirm the active Node.js runtime is supported. npm run build --if-present # Verify imports, types, and the build remain valid. npm run test --if-present # Run HTTP behavior and repository tests when defined. ``` Reproduce with: `offing receipt "$demo" --before /tmp/offing-demo-before.json` ```` ## What each section means | Part | Meaning | | --- | --- | | `` | Marker so a tool can find or replace the block in a pull request body. | | Heading verdict | Derived only from the counts: no findings, all resolved, findings remain, partially resolved, or new findings introduced. Introduced wins when any new finding appears. | | Counts table | How many findings landed in each list. | | Resolved | Present in the before scan and absent after. Matched by finding id. | | Remaining | Present in both scans. | | Introduced | Present only in the after scan. | | Suggested commands | Verification commands named by the rules the comparison touched. Printed for the reader; Offing never runs them. | | Reproduce line | The `offing receipt` command that rebuilds this receipt, with the path and `--before` scan it was built from, relative to the working directory. Shows `` and `` when the caller does not know them, as the MCP `receipt_markdown` output does. | When a list is empty, the section shows `_None._`. When a list has more than five findings, the Markdown collapses it into a `
` block. ## JSON contract `--format json` prints the same comparison as `offing.receipt.v1`. The schema is [`schemas/receipt.v1.schema.json`](../schemas/receipt.v1.schema.json). Each finding entry keeps `id`, `rule`, `title`, `severity`, repository-relative `path`, and optional `line` — enough to join the finding in a `scan --json` report, not the whole finding. `data.before` and `data.after` record the lifecycle data each side used, or `null` when a scan did not. The same two scans always produce the same receipt: comparison is by finding id, lists are sorted, and the builder reads no clock and no environment. ## Safety `receipt` reads the `--before` scan file and re-scans the path locally. It does not use the network, does not evaluate repository code, and does not execute suggested commands. A migration is complete only after the project's own tests pass and a fresh scan no longer reports the findings you meant to clear. --- Source: https://velella.sh/offing/SHARE_REPORT/ # Share a lifecycle report Produce a terminal timeline or a self-contained HTML file from a local scan, then send the file yourself. Offing never uploads the report, the repository, or the scan result. Every command below runs offline against files on disk. The examples use the deliberately outdated project at `examples/demo` in this repository; point them at any path you want to scan. ## Terminal timeline ```bash # From a clone of Offing: npm ci npm run build demo="$(node dist/tools/examples.js demo)" npx @velella/offing forecast --timeline "$demo" ``` A real run on 2026-10-30 printed: ```text Forecast from 2026-10-30 to 2027-10-30 (12 months) 2026-10 | 2026-11 |############################################################# 1 event 2026-12 | 2027-01 | 2027-02 | 2027-03 | 2027-04 |############################################################# 1 event 2027-05 | 2027-06 |############################################################# 1 event 2027-07 | 2027-08 | 2027-09 | 2027-10 | ``` Each bar is a month that has at least one dated lifecycle event in the default twelve-month window. Use `--months ` to widen or shorten the window, or drop `--timeline` for a plain list. Add `--json` when a tool needs the structured forecast instead of the drawing. ## HTML report ```bash npx @velella/offing scan --format html "$demo" > report.html ``` Open `report.html` in a browser, attach it to an issue, or paste it into chat. The file is self-contained: styles and the embedded JSON scan result travel with it. Nothing leaves your machine unless you choose to send the file. ### What the report contains - The repository path Offing scanned and when the scan ran. - A severity summary and every finding: title, severity, exact file path and line, rule id, replacement, and migration notes. - A treemap that sizes each directory and file by its severity-weighted findings (critical 8, high 4, medium 2, low 1, info 0.5) and colors it by its highest severity; each rectangle links to that file's findings below and shows its path and counts on hover or focus. - Authoritative source links for each finding (for example the Node.js release schedule or a GitHub changelog entry). - A "Next 12 months" forecast chart of dated end-of-life events in that window. - The full scan document in a `