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"