Guides

Guides · Part 7 of 8

Anchor releases from CI

Every release you ship is a set of bytes: tarballs, wheels, container images, a range of commits. Anchoring a Label 309 Proof of Existence for those bytes turns "trust our word that this build is what we published" into "here is a Cardano transaction that witnesses it." You hash each artifact into a leaf, fold the leaves into a single Merkle root, and publish that one root on chain under metadata label 309. From then on, anyone holding the transaction reference can prove an artifact existed on or before its block time, from the public chain alone, with no account and no trust in your pipeline or your vendor.

The artifact bytes never leave the runner. The CLI hashes locally and publishes only digests, so this is safe for private repositories and closed-source builds: what goes on chain is a fixed-length hash, never your code.

The core tool: cardanowall attest

Everything on this page runs through one command from the gateway-agnostic cardanowall CLI (crate cardanowall-cli on crates.io, with prebuilt binaries on the label-309-cli releases). attest is the CI-shaped entry point: it hashes your inputs, quotes and publishes one record through a gateway, and waits for the lifecycle state you ask for.

Point it at any Label 309 gateway with a base URL and a publish-scoped API key, then hand it something to hash:

export CARDANOWALL_API_KEY="…"   # a publish-scoped key from your gateway

cardanowall attest \
  --paths 'dist/*' \
  --base-url https://your-gateway.example/api/v1 \
  --wait confirmed \
  --receipt-out poe-receipt.json

--base-url and --api-key also read from CARDANOWALL_BASE_URL and CARDANOWALL_API_KEY, so both drop out of the command in CI where those are set from your secret store.

Three ways to choose what you anchor

Set exactly one input; the mode follows from it.

Files. --paths takes a literal path or a glob, repeatable. Each leaf is the SHA-256 of a file's bytes. The selection is deduplicated and byte-sorted by normalized relative path, so the same working tree always produces the same root regardless of the order the shell would expand a glob in. Quote the glob so your shell does not expand it first:

cardanowall attest --paths 'dist/**/*.tar.gz' --paths 'dist/**/*.whl'

Commits. --commits takes a git rev-list range; each leaf is the SHA-256 of a raw commit object, oldest first. This anchors the provenance of the history itself. It needs the full git history on the runner, since a shallow clone cannot resolve the range:

cardanowall attest --commits v1.0.0..v1.1.0

Pre-hashed digests. --leaf takes a 64-hex digest you computed elsewhere, repeatable and kept in argument order. Use it to anchor something the CLI never sees as a file, like an OCI image digest:

cardanowall attest --leaf 9f86d0818840…0a08   # a 64-hex digest, e.g. an image digest

A single leaf publishes a single-item record; several leaves publish a Merkle record whose root is anchored on chain, with the leaf list uploaded so every item can later get an inclusion certificate.

The manifest and the receipt

In files mode, attest writes a deterministic poe-manifest.json beside its output (rename it with --manifest-out). The manifest records the name-to-hash binding for every file it anchored, and the same inputs always produce byte-identical manifest bytes. Add --anchor-manifest to fold the SHA-256 of the manifest in as a final leaf, so the binding between filenames and hashes is itself part of what the root commits to.

--receipt-out writes a versioned JSON receipt carrying the record, the quote, the transaction, and the wait snapshot. Keep it as your build's proof: it is everything a later verify needs to find and check the anchor. Store it as a workflow artifact, attach it to the release, or commit it beside the changelog.

Waiting, pending, and re-runs

By default attest waits for the transaction to cross the confirmation threshold (--wait confirmed); --wait submitted returns as soon as it reaches the network. Waiting has a deadline (--timeout, default 600 seconds). If the deadline passes, the outputs and the receipt are still written and the process exits 3 (pending): the publish is not lost, it continues on the gateway, and you can re-check later with the receipt. A --max-usd ceiling refuses to publish (exit 1, before any upload) when the quote is over your limit, so a price spike can never surprise-bill a pipeline.

Re-runs are safe by construction. attest sends no idempotency header by default; instead the gateway deduplicates byte-identical records, so re-running the same build never anchors a second time and never bills twice. Anchor the same dist/ again and the second run replays the first record for free.

GitHub Actions

The cardanowall/poe-attest action wraps the same CLI for GitHub workflows. It is open source and supply-chain pinned: it embeds the SHA-256 digests of the CLI release it runs and verifies both the downloaded archive and the extracted binary before every run, so a swapped release asset cannot slip through.

Store two secrets on the repository, GATEWAY_URL (your gateway's data-plane base URL, ending in /api/v1) and GATEWAY_API_KEY (a publish-scoped key), then anchor your release assets on publish:

name: anchor-release
on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  attest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: cardanowall/poe-attest@v1
        with:
          gateway-url: ${{ secrets.GATEWAY_URL }}
          api-key: ${{ secrets.GATEWAY_API_KEY }}
          paths: |
            dist/**/*.tar.gz
            dist/**/*.whl

The step writes the receipt, prints a summary with the transaction and a verify link, and exposes outputs (tx, record-id, verify-url, and more) for later steps.

Sign the stream with a dedicated CI identity

To make a pipeline's anchors attributable and discoverable, sign each record with an identity seed. The gateway then indexes the record by its signer public key, so anyone can list a pipeline's whole history from the gateway's records feed with ?signer=<public-key>. Signing stays optional, and verifiers never require it.

Use a dedicated, disposable identity per pipeline, never a personal seed. Store it as an environment-protected secret so only runs from that environment can read it:

jobs:
  attest:
    runs-on: ubuntu-latest
    environment: release-signing
    steps:
      - uses: actions/checkout@v7
      - uses: cardanowall/poe-attest@v1
        with:
          gateway-url: ${{ secrets.GATEWAY_URL }}
          api-key: ${{ secrets.GATEWAY_API_KEY }}
          seed: ${{ secrets.CI_SIGNING_SEED }}
          paths: dist/**/*.tar.gz

The seed is masked in logs and passed to the CLI on stdin only, never on the command line, and no secret ever reaches the gateway: hashing and signing happen locally, and only the record and public data are published.

Never anchor from pull_request_target

That trigger exposes your repository secrets to code from forked pull requests. Anchor only from events you control, like release, push, or workflow_dispatch. The minimal job above needs just contents: read; add contents: write only if you also attach the receipt to the release.

The action carries more inputs than shown here, including one inclusion certificate per leaf, release-asset attachment, a price ceiling, and the timeout policy. See the action README for the full set.

GitLab CI/CD

On GitLab, the same wrapper ships as a CI/CD component. The job runs inside the CLI's own container image, pinned by version and digest, so nothing is installed at run time — and, as everywhere on this page, any Label 309 gateway works, hosted or self-hosted:

include:
  - component: gitlab.com/cardanowall/poe-attest/attest@1
    inputs:
      gateway-url: https://your-gateway.example/api/v1
      paths: |
        dist/**/*.tar.gz
        dist/**/*.whl

Add the secrets as CI/CD variables under Settings → CI/CD → Variables, masked and protected: CARDANOWALL_API_KEY (the publish-scoped key) and, only if you sign, CARDANOWALL_SEED — set it on the project itself, never on a group, where inheritance would silently sign every child project's anchors with one identity. By default the job runs only on protected tag pipelines, so an unprotected ref can never spend your balance; override the rules input to anchor on other events.

Results come back as a dotenv report: a downstream job that needs: the attest job reads the transaction, the verify link, and the rest of the eighteen POE_* variables directly:

announce:
  needs: [poe-attest]
  script:
    - echo "anchored in $POE_TX"
    - echo "verify at $POE_VERIFY_URL"

The component carries more inputs than shown here, including inclusion certificates, a price ceiling, runner tags, and the timeout policy. See the component README for the full set.

Other CI systems

The same CLI runs anywhere. Use the container image ghcr.io/cardanowall/label-309-cli (its entrypoint is cardanowall) or a prebuilt binary from the releases page.

Any other runner, with the binary on PATH:

export CARDANOWALL_BASE_URL="https://your-gateway.example/api/v1"
export CARDANOWALL_API_KEY="$YOUR_CI_SECRET"

cardanowall attest \
  --paths 'dist/*' \
  --wait confirmed \
  --receipt-out poe-receipt.json

# exit 0 = reached the wait target; 3 = pending (publish continues on the gateway);
# 1 = refused (for example over --max-usd) or failed.

What you need from a gateway

Publishing puts a transaction on Cardano, and that costs a fee, so attest needs a gateway to submit through. Any Label 309 gateway works: a hosted operator, or your own self-hosted gateway (the open-source label-309-gateway, one Rust binary plus Postgres). From CI you need only two things from it: a data-plane base URL, and an API key scoped to publish (poe:create) backed by prepaid balance.

The gateway owns the funded Cardano wallet and pays the fee from its own balance model. Your CI holds no wallet key and no chain funds. The worst a leaked API key can do is spend that account's prepaid balance on more anchors; it cannot move funds, read your content, or sign as you. Rotate or revoke it at any time.

Verify the anchor

That anchoring is worth something only because anyone can check it without you. Given the transaction reference from the receipt, verification runs standalone against the public chain and an explorer of your choosing, with no account and no gateway:

cardanowall verify <tx-hash>

It resolves the transaction, structurally validates the record, checks any signature, confirms the record is settled, and returns a verdict as its exit code, so it drops into a downstream check as cleanly as attest fits the publish side. To confirm one artifact against its anchor, hash the file and compare, or for a Merkle record build an inclusion certificate that pins one artifact to the published root. The full verifier model is in Verification.

The proof outlives the pipeline

A Label 309 anchor is plain metadata under label 309, not a vendor receipt. Long after the runner is gone, the registry has rotated, and the CI system is a memory, the transaction still witnesses that your artifacts existed by its block time. Anyone can verify it from the public chain, with no account and no trust in whoever published it.