name: packslip
description: >
Publish a signed packslip for a release: attest the artifacts' build
provenance, digest them, sign the release manifest keylessly with this
job's OIDC identity, and upload the bundle as a release asset.
author: Jeff Dickey <@jdx>, Shunsuke Suzuki <@suzuki-shunsuke>
branding:
icon: package
color: red
inputs:
artifacts:
description: >
Artifact files or globs already present locally, whitespace separated
(dist/*.tar.xz dist/*.zip). At least one file must match between this
and `download`.
required: false
default: ""
download:
description: >
Release asset name patterns to fetch before collecting artifacts,
whitespace separated (mytool-*.tar.gz mytool-*.zip). Downloaded files
join `artifacts`, so a job whose archives already live on the release
named by `tag` needs no separate `gh release download` step, and no
second copy of these patterns for a local artifacts glob. With
contents: read, the default github.token cannot see a draft release.
required: false
default: ""
project:
description: >
The project's name; defaults to github.com/<owner>/<repo>. A tool in
a monorepo names itself github.com/<owner>/<repo>/<tool> and runs this
step once per tool. A host such as mytool.example.com names a project
on its own domain; see https://packslip.dev/docs/self-hosting/.
required: false
default: ""
version:
description: >
The release's semver version, such as 1.2.3; defaults to the tag
without a leading v. Set it for tags such as mytool-v1.2.3 or v4.1,
which the action does not normalize.
required: false
default: ""
tag:
description: >
The release tag, such as v1.2.3. Defaults to the tag that triggered
the workflow and is required otherwise. The action records it as the
source tag, derives the default version, url-base, and notes-url from
it, and downloads from and uploads to that release.
required: false
default: ""
commit:
description: >
Source commit recorded in the release bundle; defaults to github.sha.
Set this to the full commit SHA of the release tag when publishing a
different revision, for example from a manually dispatched workflow.
This does not change the workflow identity or artifact provenance.
required: false
default: ""
url-base:
description: Download URL prefix; defaults to this release's download URL
required: false
default: ""
bin:
description: >
Executables inside every artifact, whitespace separated: a path
relative to the archive root, a plain name to look up inside each
archive (`tool` finds `tool-1.2.3-linux-x64/tool`), or NAME=PATH when
the command name differs from the file's. For a bare executable
artifact, the value is the name the file gets on PATH.
required: false
default: ""
manifest:
description: >
A release.toml file (`packslip create --manifest`) for what the inputs
cannot say per artifact: executables at different paths in different
archives, a platform the file name does not give, an artifact that
runs anywhere, OS or glibc minimums, and resources for one artifact.
Artifacts it lists are merged with the collected files. The action
always sets project, version, url_base, notes_url, and [source]
itself, from its inputs, their defaults, and the workflow's
repository, so those keys in the file are ignored. The file must be
present in the job, so check out the repository first.
required: false
default: ""
variants:
description: >
Variants for artifacts that share a platform, as FILENAME=VARIANT,
whitespace separated (tool-fips-linux-x64.tar.gz=fips).
required: false
default: ""
formats:
description: >
The format of an artifact whose name does not say, as FILENAME=FORMAT,
whitespace separated (tool-linux-x64.bin=raw). Every artifact needs
one; the name gives it for the usual archive, installer, and bare
executable spellings, and this is for the rest.
required: false
default: ""
resources:
description: >
What ships besides the executables, one per line, each a `packslip
create --resource` value: KIND[/QUALIFIER][@os[/arch[/libc]]]=SOURCE:VALUE.
For example: completion/zsh=archive:share/zsh/site-functions/_tool,
man=archive:share/man/man1/tool.1, cli-spec/usage=exec:tool usage,
skill/tool=repo:skills/tool, skill/tool=asset:dist/tool-skill.tar.gz,
or sbom/cyclonedx=asset:dist/tool.cdx.json. An @ scope limits an
entry to one platform (man@linux=archive:share/man/man1/tool.1). An
asset is a local file that is digested with the artifacts; upload it
to the release yourself, because the action uploads only the bundle.
A file the artifacts input also matched is recorded as the asset, not
as an artifact.
required: false
default: ""
require:
description: >
Commands the executables need on PATH, one per line, each a `packslip
create --require` value: bin:NAME or bin:NAME@MIN, such as bin:java@17.
What the executables load from the host is read from them and needs
no input.
required: false
default: ""
notes-url:
description: URL of the release notes; defaults to the release page
required: false
default: ""
extensions:
description: >
Release-level extensions, one per line, each a `packslip create
--extension` value: NAME=JSON, where NAME is who defines the value (a
consumer such as mise, or a domain the vendor controls) and JSON is
the value, such as example.com={"build_id":"20260901.3"}.
required: false
default: ""
attest:
description: >
What to do about build provenance. `true` attests every file
collected by artifacts and download with
actions/attest-build-provenance, links each artifact's attestation
from the packslip, and needs the attestations: write permission; an
artifact listed only in the manifest input's release.toml is
neither attested nor linked. `link` only links the attestations,
for a workflow whose build jobs already attested the files as they
produced them; it needs no extra permission, and an artifact
nothing attested leaves a URL that resolves to nothing. `false` does
neither. In a private or internal repository, GitHub attestations
need GitHub Enterprise Cloud, so use `false` on other plans.
required: false
default: "true"
out:
description: >
Directory to write the bundle into. The file is packslip.sigstore.json,
or packslip.<subpath>.sigstore.json for a tool in a GitHub monorepo
(github.com/<owner>/<repo>/<subpath>), with any / in the subpath
written as -.
required: false
default: packslip
upload:
description: >
Upload the bundle to the release named by tag, which needs contents:
write. Defaults to true. Set false to sign in a job that has only
contents: read, then upload the bundle output from a separate job that
has contents: write; see
https://packslip.dev/docs/publishing/#keep-the-action-away-from-release-write-access.
required: false
default: "true"
packslip-version:
description: >
The packslip version to run, such as 1.4.0, without a leading v;
defaults to the version this action was released with (read from its
Cargo.toml). An explicit override bypasses the internal release digest
map with a warning; use packslip-sha256 for an explicit archive pin.
Ignored when packslip-path is set.
required: false
default: ""
packslip-sha256:
description: >
SHA-256 digest of the packslip release archive the action downloads.
The default version is internally pinned. A supplied digest must agree
with that map, or pin an explicit packslip-version override.
When set, the action checks the downloaded archive before verifying its
build provenance or extracting it. It is ignored when packslip-path is
set, because the workflow then supplies the executable itself.
required: false
default: ""
packslip-path:
description: >
An existing packslip executable to run instead of downloading a
release: a path, or a name to look up on PATH. For a platform with no
release asset the job can run, build the CLI first (`cargo install
packslip --locked --root "$RUNNER_TEMP/packslip"`) and point this at
it. The action runs the binary as given and does not check its
provenance, so the job is responsible for where it came from.
required: false
default: ""
token:
description: >
Token for installing packslip, downloading release assets (download),
and uploading the bundle (upload)
required: false
default: ${{ github.token }}
outputs:
bundle:
description: Path of the written bundle, under out
value: ${{ steps.create.outputs.bundle }}
runs:
using: composite
steps:
- name: Install packslip
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
PACKSLIP_VERSION: ${{ inputs.packslip-version }}
PACKSLIP_SHA256: ${{ inputs.packslip-sha256 }}
PACKSLIP_PATH: ${{ inputs.packslip-path }}
PACKSLIP_ACTION_ROOT: ${{ github.action_path }}
run: bash "$PACKSLIP_ACTION_ROOT/scripts/install-packslip.sh"
- name: Download release assets
id: download
if: inputs.download != ''
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
IN_DOWNLOAD: ${{ inputs.download }}
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# IN_DOWNLOAD holds asset-name globs for `gh release download
# --pattern`, not paths to expand against this job's working
# directory -- turn off pathname expansion so a pattern like *.zip
# is never quietly replaced by whatever it happens to match here.
set -f
tag="${IN_TAG:-$REF_NAME}"
if [ -z "$IN_TAG" ] && [ "$REF_TYPE" != tag ]; then
echo "download is set but not running on a tag; pass the tag input" >&2
exit 1
fi
# A fresh directory per invocation, not a fixed name: the monorepo
# flow calls this action once per tool in the same job, and a fixed
# name would still hold the previous tool's files when this step is
# skipped for one of them.
dir="$(mktemp -d "${RUNNER_TEMP}/packslip-download.XXXXXX")"
args=(--repo "$REPO" --dir "$dir" --clobber)
for pattern in $IN_DOWNLOAD; do
args+=(--pattern "$pattern")
done
gh release download "$tag" "${args[@]}"
echo "dir=$dir" >> "$GITHUB_OUTPUT"
- name: Collect artifacts
id: files
shell: bash
env:
IN_ARTIFACTS: ${{ inputs.artifacts }}
IN_DOWNLOAD: ${{ inputs.download }}
IN_DOWNLOAD_DIR: ${{ steps.download.outputs.dir }}
run: |
set -euo pipefail
shopt -s nullglob
files=()
for pattern in $IN_ARTIFACTS; do
for f in $pattern; do [ -f "$f" ] && files+=("$f"); done
done
if [ -n "$IN_DOWNLOAD_DIR" ]; then
# Listed, not glob-matched: on a Windows runner RUNNER_TEMP is a
# backslash path, which bash's pathname expansion would otherwise
# read as glob escapes and match nothing.
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$IN_DOWNLOAD_DIR" -maxdepth 1 -type f -print0)
fi
if [ "${#files[@]}" -eq 0 ]; then
echo "no artifacts matched: artifacts=[$IN_ARTIFACTS] download=[$IN_DOWNLOAD]" >&2
exit 1
fi
printf '%s\n' "${files[@]}" > "${RUNNER_TEMP}/packslip-files"
{
echo 'files<<EOF'
printf '%s\n' "${files[@]}"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Check the provenance mode
shell: bash
env:
IN_ATTEST: ${{ inputs.attest }}
run: |
case "$IN_ATTEST" in
true | link | false) ;;
*) echo "attest must be true, link, or false, not \"$IN_ATTEST\"" >&2; exit 1 ;;
esac
- name: Attest build provenance
if: inputs.attest == 'true'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with:
subject-path: ${{ steps.files.outputs.files }}
- name: Create and sign the packslip
id: create
shell: bash
env:
IN_PROJECT: ${{ inputs.project }}
IN_VERSION: ${{ inputs.version }}
IN_TAG: ${{ inputs.tag }}
IN_COMMIT: ${{ inputs.commit }}
IN_URL_BASE: ${{ inputs.url-base }}
IN_BIN: ${{ inputs.bin }}
IN_MANIFEST: ${{ inputs.manifest }}
IN_VARIANTS: ${{ inputs.variants }}
IN_FORMATS: ${{ inputs.formats }}
IN_RESOURCES: ${{ inputs.resources }}
IN_REQUIRE: ${{ inputs.require }}
IN_NOTES_URL: ${{ inputs.notes-url }}
IN_EXTENSIONS: ${{ inputs.extensions }}
IN_ATTEST: ${{ inputs.attest }}
IN_OUT: ${{ inputs.out }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
SHA: ${{ github.sha }}
SERVER: ${{ github.server_url }}
API: ${{ github.api_url }}
run: |
set -euo pipefail
tag="${IN_TAG:-$REF_NAME}"
if [ -z "$IN_TAG" ] && [ "$REF_TYPE" != tag ]; then
echo "not running on a tag; pass the tag input" >&2
exit 1
fi
version="${IN_VERSION:-${tag#v}}"
project="${IN_PROJECT:-github.com/${REPO}}"
url_base="${IN_URL_BASE:-${SERVER}/${REPO}/releases/download/${tag}}"
notes_url="${IN_NOTES_URL:-${SERVER}/${REPO}/releases/tag/${tag}}"
args=(
create
--project "$project"
--version "$version"
--out "$IN_OUT"
--url-base "$url_base"
--notes-url "$notes_url"
--source-repo "${SERVER}/${REPO}"
--tag "$tag"
--commit "${IN_COMMIT:-$SHA}"
)
for b in $IN_BIN; do args+=(--bin "$b"); done
[ -n "$IN_MANIFEST" ] && args+=(--manifest "$IN_MANIFEST")
while IFS= read -r line; do
[ -n "$line" ] && args+=(--resource "$line")
done <<< "$IN_RESOURCES"
while IFS= read -r line; do
[ -n "$line" ] && args+=(--require "$line")
done <<< "$IN_REQUIRE"
while IFS= read -r line; do
[ -n "$line" ] && args+=(--extension "$line")
done <<< "$IN_EXTENSIONS"
files=()
while IFS= read -r file || [ -n "$file" ]; do
files+=("$file")
done < "${RUNNER_TEMP}/packslip-files"
if [ "$IN_ATTEST" = true ] || [ "$IN_ATTEST" = link ]; then
# GitHub serves each artifact's provenance at the attestations API
# by subject digest, whoever attested it, so the link is the same
# whether this step attested the file or an earlier job did.
for f in "${files[@]}"; do
digest=$(sha256sum "$f" | cut -d' ' -f1)
args+=(--provenance "${f##*/}=${API}/repos/${REPO}/attestations/sha256:${digest}")
done
fi
# A file name the format inference cannot read is given one.
for spec in $IN_FORMATS; do
args+=(--format "$spec")
done
# Artifacts sharing a platform carry the variant given for them.
specs=()
for f in "${files[@]}"; do
name="${f##*/}"
variant=''
for spec in $IN_VARIANTS; do
if [ "${spec%%=*}" = "$name" ]; then
variant="${spec#*=}"
fi
done
if [ -n "$variant" ]; then
specs+=("$f@$variant")
else
specs+=("$f")
fi
done
# After --, a file named like an option stays an artifact.
packslip "${args[@]}" -- "${specs[@]}"
# The file name follows the project: packslip.sigstore.json, or
# packslip.<subpath>.sigstore.json for a tool in a monorepo.
repo_path="${project#github.com/}"
sub="${repo_path#*/*/}"
if [ "$sub" = "$repo_path" ] || [ -z "$sub" ] || [ "${project%%/*}" != github.com ]; then
bundle="${IN_OUT}/packslip.sigstore.json"
else
bundle="${IN_OUT}/packslip.${sub//\//-}.sigstore.json"
fi
[ -f "$bundle" ] || { echo "expected $bundle to exist" >&2; ls -la "$IN_OUT" >&2; exit 1; }
echo "bundle=$bundle" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Verify what was written
shell: bash
env:
BUNDLE: ${{ steps.create.outputs.bundle }}
REPO: ${{ github.repository }}
SERVER: ${{ github.server_url }}
run: |
# A packslip-version or packslip-path from before --source-repository
# does not know the flag, so it keeps the workflow-prefix check, which
# a reusable workflow of another repository cannot pass.
help="$(packslip verify --help 2>&1 || true)"
if [[ "$help" == *--source-repository* ]]; then
pin=(--source-repository "${SERVER}/${REPO}")
else
pin=(--identity-prefix "${SERVER}/${REPO}/")
fi
packslip verify "$BUNDLE" "${pin[@]}" \
--issuer https://token.actions.githubusercontent.com
- name: Upload to the release
if: inputs.upload == 'true'
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
REPO: ${{ github.repository }}
TAG: ${{ steps.create.outputs.tag }}
BUNDLE: ${{ steps.create.outputs.bundle }}
run: gh release upload "$TAG" "$BUNDLE" --repo "$REPO" --clobber