Publish with GitHub Actions

Add jdx/packslip to the workflow that builds your release. The action describes the finished files in a release manifest, signs it with the workflow’s identity, verifies the result, and uploads the bundle. You do not need a long-lived signing key.

Choose the job layout that fits your workflow:

LayoutWhen to use it
One step in your release jobThe action may share the job’s contents: write permission and upload the bundle itself.
A separate signing jobThe action should have only contents: read; your own upload job attaches its output to the release.

The action adds metadata to your release process. Keep your existing build and artifact-upload steps. For publishing without GitHub Actions, start with Getting started and Artifact configuration.

Prepare the files and release

Finish building, rewriting archives, platform signing, and notarization before describing the files. The action records the digest of each final file, so later changes to its bytes fail verification.

Create the GitHub release and upload the artifacts through your existing workflow. Upload separate resource assets, such as SBOMs, too: the action uploads only the packslip bundle. Listing a file’s URL does not publish that file.

If the repository uses immutable releases, create the release as a draft. Publish it after uploading the bundle, because a published immutable release’s assets cannot change.

Once those files are on the release, either bring them into the signing job yourself (a build matrix typically stages them with actions/download-artifact before the packslip step) or let download fetch them straight from the release; see Download from the release. A job with only contents: read cannot see a draft release, so a read-only job that signs a draft must use the first way.

Add the release step to an existing job

Use this single-job form when the action may hold contents: write, which it needs to upload the bundle itself (upload defaults to true). Add it to your release job after the steps that build the files and create the release:

permissions:
  contents: write      # Upload the bundle.
  id-token: write      # Sign with the workflow's identity.
  attestations: write  # Attest the collected files.

steps:
  # Your existing build and release steps go here.
  - uses: jdx/packslip@v1
    id: packslip
    with:
      artifacts: dist/*.tar.xz dist/*.zip
      bin: mytool

The default action inputs derive the project, version, download URLs, and source commit from this workflow’s repository and tag. This example finds mytool in each archive, publishes provenance, and attaches packslip.sigstore.json to the existing release.

To try the workflow without attaching a bundle, set upload: false. The run still signs: its keyless signature is recorded in the public Rekor log, and the default attest: true publishes provenance. For an offline trial with a temporary key, use Getting started.

Keep the action away from release write access

Run packslip in its own job after the job that publishes your release files. Give it contents: read to download assets, id-token: write to sign, and attestations: write to publish provenance. Set upload: false and pass its bundle to your own job with contents: write.

GitHub grants token permissions per job, not per step. An action can use its job’s token even when you do not pass it as an input, so the separate job is what limits release write access. upload: false controls the action’s upload behavior.

jobs:
  packslip:
    needs: release # Your existing job that publishes the release files.
    runs-on: ubuntu-latest
    permissions:
      contents: read       # Download the release assets.
      id-token: write      # Sign with the workflow's identity.
      attestations: write  # Attest the downloaded files.
    steps:
      - uses: jdx/packslip@v1
        id: packslip
        with:
          download: mytool-*.tar.xz mytool-*.zip
          bin: mytool
          upload: false
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: packslip-bundle
          path: ${{ steps.packslip.outputs.bundle }}
          if-no-files-found: error

  publish-packslip:
    needs: packslip
    runs-on: ubuntu-latest
    permissions:
      contents: write  # Upload the bundle to the release.
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
        with:
          name: packslip-bundle
          path: packslip
      - name: Upload the signed bundle
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          test -f packslip/packslip.sigstore.json
          gh release upload "$GITHUB_REF_NAME" packslip/packslip.sigstore.json \
            --repo "$GITHUB_REPOSITORY" --clobber

Adapt the example to your workflow:

GitHub has no token limited to one release asset, so the upload step that holds contents: write belongs to your workflow, not to the packslip action.

The packslip job can still sign as your workflow, because signing is what it is for. To control which code can do that, pin the action to a release commit (jdx/packslip@<full SHA> # v1.4.0), as the example does for the artifact actions. The action then installs the CLI version from that commit’s Cargo.toml.

Download from the release

When the archives are already on the GitHub release, because an earlier job uploaded them or you are signing an existing release again, let the action fetch them instead of staging them yourself:

- uses: jdx/packslip@v1
  with:
    download: mytool-*.tar.xz mytool-*.zip
    bin: mytool

download replaces a gh release download step and the artifacts glob that would repeat its patterns. It fetches the matching assets from the release named by tag (the triggering tag by default) into a new temporary directory and adds them to the files artifacts collects. Set both inputs to combine files already on disk with files pulled from the release. Keep the patterns from matching anything that is not an artifact, such as a packslip.sigstore.json from an earlier run, because every downloaded file is collected as an artifact.

Other inputs cannot refer to the downloaded files by path, because they land in a temporary directory. An asset: resource needs a local file, so fetch it to a known path first:

- name: Fetch the SBOM
  env:
    GH_TOKEN: ${{ github.token }}
  run: gh release download "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --pattern mytool.cdx.json --dir dist
- uses: jdx/packslip@v1
  with:
    download: mytool-*.tar.xz mytool-*.zip
    bin: mytool
    resources: |
      sbom/cyclonedx=asset:dist/mytool.cdx.json

A TOML manifest given as manifest needs a checkout of the repository. Its [[artifact]] paths are read from the working directory, so its entries cannot describe files that download fetched. Fetch those files to known paths yourself, or describe them with bin, variants, and formats.

token must be able to read that release. GitHub lists draft releases only to tokens with push access, so with contents: read the default github.token can read a published release but not a draft. If your release stays a draft until the bundle is attached, have the read-only job collect the files with actions/download-artifact and artifacts instead of download. Then upload the bundle and publish the release from the job that has contents: write.

Check the published result

The action verifies the local bundle before uploading it. Check the published release separately: download the bundle and an artifact from the URLs users will use, then verify both against the workflow identity of your repository, as Verify a release describes. This also catches a wrong upload, stale file, or URL that points at a different build.

curl -fsSLO https://github.com/owner/repo/releases/download/v1.2.3/packslip.sigstore.json
curl -fsSLO https://github.com/owner/repo/releases/download/v1.2.3/mytool-1.2.3-linux-x64.tar.xz
packslip verify packslip.sigstore.json \
  --identity-prefix https://github.com/owner/repo/ \
  --issuer https://token.actions.githubusercontent.com \
  --artifact mytool-1.2.3-linux-x64.tar.xz

Inspect the statement with packslip show to confirm the project, normalized version, source tag, platforms, and executable paths. Inspection does not replace verification. packslip verify does not fetch linked build provenance. If the release links provenance, check it separately:

gh attestation verify mytool-1.2.3-linux-x64.tar.xz --repo owner/repo

Publish your signer fingerprint

Once you have verified the first published release of a project named after its GitHub repository, such as github.com/owner/repo, print its signer fingerprint from the bundle you downloaded:

packslip pin packslip.sigstore.json

For that name, packslip pin first verifies the bundle as packslip verify does, under the identity policy the name implies (a workflow of that repository, through GitHub’s issuer), and then prints a ps1_… value. Publish it where users can read it independently of your releases, such as your README or install instructions. With it, packslip verify --pin refuses a release signed from any other repository, including one that later takes over your repository’s name.

The fingerprint stays the same when the repository is renamed or transferred and when the workflow changes, and one fingerprint covers every tool of a monorepo, so you publish it once. A key-signed project has none; publish its public key instead. See Pin a signer with its fingerprint.

Keep later releases acceptable

Consumers remember who signed the releases they accepted and hold the next release to the same standard, so a workflow change that looks harmless can make them refuse it.

The consumer rules define these checks.

Add resources and requirements

Declare what ships besides the executables with resources, and commands the executables need with require. See Resources and Host requirements for the values.

- uses: jdx/packslip@v1
  with:
    artifacts: dist/*.tar.xz
    bin: mytool
    resources: |
      completion/zsh=archive:share/zsh/site-functions/_mytool
      man=archive:share/man/man1/mytool.1
      cli-spec/usage=exec:mytool usage
      sbom/cyclonedx=asset:dist/mytool.cdx.json
    require: |
      bin:java@17

Use paths from the actual archive root, including any top-level directory. Only include resources and commands your release really provides or needs. An asset: path names a local file in this job. A file that artifacts also matches is recorded as the asset, not as an installable artifact. When paths or requirements differ between artifacts, use a TOML manifest; see Artifact configuration.

Release several tools from one repository

Run the action once per tool, selecting only that tool’s artifacts:

- uses: jdx/packslip@v1
  with:
    project: github.com/owner/repo/mytool
    version: 1.2.3
    tag: mytool-v1.2.3
    artifacts: dist/mytool-*.tar.xz
    bin: mytool

This writes packslip.mytool.sigstore.json. Nested subpaths use hyphens in the file name: tools/mytool becomes packslip.tools-mytool.sigstore.json. The signer is still pinned to the repository. Consumers match the signed project field, not the bundle’s file name.

Publish a different source commit

tag selects the release, but does not change the default source commit: commit defaults to the workflow’s github.sha. If you dispatch from a branch and check out a different release tag, pass that tag’s full commit SHA explicitly:

# After checking out the intended release tag and preparing its artifacts:
- name: Resolve the checked-out release commit
  id: source
  shell: bash
  run: echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: jdx/packslip@v1
  with:
    tag: ${{ inputs.tag }}
    commit: ${{ steps.source.outputs.commit }}
    artifacts: dist/*.tar.xz dist/*.zip
    bin: mytool
    attest: link

The example uses attest: link because provenance made in this run would describe the dispatched run and its commit, not the build of the tag. It links to the attestations your build jobs made for these files; see Build provenance. If they made none, use attest: false; consumers then refuse this release if the previous one carried provenance, as Keep later releases acceptable explains.

commit sets only source.commit in the release manifest. The action does not check that it matches the tag or the files, so take it from the checkout you built from, as above. Consumers fetch repo: resources, such as a skill directory, from this commit, so a wrong value serves the wrong files.

The signature still names the workflow and the ref you dispatched from. Consumers compare the workflow’s path, not its ref, so this run is the same signer as a tag-triggered run of the same file, and a dispatch from any branch is accepted too. If your workflow restricts which refs may publish, keep that restriction, because consumers do not check the ref. Workflows that already run on the release commit can omit commit.

Publish outside GitHub releases

Set url-base when users download the files from your own host. The release manifest gives each file’s URL as url-base followed by / and the file name. The action uploads nothing there, so copy the artifacts, resource assets, and bundle to the host yourself. The action still attaches the bundle to the GitHub release. Keep that for a project named github.com/owner/repo, which consumers find through its GitHub releases. A project named after the host instead publishes a signed release list there and can set upload: false. Host releases on your own domain covers both.

Build the CLI on the runner

The action downloads the release archive for the runner’s platform. Where that archive does not exist or cannot be used, packslip-path points the action at an executable the job already has: a path, or a name to look up on PATH. The action runs it as packslip for the rest of the steps and skips the download.

macOS releases are arm64 only, so an x64 macOS job builds the CLI with cargo first:

jobs:
  release:
    runs-on: macos-15-intel
    permissions:
      contents: write      # Upload the bundle.
      id-token: write      # Sign with the workflow's identity.
      attestations: write  # Attest the collected files.
    steps:
      # Build the archives and create the release before these steps.
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install packslip --version 1.4.0 --locked --root "$RUNNER_TEMP/packslip"
      - uses: jdx/packslip@v1.4.0
        with:
          packslip-path: ${{ runner.temp }}/packslip/bin/packslip
          artifacts: dist/*.tar.xz
          bin: mytool

Build the CLI for the runner’s own platform, because the action runs the binary on that runner. Install the version the action ref pins, since the action and CLI are released together, and use --locked so the build uses the dependency versions that release was tested with. packslip-path also suits a platform packslip does not ship, a self-hosted runner that already has packslip installed, and a job that prefers building from source. It replaces only the CLI download: signing still contacts Sigstore, and download and the upload still use the GitHub API.

A matrix that needs this on only some runners can leave the input empty elsewhere; an empty packslip-path downloads as usual:

packslip-path: ${{ runner.os == 'macOS' && runner.arch == 'X64' && format('{0}/packslip/bin/packslip', runner.temp) || '' }}

packslip-path takes precedence over packslip-version, which the action warns about when both are set. A downloaded archive is checked against jdx/packslip’s build provenance before it runs; a binary supplied this way is not checked at all, so the job vouches for where it came from.

Released action commits include the SHA-256 digests of their CLI archives. With the default CLI version, the action checks that internal digest before provenance verification or extraction, then verifies the exact candidate build source and the Packslip release workflow. Pinning the action’s full commit therefore pins its CLI download on every supported platform without copying hashes into the calling workflow.

The digest must match the runner’s OS and architecture. A workflow matrix needs the corresponding digest for each platform. The action’s vX.Y.Z tag and the CLI release tag identify the same commit; v1 advances to that commit after publication. The candidate archives were built and attested before the digest map was committed to the release PR; their provenance truthfully names that candidate commit, not a build at the final tag.

An explicit packslip-version overrides this internal lock, with a warning, and retains repository-level build-provenance verification. For that override, use packslip-sha256 to pin the downloaded archive too. Obtain the digest from the release’s GitHub asset metadata and use lowercase hexadecimal:

- uses: jdx/packslip@<full-commit-sha> # v1.5.1
  with:
    packslip-version: 1.5.1
    packslip-sha256: d401822be0f4c0494dde170657499fc197709b530e18b547906dafe05c155d45
    artifacts: dist/*.tar.xz
    bin: mytool

An optional digest for the default version must agree with the internal map. The digest is checked after download and before provenance verification or extraction. It applies only to the action-downloaded archive; when packslip-path is set, the workflow supplies the executable and the digest input is ignored.

What the action does

The action installs the packslip CLI released with it, fetches any download assets, attests the collected files (see Build provenance), creates and signs the release manifest, verifies the bundle, and uploads it to the release unless upload is false. Its bundle output is the local bundle path.

Run it on a release tag, or pass the tag as tag. By default project is github.com/<owner>/<repo> and version is the tag without a leading v. The action does not normalize other tag spellings, so for a tag such as mytool-v1.2.3, pass version: 1.2.3.

The action and CLI share a version: @v1 follows CLI 1.x releases, and @v1.4.0 pins both to 1.4.0. By default, the action installs the CLI version from its own commit’s Cargo.toml. Set packslip-version to override that selection. packslip-path runs a CLI the job already has instead of downloading one; see Build the CLI on the runner.

When a step passes an input that the pinned release does not define, GitHub only warns, and the step runs without that input. Check that the release you pin has every input you use: download needs 1.0.1 or later and commit 1.3.0 or later.

GitHub-hosted Linux and Windows runners provide the required tools. For macOS or self-hosted runners, check these before adding the action:

ToolUsed for
bash 4 or laterRunning the action’s steps; macOS’s own /bin/bash 3.2 is too old.
GitHub CLI (gh) with gh attestation verifyInstalling and verifying packslip, downloading assets, and uploading the bundle.
tar with xz support, or unzip on WindowsUnpacking the packslip CLI.
sha256sumLinking build provenance by digest and verifying downloads (shasum is also supported for download verification).
jqReading the pinned action’s internal release digest map.

The jdx/packslip/releases action also uses jq to publish the lists used for Host releases on your own domain.

Action inputs

InputPurpose and default
artifactsWhitespace-separated local files or globs. Between this and download, at least one file must match.
downloadWhitespace-separated release asset name patterns to fetch before collecting artifacts; joins artifacts. See Download from the release.
binWhitespace-separated executables: a path inside the archive, a plain name to look up in each archive, or NAME=PATH. For a bare executable, the name it gets on PATH.
projectProject name; defaults to github.com/<owner>/<repo>. A host such as mytool.example.com names a project on its own domain; see Host releases on your own domain.
versionSemver version; defaults to the tag without its leading v.
tagExisting release tag; defaults to the triggering tag and is required otherwise.
commitSource commit SHA; defaults to github.sha. Override when the release source differs from the workflow commit.
manifestPath to a TOML manifest for per-artifact paths, platforms, OS or glibc minimums, and artifact-scoped resources. Its artifacts join the collected files. Check out the repository so the file is present.
variantsWhitespace-separated FILENAME=VARIANT entries, for artifacts that share a platform.
formatsWhitespace-separated FILENAME=FORMAT entries, for an artifact whose name does not say what it is.
resourcesOne resource declaration per line. Add @os[/arch[/libc]] after the kind and any qualifier (man@linux=…) to scope one to a platform.
requireOne bin:NAME[@MIN] requirement per line.
extensionsOne NAME=JSON extension per line.
url-baseArtifact download prefix; defaults to the release’s download URL. Set it when the files are served from elsewhere.
notes-urlRelease-notes URL; defaults to the release page.
attestDefaults to true. Use link when the build jobs already attested the files, or false for neither. See Build provenance.
outBundle output directory; defaults to packslip.
uploadDefaults to true, which needs contents: write. Set false to keep the bundle local, for example to upload it from a separate job.
packslip-versionCLI version, such as 1.4.0 without a v; defaults to the version in the action’s Cargo.toml.
packslip-pathAn existing packslip executable to run instead of downloading a release: a path, or a name on PATH. Takes precedence over packslip-version.
packslip-sha256Optional lowercase SHA-256 of the action-downloaded Packslip archive; checked before provenance verification and extraction. Ignored with packslip-path.
tokenToken for installing packslip, download, and the upload; defaults to github.token.

Output: bundle, the path of the written bundle, such as packslip/packslip.sigstore.json or packslip/packslip.mytool.sigstore.json.

The action always passes --project, --version, --url-base, --notes-url, --source-repo, --tag, and --commit to packslip create, and these flags take precedence over the TOML manifest given as manifest. Its project, version, url_base, notes_url, and [source] are therefore ignored; set the matching action inputs instead. The source repository is always the workflow’s repository.

Build provenance

With the default attest: true, the action attests every file that artifacts matched or download fetched, using actions/attest-build-provenance, and links each artifact’s attestation from the packslip. This needs attestations: write. The action neither attests nor links an artifact listed only in the TOML manifest; add the file to artifacts to cover it. A resource asset carries no provenance link, and the action attests one only when artifacts or download also collected it.

Set attest: link when your build jobs already attest each final file. GitHub serves provenance by the file’s digest, whichever job attested it, so the link is the same and the action makes no second attestation. link needs no attestations: write, and it does not check that an attestation exists: a file nobody attested gets a link that resolves to nothing.

attest: false neither attests nor links. Switching to it after a release that carried provenance makes consumers refuse the next one; see Keep later releases acceptable. In a private or internal repository, GitHub artifact attestations need GitHub Enterprise Cloud; on other plans, set attest: false from the first release.

Troubleshoot publication

SymptomWhat to check
Unexpected input(s) warningThe step ran without that input. Either the input name is misspelled (compare it with Action inputs), or the pinned action release predates the input; use a newer release.
could not download packslip-v…An x64 macOS runner has no release archive; build the CLI. Elsewhere, check that packslip-version is a released version such as 1.4.0, without a v.
not running on a tag; pass the tag inputThe run’s ref is not a tag, as on a branch push, a schedule, or a dispatch from a branch. Pass tag, and also commit if the checked-out source is not github.sha.
No artifacts matchedFiles must be present in this job’s working directory, or matched by download from the release named by tag. Download matrix outputs before the action (or set download), and check the glob.
The attest step fails with a permission errorGive the job attestations: write and id-token: write, or set attest: link or false. A private repository without GitHub Enterprise Cloud needs attest: false.
Version rejectedPass a semver version explicitly for tags such as mytool-v1.2.3 or v4.1.
warning: tag "…" does not name version …Consumers that list the project’s releases by tag will not see this release. Use a tag that names the version, such as v1.2.3, or name the release in a supplementary list.
Executable missing or ambiguousCheck the archive contents and use an explicit path or NAME=PATH mapping.
Signing cannot obtain a CI identityGive the signing job id-token: write.
Bundle upload failsThe release named by tag must exist, the token must have contents: write, and an immutable release must still be a draft.
A resource URL returns a missing fileUpload that separate asset; resource declarations only describe it.
A download fails its digest checkCompare the published file with the final local file that was signed; do not disable verification.