Image

Host releases on your own domain

A project named after its host, such as mytool.example.com, is found through the signed release list at that host and nowhere else. The bytes can live anywhere; this guide puts them on the same host, publishes both from a GitHub Actions release job, and keeps the list current between releases. The signing identity stays the repository’s workflow, so consumers pin the same thing they would for a GitHub project.

Lay out the host

Use one directory per release, named by its tag, and the well-known path for the list:

https://mytool.example.com/v1.2.3/mytool-1.2.3-linux-x64.tar.gz   an artifact
https://mytool.example.com/v1.2.3/mytool.usage.kdl               a resource asset
https://mytool.example.com/v1.2.3/packslip.sigstore.json         the release bundle
https://mytool.example.com/.well-known/packslip.json             the release list

A project with a path, example.com/tools/mytool, serves its list at https://example.com/.well-known/packslip/tools/mytool.json instead; see where consumers find the list.

The release directories never change once published: a consumer pins the digest of every file it downloads, so serve them with a long, immutable cache lifetime. The list changes with every release and refresh, so give it a short one, five minutes or so.

Sign as the repository

A consumer derives nothing from a domain name, so it is told what to pin: the OIDC issuer and an identity prefix covering the repository’s workflows, https://github.com/owner/repo/. That is the policy a github.com/owner/repo name implies, spelled out. In mise, a tool or registry entry carries it as options:

[tools]
"packslip:mytool.example.com" = { version = "latest", issuer = "https://token.actions.githubusercontent.com", identity_prefix = "https://github.com/owner/repo/" }

Every bundle a project publishes should come from one workflow file. A consumer remembers which workflow signed the releases it accepted and asks a person before taking one from another, so a second file that signs bundles, for a backfill say, looks like a change of signer. The list may be signed by a different file in the same repository; consumers check it against the pin, not against the bundles’ signer.

Publish a release

In the release job, name the project and where its files will be, upload the files, then run the action and upload the bundle it wrote. The action still attaches the bundle to the GitHub release unless upload is false, which is fine: what consumers read is the copy the list names.

permissions:
  contents: write
  id-token: write
  attestations: write

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
  AWS_REGION: auto
  AWS_ENDPOINT_URL: https://<account>.r2.cloudflarestorage.com

steps:
  # Build the archives and create the GitHub release before these steps.
  - name: Upload the release files
    run: |
      aws s3 cp dist/ "s3://releases/mytool/${GITHUB_REF_NAME}/" --recursive \
        --cache-control "public, max-age=31536000, immutable"
  - uses: jdx/packslip@v1
    id: packslip
    with:
      project: mytool.example.com
      url-base: https://mytool.example.com/${{ github.ref_name }}
      artifacts: dist/*.tar.gz dist/*.zip
      bin: mytool
      resources: cli-spec/usage=asset:dist/mytool.usage.kdl
  - name: Upload the bundle
    run: |
      aws s3 cp "${{ steps.packslip.outputs.bundle }}" \
        "s3://releases/mytool/${GITHUB_REF_NAME}/packslip.sigstore.json" \
        --content-type application/json --cache-control "public, max-age=31536000, immutable"

The example writes to a Cloudflare R2 bucket through its S3 endpoint; any host that serves files over HTTPS works the same way. A resource declared with asset: gets a URL under url-base like the artifacts do, so upload it with them. Upload the bundle last: a release is only offered once the list names its bundle, and the bundle should only appear once everything it describes is in place.

Build and publish the list

The jdx/packslip/releases action turns a directory of published bundles, laid out as <dir>/<tag>/packslip.sigstore.json, into a signed list. It verifies every bundle under the pin first, refuses one for another project or in the wrong directory, and verifies the list it wrote.

permissions:
  contents: read
  id-token: write

steps:
  - uses: actions/checkout@v5
  - name: Fetch the published bundles
    run: aws s3 sync s3://releases/mytool/ lists/ --exclude '*' --include '*/packslip.sigstore.json'
  - uses: jdx/packslip/releases@v1
    id: list
    with:
      project: mytool.example.com
      dir: lists
      url-base: https://mytool.example.com
  - name: Publish the list
    run: |
      aws s3 cp "${{ steps.list.outputs.list }}" s3://releases/mytool/.well-known/packslip.json \
        --content-type application/json --cache-control "public, max-age=300"

The list’s sequence defaults to the current Unix time, which increases on its own with no counter to keep. Its validity defaults to 30 days.

Action inputs

InputPurpose and default
projectRequired. The project’s name, as its bundles spell it.
dirRequired. A directory of bundles as <dir>/<tag>/<bundle>.
url-baseRequired. Where the bundles are served, without the tag: https://<host>.
bundleThe bundle file name in every tag directory; defaults to packslip.sigstore.json.
sequenceAn integer that increases with every list; defaults to the current Unix time.
valid-forHow long the list stays current, as a number and unit (30d, 12h, 2w); defaults to 30d.
latestRecommend this exact listed version. Empty leaves consumers to take the highest eligible version.
yankReleases to withdraw, one per line, as TAG=REASON or URL=REASON.
securityReleases that fix a vulnerability, one tag or URL per line.
identity-prefix, identity, issuerThe pin the bundles and the list must verify under; default to this repository’s workflows through GitHub’s issuer.
outWhere to write the list; defaults to packslip-releases.sigstore.json.
packslip-version, packslip-path, tokenAs for the release action.

Outputs: list, the path written, and count, how many releases it names. The action signs keylessly with the job’s identity; a project whose consumers pin a key runs packslip releases with --key instead.

Keep the list current

A consumer refuses an expired list, and for a project on its own domain that means refusing the project. Put the list job in its own workflow with three triggers, and call it from the release workflow after the release is public:

on:
  workflow_call:
  schedule:
    - cron: "0 6 * * 1"
  workflow_dispatch:
    inputs:
      yank:
        description: "Releases to withdraw, one per line, as TAG=REASON"
        default: ""

A weekly run against a 30-day validity leaves room for a few failed runs. Pass the dispatch inputs through to the action’s yank and security inputs to withdraw a release or mark one a security fix without a new release; the withdrawn release stays in the list with its status.

Serve the files

A static site host serves the release directories and the list as it serves anything else, as long as the list’s path and content type are right: application/json at /.well-known/packslip.json. On Cloudflare, one Worker can serve a documentation site as static assets and the releases from an R2 bucket on the same hostname, since a request for a path the site has no file for is what reaches the Worker’s code. That is also a place to count downloads. packslip.dev works this way; its Worker and configuration are a starting point.

Move a GitHub project

A release bundle names one project, so bundles published as github.com/owner/repo cannot go in mytool.example.com’s list, and the list cannot be empty. Before switching consumers over, describe at least the current release again under the new name: download its files from the GitHub release, run the action with the tag, version, project, and url-base set and attest: link, and upload the result beside the files. Do it from the workflow file that signs new releases, as above. Then run the list job.

The bundle attached to the GitHub release keeps naming the old project and stays valid for anyone reading it there. Consumers rename the tool they ask for, packslip:github.com/owner/repo to packslip:mytool.example.com in mise, and pin the identity as shown above; a consumer that remembered the old project’s signer starts afresh under the new name.

Troubleshoot

SymptomWhat to check
no identity to verify againstThe project is not on a forge, so the pin is not implied: pass --identity-prefix and --issuer, or --pubkey, to packslip verify. The actions do.
a release list cannot be emptyNothing under <dir>/<tag>/; check the sync, or backfill a release.
is for github.com/owner/repo, not mytool.example.comA bundle from before the move is in the directory; describe that release again under the new name, or remove it.
is release v1.2.3 but sits under v1.2.4/The directory is named by the tag the bundle records; move it.
is not among the --release entriesA yank or security entry names a tag or URL that is not in dir.
A consumer says the list expiredThe scheduled run has not published one lately; check it, and dispatch it once by hand.
A consumer refuses a backfilled release as a different signerThe backfill ran from another workflow file; run it from the one that signs releases, and have the consumer forget the pin it took.