Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
19737a9
Add release automation
wallstop Jun 30, 2026
e2298f0
Allow release workflows on moved repository
wallstop Jun 30, 2026
2977500
Fix release preparation edge cases
wallstop Jun 30, 2026
5728ac7
Harden release validation guards
wallstop Jun 30, 2026
db5ee2b
Set repository context for release gh commands
wallstop Jun 30, 2026
94797e7
Address release workflow review findings
wallstop Jun 30, 2026
1d47f66
Harden changelog fence parsing
wallstop Jun 30, 2026
3d1cd9c
Include all Unity package roots in releases
wallstop Jun 30, 2026
257526c
Harden release workflow branch checks
wallstop Jun 30, 2026
a9a6d44
Harden release package validation
wallstop Jun 30, 2026
81e607d
Align release unitypackage timeout budget
wallstop Jun 30, 2026
7f2d35d
Clarify release contract array parsing
wallstop Jun 30, 2026
bcd539e
Avoid tag checks for non-release pushes
wallstop Jun 30, 2026
92b2266
Harden release lookup validation
wallstop Jun 30, 2026
56a9395
Include scripts metadata in release package
wallstop Jun 30, 2026
86438f3
Include postinstall hook metadata in releases
wallstop Jun 30, 2026
c75878b
Serialize Unity workflow tiers
wallstop Jun 30, 2026
6b0f7d6
Avoid stale release tag warnings
wallstop Jun 30, 2026
88129a6
Harden release tag filters
wallstop Jun 30, 2026
ad4bc31
Harden release review edge cases
wallstop Jul 1, 2026
b9a865c
Clarify release tag glob semantics
wallstop Jul 1, 2026
de8756a
Harden Unity package metadata export
wallstop Jul 1, 2026
a0fa415
Use unambiguous release tag trigger
wallstop Jul 1, 2026
a25d75b
Include hidden files in package validator scans
wallstop Jul 1, 2026
8b2a360
Use structured PowerShell relative paths
wallstop Jul 1, 2026
2688c95
Validate changelog content before tagging releases
wallstop Jul 1, 2026
5a8e93e
Prevent release drafter publish races
wallstop Jul 1, 2026
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
231 changes: 231 additions & 0 deletions .github/workflows/release-prepare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
name: Release Prepare

on:
workflow_dispatch:
inputs:
bump:
description: "Semver bump kind"
type: choice
options:
- patch
- minor
- major
default: patch
version:
description: "Explicit version X.Y.Z. When set, this wins over bump."
type: string
required: false
dry_run:
description: "Validate and print the prepared diff without pushing a PR"
type: boolean
default: false

permissions:
contents: read

concurrency:
group: release-prepare
cancel-in-progress: false

jobs:
prepare:
name: Prepare release PR
if: github.repository == 'wallstop/unity-helpers' || github.repository == 'Ambiguous-Interactive/unity-helpers'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Require default branch dispatch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if [ "${GITHUB_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]; then
echo "::error::Release Prepare must run from ${DEFAULT_BRANCH}; got ${GITHUB_REF}."
exit 1
fi

- name: Check auto-commit GitHub App credentials
env:
AUTO_COMMIT_APP_ID: ${{ secrets.AUTO_COMMIT_APP_ID }}
AUTO_COMMIT_APP_PRIVATE_KEY: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
if [ -n "${AUTO_COMMIT_APP_ID:-}" ] && [ -n "${AUTO_COMMIT_APP_PRIVATE_KEY:-}" ]; then
exit 0
fi
if [ "${DRY_RUN}" = "true" ]; then
echo "::warning::AUTO_COMMIT_APP_* secrets are missing; dry-run can continue, but a real release PR cannot be pushed."
exit 0
fi
echo "::error::AUTO_COMMIT_APP_ID and AUTO_COMMIT_APP_PRIVATE_KEY are required to push the release PR with a token that triggers CI."
exit 1

- name: Generate auto-commit GitHub App token
id: app-token
if: ${{ !inputs.dry_run }}
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.AUTO_COMMIT_APP_ID }}
private-key: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token || github.token }}
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"

- name: Install Node dependencies
run: |
set -euo pipefail
if [ -f package-lock.json ]; then
npm ci
else
npm i --no-audit --no-fund --package-lock=false
fi

- name: Prepare release files
shell: pwsh
env:
BUMP: ${{ inputs.bump }}
EXPLICIT_VERSION: ${{ inputs.version }}
run: |
$arguments = @('-Bump', $env:BUMP)
if (-not [string]::IsNullOrWhiteSpace($env:EXPLICIT_VERSION)) {
$arguments += @('-Version', $env:EXPLICIT_VERSION)
}
./scripts/release-tools/prepare-release.ps1 @arguments

- name: Sync generated version references
shell: pwsh
run: |
./scripts/sync-banner-version.ps1
./scripts/sync-issue-template-versions.ps1

- name: Validate prepared release tree
run: |
set -euo pipefail
npm run test:release-tools
npm run lint:changelog
npm run test:npm-package-changelog
npm run test:npm-package-signature
npm run validate:npm-package
npm run format:check

- name: Resolve prepared version
id: version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version="$(jq -r '.version // empty' package.json)"
if ! printf '%s\n' "${version}" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; then
echo "::error::package.json version '${version}' is not strict X.Y.Z semver."
exit 1
fi
branch="release/${version}"
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${version}" >/dev/null 2>&1; then
echo "::error::Tag ${version} already exists."
exit 1
fi
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${branch}" >/dev/null 2>&1; then
echo "::error::Branch ${branch} already exists."
exit 1
Comment thread
cursor[bot] marked this conversation as resolved.
fi
{
echo "version=${version}"
echo "branch=${branch}"
} >> "${GITHUB_OUTPUT}"

- name: Print dry-run diff
if: ${{ inputs.dry_run }}
env:
VERSION: ${{ steps.version.outputs.version }}
BRANCH: ${{ steps.version.outputs.branch }}
run: |
set -euo pipefail
echo "Dry run: would push ${BRANCH} and open a PR titled 'release: ${VERSION}'."
echo "::group::Prepared diff"
git --no-pager diff HEAD --
echo "::endgroup::"

- name: Push release branch and open PR
if: ${{ !inputs.dry_run }}
env:
GH_PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ steps.version.outputs.version }}
BRANCH: ${{ steps.version.outputs.branch }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "${BRANCH}"

git add package.json CHANGELOG.md docs/images/unity-helpers-banner.svg .llm/context.md \
.github/ISSUE_TEMPLATE/bug_report.yml .github/ISSUE_TEMPLATE/feature_request.yml
if git ls-files --error-unmatch package-lock.json >/dev/null 2>&1; then
git add package-lock.json
fi

if git diff --cached --quiet; then
echo "::error::Release preparation staged no changes."
exit 1
fi

git commit -m "release: ${VERSION}"

mkdir -p artifacts/release-prepare
git format-patch -1 --stdout > "artifacts/release-prepare/release-${VERSION}.patch"
git show --stat --oneline --decorate --no-renames HEAD > "artifacts/release-prepare/release-${VERSION}.stat.txt"

auth_header="$(printf 'x-access-token:%s' "${GH_PUSH_TOKEN}" | base64 | tr -d '\n')"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
push origin "HEAD:refs/heads/${BRANCH}"

notes_file="${RUNNER_TEMP}/release-notes.md"
pwsh -NoProfile -File scripts/release-tools/write-release-notes.ps1 \
-Version "${VERSION}" \
-OutputPath "${notes_file}"
Comment thread
cursor[bot] marked this conversation as resolved.

pr_body_file="${RUNNER_TEMP}/release-pr-body.md"
{
echo "Automated release preparation for ${VERSION}."
echo
echo "## Checklist"
echo
echo "- [ ] \`package.json\` version is \`${VERSION}\`."
echo "- [ ] \`CHANGELOG.md\` has a reviewed \`## [${VERSION}]\` section."
echo "- [ ] Squash-merge with the default \`release: ${VERSION}\` title."
echo
echo "## Release Notes"
echo
cat "${notes_file}"
echo
echo "## After Merge"
echo
echo "The Release Tag workflow pushes tag \`${VERSION}\`, then Release Publish validates, packs npm, exports the \`.unitypackage\`, publishes npm, and publishes the GitHub Release assets."
} > "${pr_body_file}"

GH_TOKEN="${GH_PUSH_TOKEN}" gh pr create \
--repo "${GITHUB_REPOSITORY}" \
--base "${DEFAULT_BRANCH}" \
--head "${BRANCH}" \
--title "release: ${VERSION}" \
--body-file "${pr_body_file}"

- name: Upload release preparation recovery patch
if: ${{ failure() && !inputs.dry_run }}
uses: actions/upload-artifact@v7
with:
name: release-prepare-recovery-${{ github.run_id }}-${{ github.run_attempt }}
path: artifacts/release-prepare/
if-no-files-found: ignore
112 changes: 112 additions & 0 deletions .github/workflows/release-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Release Tag

on:
push:
branches:
- main
- master
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
paths:
- "package.json"
- "CHANGELOG.md"

permissions:
contents: read

concurrency:
group: release-tag-${{ github.ref }}
cancel-in-progress: false

jobs:
tag:
name: Tag release commit
if: github.repository == 'wallstop/unity-helpers' || github.repository == 'Ambiguous-Interactive/unity-helpers'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false

- name: Detect release commit
id: detect
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version="$(jq -r '.version // empty' package.json)"
if ! printf '%s\n' "${version}" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; then
echo "::error::package.json version '${version}' is not strict X.Y.Z semver."
exit 1
fi

if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${version}" >/dev/null 2>&1; then
echo "Tag ${version} already exists; nothing to do."
echo "proceed=false" >> "${GITHUB_OUTPUT}"
exit 0
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
fi

subject="$(git log -1 --format=%s)"
escaped_version="${version//./\\.}"
if [ "${subject}" != "release: ${version}" ] \
&& ! printf '%s\n' "${subject}" | grep -Eq "^release: ${escaped_version} \(#[0-9]+\)$"; then
# shellcheck disable=SC2016 # PowerShell $env:CHANGELOG_VERSION is expanded by pwsh, not bash.
if CHANGELOG_VERSION="${version}" pwsh -NoProfile -Command '
. ./scripts/release-tools/release-helpers.ps1
$content = [System.IO.File]::ReadAllText("CHANGELOG.md")
if (-not (Test-ChangelogVersionHeading -Content $content -Version $env:CHANGELOG_VERSION)) {
exit 1
}
'; then
message="Version ${version} is untagged and CHANGELOG.md documents it, "
message+="but head subject '${subject}' is not 'release: ${version}'. "
message+="Squash-merge release PRs with the default title, or tag this commit manually."
echo "::warning::${message}"
else
echo "Head commit is not a release commit; nothing to do."
fi
echo "proceed=false" >> "${GITHUB_OUTPUT}"
exit 0
fi

# shellcheck disable=SC2016 # PowerShell $env:CHANGELOG_VERSION is expanded by pwsh, not bash.
if ! CHANGELOG_VERSION="${version}" pwsh -NoProfile -Command '
. ./scripts/release-tools/release-helpers.ps1
$content = [System.IO.File]::ReadAllText("CHANGELOG.md")
if (-not (Test-ChangelogVersionHeading -Content $content -Version $env:CHANGELOG_VERSION)) {
exit 1
}
'; then
echo "::error::Release commit for ${version} has no matching CHANGELOG.md heading."
exit 1
Comment thread
cursor[bot] marked this conversation as resolved.
fi

{
echo "proceed=true"
echo "version=${version}"
} >> "${GITHUB_OUTPUT}"

- name: Generate auto-commit GitHub App token
id: app-token
if: ${{ steps.detect.outputs.proceed == 'true' }}
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.AUTO_COMMIT_APP_ID }}
private-key: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }}
permission-contents: write
Comment thread
cursor[bot] marked this conversation as resolved.

- name: Create and push annotated tag
if: ${{ steps.detect.outputs.proceed == 'true' }}
env:
GH_PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ steps.detect.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${VERSION}" -m "Release ${VERSION}" "${GITHUB_SHA}"
auth_header="$(printf 'x-access-token:%s' "${GH_PUSH_TOKEN}" | base64 | tr -d '\n')"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
push origin "refs/tags/${VERSION}"
echo "::notice::Pushed release tag ${VERSION}."
Loading
Loading