Skip to content
Merged
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
67 changes: 67 additions & 0 deletions .github/actions/create-release/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Create a draft release
description: >-
Tag a commit and open a draft GitHub release with artifacts attached.
Always a draft: publishing stays a human act.

inputs:
tag:
description: Tag to create, e.g. v1.2.3.
required: true
title:
description: Release title. Defaults to the tag.
required: false
default: ""
notes-file:
description: >-
Markdown file prepended to GitHub's generated notes. Use it for the one
or two sentences a human wrote about this release.
required: false
default: ""
target:
description: >-
Commit to tag. Defaults to HEAD. Tag the commit that already has the
regenerated files, not the one that only bumped the version.
required: false
default: ""
artifacts:
description: Files to attach, one path per line.
required: false
default: ""
generate-notes:
description: Include GitHub's commit-derived notes.
required: false
default: "true"
dry-run:
description: Dry run. Show what would be created, and change nothing.
required: false
default: "false"
token:
description: Token gh authenticates with.
required: false
default: ${{ github.token }}

runs:
using: composite
steps:
- name: Create the draft
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
TAG: ${{ inputs.tag }}
TITLE: ${{ inputs.title }}
NOTES_FILE: ${{ inputs.notes-file }}
TARGET: ${{ inputs.target }}
ARTIFACTS: ${{ inputs.artifacts }}
GENERATE_NOTES: ${{ inputs.generate-notes }}
DRY_RUN: ${{ inputs.dry-run }}
run: |
ARGS=(--tag "$TAG")
[ -n "$TITLE" ] && ARGS+=(--title "$TITLE")
[ -n "$NOTES_FILE" ] && ARGS+=(--notes-file "$NOTES_FILE")
[ -n "$TARGET" ] && ARGS+=(--target "$TARGET")
[ "$GENERATE_NOTES" = "false" ] && ARGS+=(--no-generate-notes)
[ "$DRY_RUN" = "true" ] && ARGS+=(--dry-run)
while IFS= read -r line; do
[ -n "$line" ] && ARGS+=(--artifact "$line")
done <<< "$ARTIFACTS"
"${{ github.action_path }}/../../../bin/create-release.sh" "${ARGS[@]}"
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ what moved. See

## [Unreleased]

### Added

- `create-release` action and `bin/create-release.sh`: tag a commit and open a
**draft** GitHub release with artifacts attached. Publishing stays a human
act. It refuses to run if the tag or release already exists, and fails on an
artifact that is missing or zero bytes.

### Changed

- `publish-to-s3`'s `dry-run` description now says "dry run". It described the
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ decision instead of a rewrite.
| `index-site` | Pagefind search index over built HTML — works with any generator |
| `deploy-site` | Checks the build produced a real site, uploads it for Pages |
| `publish-to-s3` | Uploads built assets to S3 over OIDC and invalidates CloudFront |
| `create-release` | Tags a commit and opens a draft GitHub release with artifacts |

Adding a generator means one new `build-*` action; nothing downstream changes.

Expand Down Expand Up @@ -305,6 +306,32 @@ The deploy itself is not in this action: `actions/deploy-pages` needs a
job-level `environment:`, which a composite action cannot declare. Put it in
your own deploy job, or use a `docs-*.yml` reusable workflow.

### `create-release`

| Input | Default | |
| --- | --- | --- |
| `tag` | — | Tag to create, e.g. `v1.2.3` (required) |
| `title` | the tag | Release title |
| `notes-file` | — | Markdown prepended to the generated notes |
| `target` | `HEAD` | Commit to tag |
| `artifacts` | — | Files to attach, one path per line |
| `generate-notes` | `true` | Include GitHub's commit-derived notes |
| `dry-run` | `false` | Dry run. Show what would be created, and change nothing |
| `token` | `github.token` | Token `gh` authenticates with |

**Always a draft.** Publishing stays a human act: someone reads the notes,
checks the artifacts, and presses the button. Nothing here publishes, and
nothing here decides a version — the caller has already bumped whatever holds
it and committed that.

`target` matters when the release follows a metadata regeneration. Tag the
commit that has the regenerated files, not the one that only bumped the
version, or the archive ships metadata describing the previous release.

It refuses to run if the tag or the release already exists, and it fails on an
artifact that is missing or zero bytes — a release whose files are empty looks
fine until someone downloads one.

## Adding a generator

One new action; nothing downstream changes. The contract every builder
Expand Down Expand Up @@ -340,7 +367,10 @@ Every script takes `--help`.
You need [Pandoc](https://pandoc.org/) for `build-pandoc.sh`,
[Zensical](https://zensical.org/) for `build-zensical.sh`, your project's
documentation requirements for `build-sphinx.sh`, and
[Pagefind](https://pagefind.app/) for `index-site.sh`.
[Pagefind](https://pagefind.app/) for `index-site.sh`. `create-release.sh`
needs the [`gh` CLI](https://cli.github.com/), already authenticated as
yourself; `publish-to-s3.sh` needs the AWS CLI and whatever profile you
normally use.

## Versioning

Expand Down
104 changes: 104 additions & 0 deletions bin/create-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# Tag a commit and open a draft GitHub release for it, with artifacts attached.
#
# Draft, always. Publishing is a human act: someone reads the notes, checks the
# artifacts, and presses the button. Nothing here publishes, and nothing here
# decides a version -- the caller has already bumped whatever holds it and
# committed that, so this tags what is in front of it.
#
# create-release.sh --tag v1.2.3 --notes-file notes.md --artifact dist/app.zip
#
# Authentication is not handled here. The caller is expected to have gh
# authenticated already -- in CI that is GH_TOKEN, locally it is your own
# gh login.

set -euo pipefail

TAG=""
TITLE=""
NOTES_FILE=""
TARGET=""
DRY_RUN="false"
GENERATE_NOTES="true"
declare -a ARTIFACTS=()

usage() {
cat <<'USAGE'
create-release.sh -- tag a commit and open a draft GitHub release

Options:
--tag NAME tag to create, e.g. v1.2.3 (required)
--title TEXT release title (default: the tag)
--notes-file FILE Markdown prepended to the generated notes
--target SHA commit to tag (default: HEAD)
--artifact PATH file to attach, repeatable
--no-generate-notes omit GitHub's commit-derived notes
--dry-run show what would happen, change nothing
-h, --help this text
USAGE
}

while [ $# -gt 0 ]; do
case "$1" in
--tag) TAG="$2"; shift 2 ;;
--title) TITLE="$2"; shift 2 ;;
--notes-file) NOTES_FILE="$2"; shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--artifact) ARTIFACTS+=("$2"); shift 2 ;;
--no-generate-notes) GENERATE_NOTES="false"; shift ;;
--dry-run) DRY_RUN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "create-release: unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done

[ -n "$TAG" ] || { echo "create-release: --tag is required" >&2; exit 2; }
command -v gh >/dev/null || { echo "create-release: gh is not installed" >&2; exit 1; }

[ -n "$TITLE" ] || TITLE="$TAG"
[ -n "$TARGET" ] || TARGET="$(git rev-parse HEAD)"

if [ -n "$NOTES_FILE" ] && [ ! -f "$NOTES_FILE" ]; then
echo "create-release: no such file: $NOTES_FILE" >&2
exit 1
fi

# An empty or missing artifact means the build silently produced nothing, which
# is the failure worth catching -- a release with no files looks fine until
# someone tries to download one.
for f in ${ARTIFACTS[@]+"${ARTIFACTS[@]}"}; do
[ -f "$f" ] || { echo "create-release: no such artifact: $f" >&2; exit 1; }
[ -s "$f" ] || { echo "create-release: artifact is empty: $f" >&2; exit 1; }
done

if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "create-release: tag already exists: $TAG" >&2
exit 1
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "create-release: a release already exists for $TAG" >&2
exit 1
fi

declare -a ARGS=("$TAG" --draft --title "$TITLE" --target "$TARGET")
[ "$GENERATE_NOTES" = "true" ] && ARGS+=(--generate-notes)
[ -n "$NOTES_FILE" ] && ARGS+=(--notes-file "$NOTES_FILE")
for f in ${ARTIFACTS[@]+"${ARTIFACTS[@]}"}; do ARGS+=("$f"); done

echo "tag: $TAG"
echo "target: $TARGET"
echo "title: $TITLE"
echo "artifacts: ${#ARTIFACTS[@]}"
for f in ${ARTIFACTS[@]+"${ARTIFACTS[@]}"}; do echo " $f"; done

if [ "$DRY_RUN" = "true" ]; then
echo "==> would run: gh release create ${ARGS[*]}"
echo "create-release: dry run, nothing created"
exit 0
fi

gh release create "${ARGS[@]}"

URL="$(gh release view "$TAG" --json url --jq .url)"
echo "create-release: draft created, publish it at $URL"