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.0Pre-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 digestA 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/**/*.whlThe 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.gzThe 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/**/*.whlAdd 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.