Pushing candidates from CI

Polling only sees the published ref. To show a decision record while it is still under review, for example on a merge-request branch, a CI job can push it to Adriano as a candidate. Reviewers can then accept or reject it before it is published. When the identical file later appears on the published ref, the next poll recognises it and the earlier decision carries over.

Push works for every source type, folders included. Any job or script that can send an HTTP request can push.

Pushes never change published records and never touch the approval history.

Setting up a registration for pushes

  1. Allow pushes and name the token variables in the catalog file:

    - id: central
      # ... other fields ...
      intake: [poll, push]
      push_tokens_env: [ADRIANO_PUSH_TOKEN_PLATFORM]
    
  2. Generate a token and add it to the container environment (ADRIANO_BACKEND_ENV_FILE, .env by default):

    echo "ADRIANO_PUSH_TOKEN_PLATFORM=$(openssl rand -hex 32)" >> .env
    
  3. Recreate the container: docker compose up -d adriano (Kubernetes: update the Secret, then kubectl rollout restart deployment/adriano).

  4. Check with adriano check-config: there must be no warning push token env var ... is not set.

  5. Store the same token as a masked, protected CI variable in the projects (or group) that push, for example ADRIANO_PUSH_TOKEN.

A push token is valid for its own registration only and gives no read access to the catalog.

Rotating a token

List two variable names, so old and new token are valid at the same time:

  1. Add ADRIANO_PUSH_TOKEN_PLATFORM_NEXT to push_tokens_env and to the environment, then recreate the container.

  2. Switch the CI variable to the new token.

  3. Remove the old name and its variable, then recreate the container again.

The push request

PUT /api/v1/candidates with the push token as bearer token and a JSON body:

source_idrequired

The registration id.

projectrequired

The project path, as Adriano knows it (for GitLab, the full project path; for a folder source, the name of the subfolder below root). It must be inside the registration’s scope.

adr_uuidrequired

The uuid from the record’s frontmatter. It must match the content.

refrequired

The ref the content comes from, for example a branch. It may not be the registration’s published_ref.

contentrequired

The complete Markdown file, at most 1 MiB.

pathoptional

The file’s path inside the project, ending in .md. Send it, so the candidate shows the right location and the title or date can be taken from the file name.

delivery_idoptional

A unique ID of this CI run, such as the pipeline ID. A retried job with the same ID gets the earlier answer instead of a second submission.

authoroptional

The author’s email address. Send it if the approval policy forbids self-approval: it is the only way Adriano can tell that a reviewer is the author.

PUT creates the candidate or replaces the previous content for the same project, record and ref. POST with the same body only creates, and answers 409 candidate_exists if the candidate is already there.

Example script

The source tree ships samples/push-candidate.sh, which builds the JSON body correctly (including escaping) and sends it:

export ADRIANO_API_BASE_URL=https://adriano.example.com
export ADRIANO_PUSH_TOKEN_CENTRAL=<push token>
samples/push-candidate.sh \
  doc/decisions/2026-01-05-use-redis-for-caching.md \
  platform feature/redis-cache central "$CI_PIPELINE_ID" \
  doc/decisions/2026-01-05-use-redis-for-caching.md

Its arguments are the file, project, ref, and optionally the registration ID, delivery ID and path. It reads the token from ADRIANO_PUSH_TOKEN_<REGISTRATION ID> (upper case); a registration ID containing - or . therefore needs your own script.

Example: GitLab CI job

The same request from a GitLab CI job. Other CI systems work the same way: send the same body, or call the example script above.

push-adr-candidate:
  stage: review
  rules:
    - if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH'
      changes: [doc/decisions/**/*.md]
  script:
    - |
      python3 - <<'PY'
      import json, os, pathlib, urllib.request

      path = pathlib.Path("doc/decisions/2026-01-05-use-redis-for-caching.md")
      body = json.dumps({
          "source_id": "gitlab-platform",
          "project": os.environ["CI_PROJECT_PATH"],
          "adr_uuid": "b87a5fc5-a48f-49cb-a029-ac1f7c56c717",
          "ref": os.environ["CI_COMMIT_REF_NAME"],
          "content": path.read_text(encoding="utf-8"),
          "path": str(path),
          "delivery_id": os.environ["CI_PIPELINE_ID"],
          "author": os.environ["GITLAB_USER_EMAIL"],
      }).encode("utf-8")
      req = urllib.request.Request(
          "https://adriano.example.com/api/v1/candidates",
          data=body,
          method="PUT",
          headers={
              "Authorization": f"Bearer {os.environ['ADRIANO_PUSH_TOKEN']}",
              "Content-Type": "application/json",
          },
      )
      print(urllib.request.urlopen(req, timeout=10).read().decode())
      PY

Pushing an erratum

An erratum (a correction to a published decision) is pushed the same way. Use the erratum file’s own uuid as adr_uuid, not the uuid of the decision it corrects, and send its real path (for example under errata/), so the candidate sits where the erratum will be once published.

What the answer means

201

A new candidate was created.

200

The candidate already existed and was replaced or was unchanged; with "deduplicated": true, the delivery_id was seen before and nothing was submitted again.

A successful answer looks like this:

{"record_id": "0b6c2f4e-...", "digest": "sha256-v1:a0b7e2...",
 "created": true, "updated": false, "deduplicated": false,
 "issues": [], "catalog_path": "/records/0b6c2f4e-..."}

catalog_path is the record’s page in the web UI. issues lists problems in the file that do not block the push, such as a broken link; reviewers see them too.

Errors carry a code and a hint:

401 not_authenticated

No token was sent.

401 invalid_push_token

The token is not a push token of this registration, or the registration is unknown, disabled or has no token set.

403 push_disabled

The registration does not have push in intake.

403 project_out_of_scope

The project is not in the registration’s scope.

409 candidate_exists

POST only: the candidate already exists; use PUT.

413 payload_too_large, 413 content_too_large

The request or the file is too large (file limit 1 MiB).

422 invalid_request

The body is not valid JSON, misses a field or has an unknown one.

422 published_ref_not_allowed

ref is the published ref; candidates come from other refs.

422 invalid_document

The file has no usable frontmatter or uuid; the answer lists the problems.

422 uuid_mismatch

adr_uuid differs from the uuid in the file.

422 bad_encoding

The content is not valid text.

Note

For a gitlab source with published_ref: "@project-default", Adriano does not know each project’s default branch when a push arrives, so a push from the default branch is accepted as a candidate. Limit the CI job to other branches, as in the example above.