Publisher guide
For source repo owners who want to co-sign their content and qualify for the
Dual-Attestedtrust tier. Covers setup, first run, and how to verify everything is working.
What you get
Section titled “What you get”When a registry crawls your repository, it checks whether you have a moat-attestation.json on the moat-attestation branch with valid Rekor entries for each content item. If it finds them and the Rekor signatures check out, your content is indexed as Dual-Attested instead of Signed.
Dual-Attested means: the registry attested the content, AND the source repository’s own CI independently attested the same content hash. Neither can tamper with the other’s entry.
Prerequisites
Section titled “Prerequisites”- A GitHub repository containing content in one of the recognized layouts (see Content Discovery)
- Python 3.9+ and
cosignv2.x are installed automatically by the workflow — you do not need them locally
Content discovery
Section titled “Content discovery”The Publisher Action finds content items using the same two-tier model as the Registry Action:
Tier 1 — canonical directories: Any of skills/, agents/, rules/, commands/ at the repository root. Each subdirectory inside one of these is treated as one content item.
my-repo/ skills/ my-summarizer/ ← one item, type: skill SKILL.md ... my-formatter/ ← one item, type: skill SKILL.md ... rules/ coding-standards/ ← one item, type: rules rules.mdTier 2 — .moat/publisher.yml config: For custom layouts, create .moat/publisher.yml:
items: - name: my-tool type: skill path: tools/my-tool - name: shared-rules type: rules path: config/rulesTier 2 supplements Tier 1 — both are discovered if both are present.
1. Copy the workflow file
Section titled “1. Copy the workflow file”Copy the workflow file from the Publisher Action spec to .github/workflows/moat-publisher.yml in your source repo. The recommended filename is moat-publisher.yml — you may use a different name, but the filename is encoded into the Rekor certificate and registries use it to verify your attestation (see Workflow filename).
2. Configure the trigger
Section titled “2. Configure the trigger”The default workflow triggers on push to main. If your default branch is named differently, update the trigger:
on: push: branches: - trunk # or whatever your default branch is named paths-ignore: - 'moat-attestation/**'The paths-ignore guard is belt-and-suspenders — recursive execution is structurally impossible because the action pushes to moat-attestation, not to the triggering branch. Keep it for clarity.
3. Private repositories (optional)
Section titled “3. Private repositories (optional)”By default the action exits with an error on the first run if your repository is private or internal. The Actions log for the failed Run MOAT publisher action step shows exactly this:
error: repository visibility is 'private'. Set ALLOW_PRIVATE_REPO: 'true' to attest private repositories. Note: content hashes and repository identity will be permanently recorded in the public Rekor transparency log.##[error]Process completed with exit code 1.The job then fails at the attest step. This is the spec’s Private Repository Guard working as designed. No signing happens before the guard fires, so a failed first run does not leak anything to Rekor.
If you intentionally want to attest a private repo, set the env var in the workflow:
env: ALLOW_PRIVATE_REPO: 'true'What is and isn’t logged. Read this carefully before flipping the flag — opting in is one-way, since Rekor entries cannot be deleted:
- Logged to public Rekor: the SHA-256 hash of each content item, your repository identity (
<owner>/<repo>), the workflow file path, and the timestamp. Anyone in the world can see that your repo signed something and roughly what it was structured like. - NOT logged: the actual file contents. Content bytes never leave your repository. Only their hashes are signed.
For a brand-new private repo with no prior public footprint, the metadata exposure may be material. For a repo whose existence and structure are already publicly known (a private fork of a public project, an org’s known internal tooling), the additional exposure is small.
If you’re self-publishing (running both actions in this repo), you also need to set allow-private-source: true on the source entry in .moat/registry.yml. See the self-publishing guide for the full picture.
First run
Section titled “First run”Push any change to your default branch to trigger the Publisher Action, or trigger it manually:
gh workflow run moat-publisher.yml --repo <owner>/<repo>Watch it run:
gh run list --repo <owner>/<repo> --workflow=moat-publisher.yml --limit=5gh run watch <run-id> --repo <owner>/<repo>A successful run ends with:
Done. moat-attestation branch updated.Verify the attestation
Section titled “Verify the attestation”Check the branch exists
Section titled “Check the branch exists”git fetch origin moat-attestationgit show origin/moat-attestation:moat-attestation.json | python3 -m json.toolOr via the GitHub API:
gh api "repos/<owner>/<repo>/contents/moat-attestation.json?ref=moat-attestation" \ --jq '.content' | base64 -d | python3 -m json.toolWhat a valid moat-attestation.json looks like
Section titled “What a valid moat-attestation.json looks like”{ "schema_version": 1, "attested_at": "2026-04-11T04:30:00Z", "publisher_workflow_ref": ".github/workflows/moat-publisher.yml@refs/heads/main", "private_repo": false, "items": [ { "name": "my-summarizer", "content_hash": "sha256:abc123...", "source_ref": "def456...", "rekor_log_id": "24296fb24b8ad77a...", "rekor_log_index": 12345678 } ], "revocations": []}Check each field:
| Field | Expected |
|---|---|
schema_version | 1 |
publisher_workflow_ref | Your workflow path + ref, e.g. .github/workflows/moat-publisher.yml@refs/heads/main |
private_repo | false for public repos; true only if you set ALLOW_PRIVATE_REPO: 'true' |
items[].name | Matches your content directory names |
items[].rekor_log_index | A positive integer (the Rekor entry index) |
Verify the Rekor entry directly
Section titled “Verify the Rekor entry directly”For each item, confirm the Rekor entry covers the expected content hash:
LOG_INDEX=12345678 # from moat-attestation.json items[].rekor_log_indexCONTENT_HASH="sha256:abc123..." # from items[].content_hash
# Fetch the Rekor entrycurl -s "https://rekor.sigstore.dev/api/v1/log/entries?logIndex=${LOG_INDEX}" \ | python3 -c "import sys, json, base64, hashlib
entry = next(iter(json.load(sys.stdin).values()))body = json.loads(base64.b64decode(entry['body']))spec = body['spec']
# Check payload hashentry_hash = spec['data']['hash']['value']content_hash = '${CONTENT_HASH}'canonical = json.dumps({'_version': 1, 'content_hash': content_hash}, separators=(',', ':'), sort_keys=True).encode('utf-8')canonical_hash = hashlib.sha256(canonical).hexdigest()
print('Entry hash: ', entry_hash[:16] + '...')print('Expected hash: ', canonical_hash[:16] + '...')print('Match:', entry_hash == canonical_hash)"Expected output:
Entry hash: <first 16 hex chars>...Expected hash: <same first 16 hex chars>...Match: TrueVerify the OIDC subject
Section titled “Verify the OIDC subject”The certificate in the Rekor entry must show your repo and workflow path:
LOG_INDEX=12345678
curl -s "https://rekor.sigstore.dev/api/v1/log/entries?logIndex=${LOG_INDEX}" \ | python3 -c "import sys, json, base64from cryptography import x509
entry = next(iter(json.load(sys.stdin).values()))body = json.loads(base64.b64decode(entry['body']))cert_b64 = body['spec']['signature']['publicKey']['content']cert = x509.load_pem_x509_certificate(base64.b64decode(cert_b64))san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)uris = san.value.get_values_for_type(x509.UniformResourceIdentifier)print('OIDC subject:', uris[0] if uris else '(none)')"Expected:
OIDC subject: https://github.com/<owner>/<repo>/.github/workflows/moat-publisher.yml@refs/heads/mainGetting to Dual-Attested
Section titled “Getting to Dual-Attested”Once your moat-attestation.json is published, any registry that includes your repo as a source will see it on the next crawl. What the registry checks:
- Fetches
moat-attestation.jsonfrom yourmoat-attestationbranch - For each item, fetches the Rekor entry at
rekor_log_index - Reconstructs
{"_version":1,"content_hash":"<hash>"}and confirms it matches the Rekor entry hash - Confirms the OIDC subject in the certificate matches your repo and workflow path (read from
publisher_workflow_ref)
If all four pass, your item is indexed as Dual-Attested and the signing_profile field is written to the manifest entry.
You don’t need to notify registries — they crawl on schedule. If the registry supports the optional webhook, you can configure it for faster propagation (see registry-webhook in the Publisher Action spec).
Workflow filename
Section titled “Workflow filename”The Publisher Action can be named anything — the actual filename is recorded automatically in publisher_workflow_ref in moat-attestation.json, and registries read this field to derive the expected OIDC subject. The recommended filename is .github/workflows/moat-publisher.yml.
If you rename the workflow file after your first run, the existing Rekor entries in moat-attestation.json were signed with the old filename and will fail verification with registries that have already crawled you. To fix: retrigger the Publisher Action (which creates new Rekor entries with the new filename) and wait for registries to re-crawl.
Troubleshooting
Section titled “Troubleshooting”Run succeeds but moat-attestation branch doesn’t exist
The action only creates the branch if it finds content items. Check:
- Your repo has at least one content directory (
skills/,agents/,rules/,commands/) or a.moat/publisher.ymlconfig - Content directories are not empty
rekor_log_index is missing from an item
The Sigstore signing step failed for that item. Check the workflow run log for cosign sign-blob failed: output.
Items show as Signed instead of Dual-Attested at a registry
The registry ran publisher verification and it failed. Common causes:
- The
publisher_workflow_refin yourmoat-attestation.jsondoes not match the OIDC subject in the Rekor entry — this happens if you renamed the workflow file after signing - The registry is using an old
moat-attestation.json— wait for the next crawl or use the webhook to notify
Check the registry’s action log for a Publisher Rekor verification failed message — it will show the expected vs. observed OIDC subject.
Private repository error
error: repository visibility is 'private'. Set ALLOW_PRIVATE_REPO: 'true' to attest private repositories.Set ALLOW_PRIVATE_REPO: 'true' in the workflow env block and re-read the warning about Rekor permanence before proceeding.