Skip to main content

Policy CI

Treat HaltSQL policies like code. Every change is validated, linted and tested against policy test suites before it merges. The main branch then produces a signed bundle that servers load.

HaltSQL ships three integrations:

IntegrationFileFor
GitHub Actionaction.ymlGitHub Actions workflows
GitLab CI templatesci/gitlab/haltsql.gitlab-ci.ymlGitLab pipelines (include: + extends:)
pre-commit hooks.pre-commit-hooks.yamlLocal checks before each commit

All three run the same CLI commands: haltsql validate, haltsql lint-policy, haltsql test --json and, optionally, haltsql bundle build/sign. A pipeline always agrees with what you see locally.

Ready-made examples are in examples/ci/.

GitHub Action​

# .github/workflows/policy-ci.yml
on: [pull_request]
permissions:
contents: read
pull-requests: write # only for comment-summary
jobs:
policies:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: HaltSQL/HaltSQL@v0.2.0
with:
version: v0.2.0
policies: policies/**/*.yaml
suites: tests
fail-on-warnings: "true"
comment-summary: "true"

What it does​

  1. Installs haltsql. It downloads the release archive for the runner (linux-amd64, linux-arm64 or darwin-arm64) and verifies it against the release's SHA256SUMS. The archive must be listed exactly once and its checksum must match. A mismatch fails the job and never falls back to another source. With verify-signature: true (and cosign on PATH), it also verifies SHA256SUMS with cosign keyless signatures. On other platforms, or if the download itself fails, it falls back to the container image ghcr.io/haltsql/haltsql:<version>, which needs Docker. The tag is resolved to a digest once, and the image is always run by that digest. With verify-signature: true, a failed download is fatal (there is no container fallback), and a container image (install-method: docker or an unsupported platform) is used only after cosign verify of its digest passes with the same identity constraints as the release.

  2. Validates every policy and suite file (haltsql validate --json).

  3. Lints every policy (haltsql lint-policy --json).

  4. Tests the suites (haltsql test --json).

  5. Writes a job summary ($GITHUB_STEP_SUMMARY) with these sections:

    • a check overview;
    • per-suite pass/fail counts;
    • failed cases with their reason codes;
    • a collapsible table of denied cases per suite, showing whether the denial was expected, the reason codes and the first reason message;
    • validation errors and lint warnings.

    With comment-summary: true on pull requests, the same Markdown is posted as one PR comment that is updated in place. With a read-only token (for example on fork PRs) this only produces a warning.

  6. Optionally builds a bundle (bundle: true). It runs only after all the checks pass. The bundle is signed when signing-key is set, verified against public-keys when given, and uploaded as the haltsql-policy-bundle artifact.

All case names and messages are HTML-escaped in the summary, so a crafted test name in a pull request cannot inject Markdown or HTML. Inputs reach the scripts only as environment variables, never interpolated into shell code.

Inputs​

InputDefaultMeaning
versionlatestRelease to install (latest, v0.2.0 or 0.2.0). Pin it.
policiespoliciesPolicy files, directories or globs, whitespace or newline separated. ** matches any depth, including none. Directories are searched for *.yaml, *.yml and *.json.
suites(none)Test-suite files, directories or globs. Directories are searched for *.suite.yaml, *.suite.yml and *.suite.json.
fail-on-warningsfalseFail when lint-policy reports warnings.
comment-summaryfalsePost or update a PR comment with the summary.
max-denied50Denied cases listed per suite in the summary.
working-directory.Base directory for the globs.
install-methodautoauto, release (never use the container) or docker.
verify-signaturefalseAlso verify the release (or the container image) with cosign. Disables the container fallback after a download failure.
haltsql-path(none)Use an existing binary and skip the install.
bundlefalseBuild a bundle from policies after the checks pass.
bundle-namerepository nameBundle metadata.name.
bundle-versionfirst 12 characters of the commit SHABundle metadata.version.
catalogs(none)Catalog snapshots to ship ([NAME=]FILE, whitespace separated).
signing-key(none)PEM Ed25519 private key; pass ${{ secrets.HALTSQL_SIGNING_KEY }}.
public-keys(none)Public keys the signed bundle must verify against.
upload-bundletrueUpload the bundle and signature as a workflow artifact.
github-token${{ github.token }}Used for the PR comment and for resolving latest.

Outputs​

result (pass or fail), passed, failed, warnings, summary-file, haltsql-path, bundle-path, signature-path.

Signing in CI safely​

Put HALTSQL_SIGNING_KEY in a protected environment that requires reviewers, and run the bundle job only on the default branch (see examples/ci/github-policy-ci.yml). The key reaches haltsql through an environment variable (--key-env), is never written to disk, and is never printed.

GitLab CI​

# .gitlab-ci.yml
include:
- remote: https://raw.githubusercontent.com/HaltSQL/HaltSQL/v0.2.0/ci/gitlab/haltsql.gitlab-ci.yml

variables:
HALTSQL_VERSION: v0.2.0
HALTSQL_POLICIES: policies
HALTSQL_SUITES: tests

policy-check:
extends: .haltsql-check

policy-bundle:
extends: .haltsql-bundle
needs: [policy-check]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The template only defines hidden jobs, so including it adds no jobs until you extend them.

  • .haltsql-base: an alpine image that installs the static Linux release binary after checking it against SHA256SUMS, with the same fail-closed rules as the action. HALTSQL_RELEASE_BASE_URL points it at an https mirror.
  • .haltsql-check: runs validate, lint (with HALTSQL_FAIL_ON_WARNINGS) and test. It writes haltsql-ci/test.json and a JUnit report (haltsql-ci/junit.xml), so merge requests show per-case results. Denied cases list their reason codes in the JUnit system-out.
  • .haltsql-bundle: builds the bundle with the project URL and commit as its source. It signs the bundle when the masked, protected variable HALTSQL_SIGNING_KEY is set and verifies it against HALTSQL_PUBLIC_KEYS. The bundle is kept as a job artifact.

Pin the include: to a release tag, never a branch.

Publishing to an OCI registry​

Servers with a bundle_source poll a registry for new bundles, so the last CI step is haltsql bundle push. Push only after the bundle is built, signed and verified, and only from the default branch.

Use a version that increases. Servers refuse a bundle whose metadata.version is not strictly greater than the one they run (rollback protection). The action's default bundle-version is the commit SHA, which has no order, so set it explicitly when servers poll the registry. Good choices are 1.0.${{ github.run_number }}, a date with the run number such as 2026.11.07.42, or your release tag.

GitHub Actions (GHCR)​

# .github/workflows/policy-publish.yml
on:
push:
branches: [main]
permissions:
contents: read
packages: write # push to ghcr.io
jobs:
publish:
runs-on: ubuntu-24.04
environment: policy-signing # holds HALTSQL_SIGNING_KEY, requires reviewers
steps:
- uses: actions/checkout@v4
- id: haltsql
uses: HaltSQL/HaltSQL@v0.2.0
with:
version: v0.2.0
policies: policies/**/*.yaml
suites: tests
bundle: "true"
bundle-name: prod-policies
bundle-version: 1.0.${{ github.run_number }}
signing-key: ${{ secrets.HALTSQL_SIGNING_KEY }}
public-keys: keys/policy-signing.pub
- name: Push the bundle
env:
HALTSQL: ${{ steps.haltsql.outputs.haltsql-path }}
BUNDLE: ${{ steps.haltsql.outputs.bundle-path }}
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
REF: ghcr.io/${{ github.repository_owner }}/haltsql-policies:prod
run: |
"$HALTSQL" bundle push --oci "$REF" "$BUNDLE" \
--public-key keys/policy-signing.pub \
--username "$GITHUB_ACTOR" --password-env REGISTRY_PASSWORD --json \
| tee push.json
echo "Pinned: $(jq -r .pinned push.json)" >> "$GITHUB_STEP_SUMMARY"

The repository name in a reference must be lower case. If your owner name has capitals, lower-case it first.

GitLab CI (GitLab container registry)​

policy-bundle:
extends: .haltsql-bundle
needs: [policy-check]
variables:
HALTSQL_BUNDLE_NAME: prod-policies
HALTSQL_BUNDLE_VERSION: "1.0.$CI_PIPELINE_IID"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

policy-push:
extends: .haltsql-base
stage: deploy
needs: [policy-bundle] # brings haltsql-ci/prod-policies.haltsqlbundle{,.sig}
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- >
haltsql bundle push --oci "$CI_REGISTRY_IMAGE/haltsql-policies:prod"
haltsql-ci/prod-policies.haltsqlbundle --public-key keys/policy-signing.pub
--username "$CI_REGISTRY_USER" --password-env CI_REGISTRY_PASSWORD

.haltsql-bundle writes haltsql-ci/<HALTSQL_BUNDLE_NAME>.haltsqlbundle and its .sig, and keeps them as job artifacts. The push job picks them up through needs:.

What push checks and prints​

  • It refuses a bundle that does not load or a malformed signature file. With --public-key, it also refuses a signature that does not verify, so CI never publishes something servers would reject.
  • Passwords and tokens are read from environment variables (--password-env, --token-env) and never appear in arguments or output.
  • It prints the digest-pinned reference (pinned in --json). Deploy that when you want an immutable rollout, and use the tag when servers should follow new versions.
  • The artifact is the layout oras push --artifact-type application/vnd.haltsql.bundle.v1 produces (see OCI artifact layout), so existing oras-based pipelines keep working and either tool can read the other's artifacts.

For S3 or HTTPS sources, upload prod.haltsqlbundle.sig before prod.haltsqlbundle, for example with aws s3 cp in that order.

pre-commit​

# .pre-commit-config.yaml
repos:
- repo: https://github.com/HaltSQL/HaltSQL
rev: v0.2.0
hooks:
- id: haltsql-validate
- id: haltsql-lint-policy
- id: haltsql-test
args: [tests]

The hooks use the haltsql binary on PATH (language: system):

HookRunsFiles
haltsql-validatehaltsql validate FILE...YAML/JSON under policies/, policy/, haltsql/, tests/, suites/, catalog(s)/
haltsql-lint-policyhaltsql lint-policy per changed policyYAML/JSON under policies/ or policy/, excluding suites
haltsql-testhaltsql test tests (once, not per file)runs when any of those files change

If your layout differs, override files: or args:.

Testing the integrations​

The action's shell steps and the GitLab job scripts are tested locally against a locally built binary. The tests make no network calls.

dune build ./bin/haltsql/main.exe
test/bundle/action/test_action.sh _build/default/bin/haltsql/main.exe
PYTHON=python3 test/bundle/action/test_gitlab.sh _build/default/bin/haltsql/main.exe # needs PyYAML
shellcheck -x .github/actions/policy-ci/*.sh test/bundle/action/*.sh

These tests cover glob expansion (including **), checksum verification (match, mismatch, unlisted, duplicated), install argument validation, the summary Markdown (exact rows, escaping, denied-case details, truncation), exit codes, fail-on-warnings, the GitHub outputs, bundle build/sign/verify with provenance, that no key is ever logged, and JUnit well-formedness and escaping.

The OCI, S3 and HTTPS bundle sources and haltsql bundle push/pull are tested against real servers in Docker (registry:2 with anonymous, htpasswd and token auth via cesanta/docker_auth, MinIO, nginx over HTTPS, and the oras CLI). The tests skip with a message when Docker is unavailable:

dune test test/bundle_source # HALTSQL_SKIP_DOCKER=1 runs only the unit tests