From 7ff98868cc1020f87fe8e064c61c8d0cee8c353e Mon Sep 17 00:00:00 2001 From: Tommy Keswick Date: Thu, 10 Sep 2026 09:01:06 -0700 Subject: [PATCH] Add a create-release action Tags a commit and opens a draft GitHub release with artifacts attached. Always a draft: publishing stays a human act, so nothing here publishes and nothing here decides a version. The caller has already bumped whatever holds the version and committed that, so this tags what is in front of it. target exists because the commit to tag is the one carrying the regenerated files, not the one that only bumped the version -- tag the wrong one and the archive ships metadata describing the previous release. It refuses to run if the tag or the release already exists, and fails on an artifact that is missing or zero bytes. A release whose files are empty looks fine until someone downloads one. --- .github/actions/create-release/action.yml | 67 ++++++++++++++ CHANGELOG.md | 7 ++ README.md | 32 ++++++- bin/create-release.sh | 104 ++++++++++++++++++++++ 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 .github/actions/create-release/action.yml create mode 100755 bin/create-release.sh diff --git a/.github/actions/create-release/action.yml b/.github/actions/create-release/action.yml new file mode 100644 index 0000000..2018de9 --- /dev/null +++ b/.github/actions/create-release/action.yml @@ -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[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 90452ca..9ba686e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c39e9a8..e6e44be 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 diff --git a/bin/create-release.sh b/bin/create-release.sh new file mode 100755 index 0000000..4f25d45 --- /dev/null +++ b/bin/create-release.sh @@ -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"