name: attest-sign
description: Sign an attestation for the gates that passed on this tree, and push it
branding:
icon: edit-3
color: green
inputs:
key:
description: >-
The private half of an ed25519 key whose public half is committed in
allowed_signers, passed from a repository secret (see the README for the
expression; it cannot be spelled here, because GitHub EVALUATES a
dollar-brace expression inside this description and refuses `secrets`).
Empty on a fork pull request, which is fine: nothing is signed and the
step succeeds.
required: true
gates:
description: >-
Space-separated names of the gates that ACTUALLY RAN AND PASSED in this
job. Build it from step outcomes, so a gate that was skipped because it
was already attested is not re-signed on this platform:
"steps.test.outcome == 'success' && 'pre-push-cargo-test' || ''".
An empty list is not an error.
required: true
platform:
description: >-
Where the gates ran, as <arch>-<os>. Default: this runner. Do not claim
another platform.
required: false
default: ""
object:
description: >-
The object to attach the note to. Default: HEAD's tree. A commit is
accepted when its tree IS HEAD's tree; anything else is refused.
required: false
default: ""
remote:
description: The remote to push the notes ref to.
required: false
default: origin
push:
description: >-
Push refs/notes/amont-attest after signing. `false` appends to the local
ref only.
required: false
default: "true"
allow-dirty:
description: >-
Sign even if the working tree has modified or untracked files. The
default refuses, because the checks then did not run on HEAD's tree.
required: false
default: "false"
producer:
description: >-
The `amont <name>` line of the payload. Keep it stable across runs — no
run id, no timestamp — so a re-run recognises its own block as already
present instead of appending it again.
required: false
default: attest-sign
attempts:
description: How many times to retry a push rejected because another job attested the same tree meanwhile.
required: false
default: "5"
binary:
description: >-
TEST-ONLY: a git-attest binary to run instead of the pinned release,
relative to the workspace (see the verifying action). Setting it prints
a notice.
required: false
default: ""
outputs:
status:
description: >-
One of: pushed, local, already-present, no-key, bad-key, no-gates,
dirty, sign-failed, push-failed, error.
value: ${{ steps.sign.outputs.status }}
signed:
description: Whether a new block for this tree exists locally after this step.
value: ${{ steps.sign.outputs.signed }}
pushed:
description: Whether the remote holds a block for these gates.
value: ${{ steps.sign.outputs.pushed }}
gates:
description: The normalised, space-separated list that was (or would have been) signed.
value: ${{ steps.sign.outputs.gates }}
inputs:
description: >-
Input fingerprints, as published/wanted: how many of the signed gates
the committed attest-inputs spec declares, and how many of those were
published under their fingerprint key. `0/0` without a spec.
value: ${{ steps.sign.outputs.inputs }}
runs:
using: composite
steps:
- id: sign
shell: bash
env:
ATTEST_KEY: ${{ inputs.key }}
ATTEST_GATES: ${{ inputs.gates }}
ATTEST_PLATFORM: ${{ inputs.platform }}
ATTEST_OBJECT: ${{ inputs.object }}
ATTEST_REMOTE: ${{ inputs.remote }}
ATTEST_PUSH: ${{ inputs.push }}
ATTEST_ALLOW_DIRTY: ${{ inputs.allow-dirty }}
ATTEST_PRODUCER: ${{ inputs.producer }}
ATTEST_ATTEMPTS: ${{ inputs.attempts }}
ATTEST_BINARY: ${{ inputs.binary }}
run: |
set -u
# The key leaves the ENVIRONMENT first: a step's env is inherited by
# every process it starts — git, ssh-keygen, any hook or credential
# helper — so it is copied into a shell variable and unset. From there
# it reaches `git-attest sign` (or sign.sh) on stdin only: printf is a
# builtin, so it is never on an argv, and never in the script text.
# (Each then holds it in a 0600 file of its own under a private
# temporary directory, removed on every exit — ssh-keygen reads keys
# from nowhere else.)
key=$ATTEST_KEY
unset ATTEST_KEY
# Positional parameters rather than a bash array (bash 3.2 on macOS
# runners, under `set -u`).
set --
if [ -n "$ATTEST_PLATFORM" ]; then set -- "$@" --platform "$ATTEST_PLATFORM"; fi
if [ -n "$ATTEST_OBJECT" ]; then set -- "$@" --object "$ATTEST_OBJECT"; fi
if [ -n "$ATTEST_REMOTE" ]; then set -- "$@" --remote "$ATTEST_REMOTE"; fi
if [ -n "$ATTEST_PRODUCER" ]; then set -- "$@" --producer "$ATTEST_PRODUCER"; fi
if [ -n "$ATTEST_ATTEMPTS" ]; then set -- "$@" --attempts "$ATTEST_ATTEMPTS"; fi
[ "$ATTEST_PUSH" = true ] || set -- "$@" --no-push
[ "$ATTEST_ALLOW_DIRTY" != true ] || set -- "$@" --allow-dirty
# The binary: the pinned release, or (tests only) the one given.
bin= why=
if [ -n "$ATTEST_BINARY" ]; then
echo "::notice title=attest::binary: is test-only; running $ATTEST_BINARY, not the pinned release"
bin=$ATTEST_BINARY
else
err=$(mktemp)
bin=$(bash "$GITHUB_ACTION_PATH/../fetch.sh" 2> "$err") || bin=
cat "$err" >&2
if [ -z "$bin" ]; then
why=$(tail -n 1 "$err" | sed 's/^attest: //')
[ -n "$why" ] || why="fetch.sh named no binary"
fi
rm -f "$err"
fi
# GitHub runs this under `-e`: every status below is tested, not left
# to end the step.
out=
if [ -n "$bin" ]; then
if out=$(printf '%s' "$key" | "$bin" sign --github-output --gates "$ATTEST_GATES" "$@"); then
case $'\n'"$out" in
*$'\n'status=*) ;;
*) why="git-attest sign wrote no status= line" ;;
esac
else
why="git-attest sign exited $?"
fi
fi
if [ -n "$why" ]; then
echo "::warning title=attest::$why; sign.sh signs instead"
# Outputs exist whatever happens; a script that could not even start
# still yields a well-formed, negative answer.
out=$(printf '%s' "$key" \
| ATTEST_NO_DEPRECATION=1 bash "$GITHUB_ACTION_PATH/sign.sh" --github-output --gates "$ATTEST_GATES" "$@") \
|| out=$(printf '%s\nstatus=error\nsigned=false\npushed=false\ngates=\ninputs=0/0' "$out")
fi
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"