Assure GitHub Action
Assure checks the software your code runs on. From your own GitHub Actions workflow it reads your PostgreSQL server or your site's HTTP responses and tells you, obligation by obligation, what holds, what is disproven, and what it could not establish and why. The check reads green only when nothing it examined is disproven, and the first line of every result says how much it could see.
The Action collects facts in your runner. The check runs on Symbolia's server at api.symbolia.ai. The verdict comes back into your runner and, on a pull request, onto the pull request as annotations.
Install. Give this to your agent: skills/assure/SKILL.md. It knows the supported routes, what the Action needs from your repository, and where it stops.
Assure returns information. What to change is your call; your agents can act on the result directly — every reading carries the obligation, the machine, the evidence and the reason.
Served today: five profiles. postgresql-observed-baseline: intent-free, from a connection or an artefacts directory. postgresql-declared-model: an artefacts directory (raw/declaration.json) or a connection with .assure/declaration.json. http-observed-baseline: an artefacts folder holding an endpoint manifest and captured response heads; the Action produces the scope binding and passes the identity key by pipe. mysql-declared-model: a MySQL server, from a mysql:// connection plus your declaration file (declaration), or an artefacts directory holding raw/submission.json; reports are withheld (profile_unqualified) until the writer is qualified for MySQL. python-auth-declared-model: a Python (Flask) repository, free, from the checkout in one step; the Action proposes a drafted declaration in a pull request the merge accepts; FastAPI, Django and standard-library http.server repositories with your declaration.
Known-vulnerability scanners check your code against a list of what has broken before. Assure checks whether the way your code meets the foundational software it runs on stays inside a regime that can be shown safe and reliable, and says exactly what it could and could not establish.
Assure is the check that reads green only when nothing it examined is disproven. It tells you the bounds of what it has established, and why it matters. In an age where machines generate code at a volume nobody can read, Assure shows that each change maintains its baseline security and reliability, and stays true to any formalised requirements.
postgresql-observed-baseline: your server against what is observed and pinned public baselines; you declare nothing. From a connection, or from an artefacts directory the collector wrote.postgresql-declared-model: your server against a declaration you write (raw/declaration.json). When the check carried yourrequirements.md, the verdict also carriesrequirements: one row per requirement line, with itsid, itssentenceas you wrote it, and one of three states.http-observed-baseline: the response heads you captured for the endpoints you select, offline; the Action produces the scope binding itself.mysql-declared-model: a MySQL server, from amysql://connection and your declaration file (declaration), or an artefacts directory holdingraw/submission.json. Seven machines read it, M1 to M4, M6, M7 and M8, and the check contacts no server. A report is withheld withprofile_unqualifieduntil the report writer is qualified for MySQL.
PostgreSQL majors 14 to 18 are supported. Real-run evidence exists for PostgreSQL 17 and 18.
What a check claims
A green check claims this: within the bounds its first line states, nothing the rules Symbolia holds could check was disproven; every obligation that could not be established is named with what would establish it. The claim covers those rules and those observations, and nothing beyond them. It does not say the database is fit for its purpose.
A run that read nothing never goes green: it is yellow and exits 3. When no obligation on the machines read holds, the first line says "nothing could be established". It never means "no issues".
SCOPE.md states the claim and its limits in full.
What leaves your runner
The Action sends catalog facts (roles, memberships, object ACLs, policies, functions, schemas, settings and similar) and, when the runner can read them, your configuration files. Before anything is sent, the collector withholds every secret-bearing value whole, every comment in your configuration files is replaced with # <withheld comment>, and every string literal in a policy expression is replaced with '<withheld literal N>' (except a setting name written as a standard-quoted 'name' directly after current_setting(, which is sent as written). The collector never reads table contents, password hashes, key material or other sessions' statements. Your connection string and password stay in your runner. The Action sends only the files the profile reads, each within its size limit. The server deletes the collected facts when the check ends and keeps the verdict for 30 days; on the Premium tier a sealed copy of the collected facts is kept for the formal shadow (see DATA.md). Both data-sending inputs are opt-in: with no inputs the Action asks for no report, so no verdict record goes to a third-party model, and uploads no requirements.md; set report: true to ask for the report, and name the file in the requirements input (for example requirements: requirements.md) to send its text. In mode: api the Action sends the text of the file you name (within 65536 bytes, 256 lines and 8192 bytes per line), your repository name and the commit (on a pull request, its head commit); the server keeps them with the check for 30 days. Setting requirements to none stops the requirements text only: the repository and the commit are still sent and kept with the check for 30 days. DATA.md lists every item.
Quickstart
You need:
- the permission
id-token: writeon the job that runs Assure, so the Action signs in with your workflow's GitHub identity and you store no key; or, instead, - an Assure API key from Symbolia (it starts with
asr_), stored as the repository secretASSURE_API_KEY; - outbound HTTPS from the runner to
api.symbolia.ai(port 443); - Python 3.14 on the runner and, to collect over a connection, the PostgreSQL client (
psql); - a collection role with read access to settings and statistics only, if you collect over a connection.
Rotate a stored key with POST /v1/keys/rotate during its rotation window; a GitHub sign-in key lasts at most an hour and never rotates, and your Symbolia contact revokes stored keys.
The smallest job, for files you collected and committed in collected/; put it under jobs: in your workflow:
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- name: Check out the repository
uses: actions/checkout@<full commit sha of the release you trust>
- name: Set up Python 3.14
uses: actions/setup-python@<full commit sha of the release you trust>
with:
python-version: "3.14"
- name: Assure check
uses: Symbolia-Assurance/assure-action@beta
with:
artefacts: collectedKeep contents: read beside id-token: write: naming one permission sets the others to none. Your Assure account is your GitHub owner, on the free plan the first time it signs in, and each run gets a key that lives for that run only.
On a pull_request event the Action also posts the result as one comment on the pull request, "Assure: N rules broken by this change", with what is broken and the fix, what still holds, and what was not checked yet. It is rendered from the verdict, with no model. Each later run on the pull request updates the same comment. The Action posts it with the workflow's own token, so the job needs pull-requests: write under permissions: (keep contents: read beside it); the jobs on this page grant it. Without that permission the comment is not posted, the job log carries one notice naming the line to add, and the verdict, the job summary and the exit code are unchanged.
With an API key instead, pass it from a secret. The declared profile reads collected files. Put the collector's output in a directory that holds raw/, add your declaration as raw/declaration.json, commit the directory, then add these steps. The checkout step puts the directory on the runner; for a directory made by an earlier job, fetch it with actions/download-artifact instead.
- name: Check out the repository
uses: actions/checkout@<full commit sha of the release you trust>
- name: Assure check
id: assure
uses: Symbolia-Assurance/assure-action@beta
with:
api-key: ${{ secrets.ASSURE_API_KEY }}
profile: postgresql-declared-model
artefacts: collected
fail-on: failsUse @beta on the Symbolia-Assurance/assure-action line. It moves to each new release when Symbolia publishes, so you get fixes without editing your workflow. A moving ref runs what Symbolia last published; every release is first served by production and reviewed before merge, and each release also has an immutable tag v0.1.0-beta.N.
To freeze, pin a full 40-character commit SHA of Symbolia-Assurance/assure-action in place of @beta; in a clone, git -C assure-action rev-parse HEAD prints it. Pin every third-party action to the full commit SHA of the release you trust. The source: line of skills/assure/VERSION names another commit: the Assure source commit the Action was built from. It is not a commit of the Action repository, so it never goes in a uses: line. The pin: line of skills/assure/VERSION says the same: the build cannot know which Action commit you pin, so it names none.
SOURCE-CONTRACT.json at the top of this repository records the digest of the Assure source the tree was built from, the files that built it and the digest of every file it ships, with its details in SOURCE-CONTRACT-DETAIL.json; DIST-MANIFEST.json lists each shipped file with its digest and the reason it ships.
The full quickstart covers the collection role, collecting over a connection, managed PostgreSQL, running the collector yourself, complete workflow files that run on pull requests and on pushes to your default branch, the HTTP captures, and every typed outcome.
Inputs
| Input | Default | Meaning |
|---|---|---|
api-key | none | Your Assure API key. Pass it from a secret. Leave it out when the job has id-token: write under permissions:: the Action then signs in with the workflow's GitHub identity. |
api-url | https://api.symbolia.ai | The API address. The published API is https:// only. Plain http:// is accepted only for a loopback host (127.0.0.1, ::1 or localhost), for a local test server. |
mode | api | api: the check runs on the Assure API. |
profile | postgresql-observed-baseline | The checker profile to run. |
connection | none | A libpq connection string or URI. Pass it from a secret. |
artefacts | none | A directory of collected files. Use this or connection, never both. |
collection-role | the user in connection | The role's name inside the database. Set it when a connection pooler's login name differs. |
collection-privileges | pg_read_all_settings,pg_read_all_stats | The roles you granted to the collection role. |
data-dir | none | Where the runner can read the server's data directory. Used with connection. |
config-dirs | none | Directories outside the data directory that hold configuration files. Used with connection. |
fail-on | fails | Which statuses fail the job, or never. |
requirements | unset | A requirements file to send with the check, mode: api only. The upload is opt-in: empty sends no requirements text and prints no notice, even with a requirements.md at the repository root; name it (requirements: requirements.md) to send it. A file you name that cannot be sent stops the run with exit 2. none sends no requirements text and prints no notice (a file named none is then reached as ./none). Only none in lower case is the sentinel: NONE names a file, and on a file system that ignores case, such as the default on macOS, a file named none answers to it. mode: local does not read requirements. The repository and the commit (on a pull request, its head commit) are sent on every mode: api run, with none too. |
report | unset | The report is opt-in: report: true in mode: api asks for it; unset or report: false leaves it off. Local mode never asks for one. Reports are written for postgresql-declared-model verdicts today; for other profiles the report is withheld and says so. |
output | assure-verdict.json | Where to write the verdict file. |
python | python3 | The Python 3.14 interpreter to use. |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Green: nothing disproven within the bounds the first line states; or red with exit 0 because your fail-on leaves the disproven status out (or is never, which still exits 1 on a prove-class obligation that is not proven), and the first line says so. |
| 1 | Red: something is disproven and your fail-on stops on it, a prove-class obligation is not proven, a status you chose to stop on was read, or under strict (fail-on: unresolved-security) an obligation marked security: true is unresolved. |
| 2 | Red: bad input, or an API key that is missing, unknown or revoked. Fix the inputs, the files or the key. With no key, the job needs id-token: write under permissions:. |
| 3 | Yellow: nothing could be read at all, or a typed outcome stopped the check. The failure file holds the reason and what to do. |
The colour is green, red or yellow. A fails reading on a read machine is red whatever fail-on says. A status you name in fail-on also turns the reading red and sets the exit. fail-on: unresolved-security is the strict preset; it is never the default, and while a profile marks no obligation it changes nothing ("strict: no obligations marked").
The Action sets these outputs: verdict-path, outcome, exit-code, colour, not-established (how many obligations could not be established), and with the report on also report-status, report-url, report-cost-usd, report-charge-usd and report-allowance-remaining. On postgresql-observed-baseline, when the Action writes assure-requirements-draft.md, it also sets requirements-draft, that file's path (empty when nothing was drafted).
What is never charged
Two modes of the Action charge nothing. mode: lint collects and checks your files in the runner, then sends them to POST /v1/lint, which lists the problems it finds; nothing is charged, but it counts against your rate limits. mode: local runs the checker in the runner and sends nothing; it runs only from the Assure source tree, and this published Action refuses it with bad_input. From the source tree it also runs as one command, with no INPUT_* variables set: python3 -m action.entry --mode local --artefacts DIR --profile P [--fail-on ...] [--out FILE]. Each --<input> VALUE (or --<input>=VALUE) sets the input of that name (--out is output); an unknown flag, or one with no value, stops with exit 2 naming its INPUT_ form, and api-key, connection and identity-key are read from the environment only. Deliberate feedback (the feedback input) is free on every plan. A check in mode: api and its report can be charged: what a check costs is in section 7 of the full quickstart, and what a report costs in The report.
Where the verdict goes
- The verdict file: JSON at the
outputpath, schemaassure.serve.verdict/v1, with every reading and its premises. VERDICTS.md explains how to read it. - The job summary: the first line (colour, coverage, bounds, finding), one sentence on what it means, then counts by status and one line per obligation (on
postgresql-observed-baseline, an M6deviatesline is followed by the functions it read, one entry per function with its login roles), on the run page. - Annotations: the first line is the first notice; each
failsreading is an error and eachdeviatesreading is a warning, up to 50. - The ignored line: one notice, "N name(s) not read by profile P: ...", lists the names under
artefactsthe profile does not read. A file that matches only an optional input and is over that input's byte cap is left out of the bundle and listed there as "NAME: over CAP bytes (optional input)" (a sidecar besideraw/as "NAME beside raw/: ..."); the check then runs on the rest, and what comes back depends on the profile.postgresql-observed-baseline: a droppedraw/pg_hba.conf(cap 1 MiB) gives a verdict, charged, whose first line counts the refused machines; M2, M3 and M4 readrepresentation: pg_hba.conf absent from raw, so nothing about authentication rules is established.postgresql-declared-model: after a droppedraw/pg_hba.conf, the check still runs on the rest against your declaration with no observed HBA rules; M4 and M8 name the missing source in theirsource_not_visiblegaps and readmissing_premise, while the finalverdictorno_verdictand any charge follow the remaining inputs and normal outcome policy.http-observed-baseline: a droppedraw/response-heads/endpoint-NNN.txt(cap 64 KiB) that the sidecar still lists is refused as an input fault (UNEXPECTED-ARTEFACT), not read around.python-auth-declared-model: a suppliedraw/REPOSITORY.jsonis kept unchanged. If it still lists a dropped repository file, the step stops withprofile_refused(input_pin_differs:file_absent, naming the file; exit 3, not charged), and the ignored line is not printed because no verdict came back. Removing its file row without recomputing that supplied manifest's tree does not repair its pin. When no manifest is supplied inmode: api, the generated manifest lists only the retained files, so the checked repository excludes the dropped file; the remaining repository and declaration decide the outcome and exit under normal charge policy, and a returned verdict prints the ignored notice. Keep each repository file under the profile's cap (512 KiB for python-auth) or out of the repository you declare.mysql-declared-modelhas no optional input. A required input over its cap still stops the run withoversize_input(exit 2).
When the Action cannot produce a verdict, it writes a failure file at the output path instead, with the outcome, the reason and what to do.
Lines the Action prints
Three lines the Action prints to the job log, with their placeholders, so a workflow step or a test can match them without reading the source. action/entry.py exports each format as the constant named in the first column.
| Line | Format | Placeholders |
|---|---|---|
Names not read (NOTICE_LINE) | ::notice title=Assure::<count> name(s) not read by profile <profile>: <names> | <count> is how many collected names the profile does not read; <profile> is your profile input; <names> is the first 20 of them, comma separated, then and N more when there are more. Printed only when at least one name was not read. |
Lint summary (LINT_LINE) | Assure: lint: <reading>; <findings> finding(s); nothing was charged; policy exit <exit>; <output> | <reading> is ready for a check or fix these before a check; <findings> is the number of findings; <exit> is 0 or 2; <output> is the output path the lint record was written to. The last line of a mode: lint run. |
Withheld (WITHHELD_LINE) | Assure: <withheld> | <withheld> is the sentence of counts starting Withheld in the runner before anything was sent:, followed by one more sentence starting Also: when expressions, unparseable lines or not-observed lines were also withheld; the same text as the job summary's "Withheld in the runner" section. Printed before anything is sent. |
The order each mode prints these lines in, as entry.ORDER in action/entry.py names them (tests/serving/test_issue_1960_accept.py pins it against a run in each mode). Each is printed only when its condition above holds; other lines, such as the masks, the annotations and the verdict line, can come before, between and after them.
mode: api:WITHHELD_LINE,NOTICE_LINEmode: local:WITHHELD_LINE,NOTICE_LINEmode: lint:WITHHELD_LINE,NOTICE_LINE,LINT_LINE
The text after title=Assure:: in the notice and the <output> path in the lint summary are printed as workflow-command data: a % in them is printed as %25 and a newline as %0A, and the notice's text is cut to 300 characters ending in … when a long name list exceeds that.
Read more
- Beta tester brief: one page for your first run
- One-page first run
- Full quickstart
- What a verdict claims
- Reading a verdict
- What is collected and where it goes