diff --git a/README.md b/README.md index 81bef70..8aa4fe3 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ When a PR is merged (and the release label requirement is met, if enabled): When the release PR (detected by the `release/` branch prefix) is merged: -1. Creates an annotated git tag +1. Creates an annotated tag (through the GitHub API, so the default `GITHUB_TOKEN` works — no PAT needed) 2. Optionally creates a GitHub release with auto-generated release notes 3. Optionally publishes to an npm registry @@ -437,10 +437,15 @@ The workflow needs `contents: write` (for tags and releases) and `pull-requests: publishing to GitHub Packages, also add `packages: write`. If using the default `GITHUB_TOKEN`, set these under the job's `permissions` key. +The release tag is created through the GitHub API (not `git push`), so the default `GITHUB_TOKEN` is sufficient — you +do **not** need a personal access token with the `workflow` scope, even when your repo contains files under +`.github/workflows/`. (A `git push` of the tag would be rejected for `GITHUB_TOKEN` in that case; the API path avoids +it.) + ## Tests ```shell -bats tests/conventional.bats +bats tests/ ``` ## Contributing diff --git a/scripts/finalize-release.sh b/scripts/finalize-release.sh index 9a55f60..803747a 100755 --- a/scripts/finalize-release.sh +++ b/scripts/finalize-release.sh @@ -8,6 +8,7 @@ source "$SCRIPT_DIR/conventional.sh" source "$SCRIPT_DIR/workspaces.sh" source "$SCRIPT_DIR/install.sh" source "$SCRIPT_DIR/npm-auth.sh" +source "$SCRIPT_DIR/tag.sh" main() { # Extract version from branch name (release/v1.2.3 → v1.2.3) @@ -21,13 +22,13 @@ main() { if [[ "$INPUT_CREATE_TAG" == "true" ]]; then echo "::group::Creating tag: ${tag_name}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + # Tag the checked-out commit (the PR merge commit) via the GitHub API so the + # default GITHUB_TOKEN suffices — see tag.sh. + local commit_sha + commit_sha=$(git rev-parse HEAD) + create_tag "$GITHUB_REPOSITORY" "$tag_name" "$commit_sha" "Release ${tag_name}" - git tag -a "$tag_name" -m "Release ${tag_name}" - git push origin "$tag_name" - - echo "Tag ${tag_name} created and pushed" + echo "Tag ${tag_name} created" echo "::endgroup::" echo "tag=${tag_name}" >> "$GITHUB_OUTPUT" diff --git a/scripts/tag.sh b/scripts/tag.sh new file mode 100644 index 0000000..a642f27 --- /dev/null +++ b/scripts/tag.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Release tag creation. +# +# Tags are created through the GitHub API rather than `git push`. A `git push` +# of a ref whose commits touch .github/workflows/ is rejected for the default +# GITHUB_TOKEN ("refusing to allow a GitHub App to create or update workflow ... +# without `workflows` permission"), which would otherwise force consumers to +# supply a PAT with the `workflow` scope. Creating a ref via the API points at a +# commit that already exists on the server — no push, no workflow-file +# protection — so a plain GITHUB_TOKEN with `contents: write` is enough. + +set -euo pipefail + +# Create an annotated tag via the GitHub API: first a tag object, then a ref +# pointing at it. Uses gh (authenticated via GH_TOKEN). +# Usage: create_tag +create_tag() { + local repo="$1" tag_name="$2" commit_sha="$3" message="$4" + + # Create the annotated tag object and read back its SHA. + local tag_sha + tag_sha=$(gh api "repos/${repo}/git/tags" \ + -f tag="$tag_name" \ + -f message="$message" \ + -f object="$commit_sha" \ + -f type=commit \ + | node -e 'const fs=require("fs");console.log(JSON.parse(fs.readFileSync(0,"utf8")).sha)') + + # Point refs/tags/ at the tag object. + gh api "repos/${repo}/git/refs" \ + -f ref="refs/tags/${tag_name}" \ + -f sha="$tag_sha" > /dev/null +} diff --git a/tests/tag.bats b/tests/tag.bats new file mode 100644 index 0000000..492e57a --- /dev/null +++ b/tests/tag.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats + +setup() { + source "$BATS_TEST_DIRNAME/../scripts/tag.sh" + + # Stub `gh` on PATH: record every call (one arg per line) and emit a tag-object + # SHA for the git/tags endpoint so create_tag can read it back. + STUB_DIR="$(mktemp -d)" + export GH_CALLS="$STUB_DIR/calls.log" + + cat > "$STUB_DIR/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" >> "$GH_CALLS" +case "$*" in + *git/tags*) echo '{"sha":"TAGOBJ123"}' ;; + *) echo '{}' ;; +esac +EOF + chmod +x "$STUB_DIR/gh" + PATH="$STUB_DIR:$PATH" +} + +teardown() { + rm -rf "$STUB_DIR" +} + +@test "create_tag: succeeds and hits both API endpoints" { + run create_tag "owner/repo" "v1.2.3" "abc123" "Release v1.2.3" + [ "$status" -eq 0 ] + grep -Fxq "repos/owner/repo/git/tags" "$GH_CALLS" + grep -Fxq "repos/owner/repo/git/refs" "$GH_CALLS" +} + +@test "create_tag: creates the tag object from the commit SHA" { + create_tag "owner/repo" "v1.2.3" "abc123" "Release v1.2.3" + grep -Fxq "tag=v1.2.3" "$GH_CALLS" + grep -Fxq "object=abc123" "$GH_CALLS" + grep -Fxq "type=commit" "$GH_CALLS" + grep -Fxq "message=Release v1.2.3" "$GH_CALLS" +} + +@test "create_tag: points the ref at the returned tag-object SHA" { + create_tag "owner/repo" "v1.2.3" "abc123" "Release v1.2.3" + grep -Fxq "ref=refs/tags/v1.2.3" "$GH_CALLS" + # The ref must use the tag object's SHA from the first call, not the commit SHA. + grep -Fxq "sha=TAGOBJ123" "$GH_CALLS" + ! grep -Fxq "sha=abc123" "$GH_CALLS" +} + +@test "create_tag: never shells out to git push" { + # The whole point of the API approach is to avoid `git push`. Stub git too and + # assert it is not invoked. + cat > "$STUB_DIR/git" <<'EOF' +#!/usr/bin/env bash +echo "git $*" >> "$GH_CALLS" +EOF + chmod +x "$STUB_DIR/git" + + create_tag "owner/repo" "v1.2.3" "abc123" "Release v1.2.3" + ! grep -q "^git " "$GH_CALLS" +}