Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/actions/upload-release-assets/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Upload release assets
description: >-
Upload assets to a draft release, but only while it is still a draft that
targets the commit the assets were built from.
inputs:
tag:
description: Tag name of the draft release.
required: true
sha:
description: Full commit SHA the assets were built from.
required: true
files:
description: Files to upload, one path or glob per line. Each must match at least one file.
required: true
token:
description: Token with contents write access.
required: false
default: ${{ github.token }}
runs:
using: composite
steps:
- name: Upload release assets
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
TAG: ${{ inputs.tag }}
SHA: ${{ inputs.sha }}
FILES: ${{ inputs.files }}
run: bash "$GITHUB_ACTION_PATH/upload-release-assets.sh"
118 changes: 118 additions & 0 deletions .github/actions/upload-release-assets/test-upload-release-assets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -euo pipefail

# Runs upload-release-assets.sh against a stub gh whose release state lives in
# files, so a test can publish or move the draft mid-upload.

ROOT="$(cd "$(dirname "$0")" && pwd)"
TESTDIR="$(mktemp -d)"
trap 'rm -rf "$TESTDIR"' EXIT

SHA=1111111111111111111111111111111111111111
OTHER=2222222222222222222222222222222222222222

mkdir -p "$TESTDIR/bin"
# State: $STATE/draft (true|false), $STATE/target, $STATE/missing (exists =
# no release). $STATE/on-upload, if present, is run by `gh release upload`.
cat > "$TESTDIR/bin/gh" << 'GH'
#!/usr/bin/env bash
set -euo pipefail
echo "gh $*" >> "$STATE/calls"
case "$1 $2" in
"release view")
[ ! -e "$STATE/missing" ] || exit 1
echo "$(cat "$STATE/draft") $(cat "$STATE/target")"
;;
"release edit") ;;
"release upload")
if [ -e "$STATE/on-upload" ]; then bash "$STATE/on-upload"; fi
;;
*) echo "unexpected gh call: $*" >&2; exit 2 ;;
esac
GH
chmod +x "$TESTDIR/bin/gh"

fail=0

# run <want: pass|fail> <description> <draft> <target> [files] [on-upload]
run() {
local want=$1 desc=$2 got out
export STATE="$TESTDIR/state-$RANDOM"
mkdir -p "$STATE"
echo "$3" > "$STATE/draft"
echo "$4" > "$STATE/target"
[ -z "${6:-}" ] || echo "$6" > "$STATE/on-upload"

if out=$(cd "$TESTDIR/work" && PATH="$TESTDIR/bin:$PATH" TAG=v1.2.3 SHA="${SHA_OVERRIDE:-$SHA}" \
FILES="${5:-bin/app-*
checksums.txt}" GITHUB_REPOSITORY=org/repo bash "$ROOT/upload-release-assets.sh" 2>&1); then
got=pass
else
got=fail
fi

if [ "$got" = "$want" ]; then
echo "ok $desc"
else
echo "FAIL $desc: expected $want, got $got"
printf " %s\n" "$out"
fail=1
fi
LAST_OUT=$out
LAST_CALLS=$(cat "$STATE/calls" 2> /dev/null || true)
}

mkdir -p "$TESTDIR/work/bin"
touch "$TESTDIR/work/bin/app-linux" "$TESTDIR/work/bin/app-darwin" \
"$TESTDIR/work/checksums.txt" "$TESTDIR/work/with space.txt"

# has <text> <pattern> / lacks <text> <pattern>: assert on the last run.
has() { grep -q -- "$2" <<< "$1" || bad "expected '$2' in: $1"; }
lacks() { ! grep -q -- "$2" <<< "$1" || bad "unexpected '$2' in: $1"; }
bad() {
echo "FAIL $*"
fail=1
}

run pass "uploads to a draft that targets the built commit" true "$SHA"
has "$LAST_CALLS" 'release upload v1.2.3 bin/app-darwin bin/app-linux checksums.txt --repo org/repo --clobber'
has "$LAST_CALLS" 'release edit v1.2.3 --repo org/repo --title v1.2.3'

run fail "refuses a published release" false "$SHA"
has "$LAST_OUT" 'was published before the upload'
lacks "$LAST_CALLS" 'release edit'
lacks "$LAST_CALLS" 'release upload'

run fail "refuses a draft moved to another commit" true "$OTHER"
has "$LAST_OUT" "now targets $OTHER"
lacks "$LAST_CALLS" 'release upload'

run fail "refuses a draft that targets a branch" true main

# The stub runs this itself, so it expands $STATE there.
# shellcheck disable=SC2016
run fail "reports a release published during the upload" true "$SHA" "" 'echo false > "$STATE/draft"'
has "$LAST_OUT" 'was published during the upload'
lacks "$LAST_CALLS" 'release edit'

run fail "fails when a pattern matches nothing" true "$SHA" "bin/app-*
provenance/*.intoto.jsonl"
lacks "$LAST_CALLS" 'release upload'

run pass "keeps a path with spaces whole" true "$SHA" "with space.txt"
has "$LAST_CALLS" 'release upload v1.2.3 with space.txt --repo'

SHA_OVERRIDE=1111111 run fail "rejects a short sha" true 1111111
unset SHA_OVERRIDE

export STATE="$TESTDIR/missing"
mkdir -p "$STATE"
touch "$STATE/missing"
if (cd "$TESTDIR/work" && PATH="$TESTDIR/bin:$PATH" TAG=v1.2.3 SHA=$SHA FILES=checksums.txt \
GITHUB_REPOSITORY=org/repo bash "$ROOT/upload-release-assets.sh" > /dev/null 2>&1); then
bad "a missing release passed"
else
echo "ok fails when the release does not exist"
fi

exit "$fail"
69 changes: 69 additions & 0 deletions .github/actions/upload-release-assets/upload-release-assets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Uploads release assets only to a draft that still targets the commit they
# were built from. Publishing makes a release immutable, and a newer run may
# have moved the draft on, so both are checked before and after the upload.
#
# Env: TAG, SHA (full commit), FILES (one path or glob per line),
# GITHUB_REPOSITORY, GH_TOKEN.
set -euo pipefail

: "${TAG:?TAG is required}"
: "${SHA:?SHA is required}"
: "${FILES:?FILES is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"

if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::sha must be a full commit SHA, got '${SHA}'."
exit 1
fi

files=()
shopt -s nullglob
while IFS= read -r pattern; do
pattern="${pattern#"${pattern%%[![:space:]]*}"}"
pattern="${pattern%"${pattern##*[![:space:]]}"}"
[ -n "$pattern" ] || continue
# Unquoted on purpose: the pattern is a glob. An empty IFS keeps paths
# with spaces whole.
IFS=
# shellcheck disable=SC2206
matches=($pattern)
unset IFS
if [ ${#matches[@]} -eq 0 ]; then
echo "::error::No file matches '${pattern}'."
exit 1
fi
files+=("${matches[@]}")
done <<< "$FILES"
shopt -u nullglob

# check <when>: fails unless TAG is a draft targeting SHA.
check() {
local state is_draft target
if ! state=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json isDraft,targetCommitish --jq '"\(.isDraft) \(.targetCommitish)"'); then
echo "::error::Failed to look up release ${TAG} $1. See the gh error above."
exit 1
fi
read -r is_draft target <<< "$state"

if [ "$is_draft" != "true" ]; then
echo "::error::Release ${TAG} was published $1. Published releases are immutable, so its assets are missing or were built from another commit. Do not publish while the Release run is building assets; cut the next version."
exit 1
fi
if [ "$target" != "$SHA" ]; then
echo "::error::Draft ${TAG} now targets ${target}, but these assets were built from ${SHA} (checked $1). A newer run owns the draft; it builds its own assets."
exit 1
fi
}

check "before the upload"
echo "Uploading ${#files[@]} assets to draft ${TAG} at ${SHA}."
# --clobber makes a retry idempotent; it can only replace assets on a draft.
gh release upload "$TAG" "${files[@]}" --repo "$GITHUB_REPOSITORY" --clobber
check "during the upload"

# The release workflow titles the draft "<tag> (assets pending)"; drop the
# marker now that the draft is complete.
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TAG" > /dev/null
echo "Draft ${TAG} has all its assets and can be published."
Loading
Loading