diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 50a59ad1d..93799b4ff 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,16 +1,8 @@ name: Release Drafter on: - push: - branches: - - main - # Uncomment after release-drafter.yml config is merged to main: - # pull_request_target: - # types: - # - opened - # - reopened - # - synchronize - # - labeled + # Manual-only: release-tag.yml and release.yml own release merges. + # Running this draft updater on push can race tag-triggered publishing. workflow_dispatch: permissions: diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 000000000..54eabfdcd --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,249 @@ +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}" + set +e + tag_lookup_output="$(gh api -i "repos/${GITHUB_REPOSITORY}/git/ref/tags/${version}" 2>&1)" + tag_lookup_exit=$? + set -e + if [ "${tag_lookup_exit}" -eq 0 ]; then + echo "::error::Tag ${version} already exists." + exit 1 + fi + if ! printf '%s\n' "${tag_lookup_output}" | grep -E '(^HTTP/[0-9.]+ 404( |$)|"status":"404")' >/dev/null; then + echo "::error::Failed to check whether tag ${version} already exists." + printf '%s\n' "${tag_lookup_output}" + exit "${tag_lookup_exit}" + fi + set +e + branch_lookup_output="$(git ls-remote --exit-code --heads origin "${branch}" 2>&1)" + branch_lookup_exit=$? + set -e + if [ "${branch_lookup_exit}" -eq 0 ]; then + echo "::error::Branch ${branch} already exists." + exit 1 + fi + if [ "${branch_lookup_exit}" -ne 2 ]; then + echo "::error::Failed to check whether branch ${branch} already exists." + printf '%s\n' "${branch_lookup_output}" + exit "${branch_lookup_exit}" + 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" + + notes_file="${RUNNER_TEMP}/release-notes.md" + pwsh -NoProfile -File scripts/release-tools/write-release-notes.ps1 \ + -Version "${VERSION}" \ + -OutputPath "${notes_file}" + + 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}" + + 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 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 000000000..4a4c79b70 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,142 @@ +name: Release Tag + +on: + push: + 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') && + github.ref_name == github.event.repository.default_branch + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + 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 + + 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 ' + $ErrorActionPreference = "Stop" + . ./scripts/release-tools/release-helpers.ps1 + $content = [System.IO.File]::ReadAllText("CHANGELOG.md") + [void](Get-ChangelogSection -Content $content -Version $env:CHANGELOG_VERSION) + '; then + if git show-ref --verify --quiet "refs/tags/${version}"; then + echo "Head commit is not a release commit; version ${version} is already tagged; nothing to do." + else + 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}" + fi + 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 ' + $ErrorActionPreference = "Stop" + . ./scripts/release-tools/release-helpers.ps1 + $content = [System.IO.File]::ReadAllText("CHANGELOG.md") + [void](Get-ChangelogSection -Content $content -Version $env:CHANGELOG_VERSION) + '; then + echo "::error::Release commit for ${version} has no CHANGELOG.md section with release-note content." + exit 1 + fi + + set +e + tag_lookup_output="$(gh api -i "repos/${GITHUB_REPOSITORY}/git/ref/tags/${version}" 2>&1)" + tag_lookup_exit=$? + set -e + if [ "${tag_lookup_exit}" -eq 0 ]; then + git fetch --force --tags origin "refs/tags/${version}:refs/tags/${version}" >/dev/null + tag_target="$(git rev-list -n 1 "${version}")" + if [ "${tag_target}" = "${GITHUB_SHA}" ]; then + echo "Tag ${version} already points at this release commit; nothing to do." + echo "proceed=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "::error::Tag ${version} already exists at ${tag_target}, not release commit ${GITHUB_SHA}." + exit 1 + fi + if ! printf '%s\n' "${tag_lookup_output}" | grep -E '(^HTTP/[0-9.]+ 404( |$)|"status":"404")' >/dev/null; then + echo "::error::Failed to check whether tag ${version} already exists." + printf '%s\n' "${tag_lookup_output}" + exit "${tag_lookup_exit}" + fi + + { + echo "proceed=true" + echo "version=${version}" + } >> "${GITHUB_OUTPUT}" + + - name: Check auto-commit GitHub App credentials + if: ${{ steps.detect.outputs.proceed == 'true' }} + env: + AUTO_COMMIT_APP_ID: ${{ secrets.AUTO_COMMIT_APP_ID }} + AUTO_COMMIT_APP_PRIVATE_KEY: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -n "${AUTO_COMMIT_APP_ID:-}" ] && [ -n "${AUTO_COMMIT_APP_PRIVATE_KEY:-}" ]; then + exit 0 + fi + echo "::error::AUTO_COMMIT_APP_ID and AUTO_COMMIT_APP_PRIVATE_KEY are required to push release tags with a token that triggers Release Publish." + exit 1 + + - 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 + + - 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}." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..6c5d07462 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,320 @@ +name: Release Publish + +on: + push: + tags: + # Keep the trigger as an unambiguous digit-start glob. The verify job below + # rejects anything except exact no-leading-zero X.Y.Z semver before publish. + - "[0-9]*.[0-9]*.[0-9]*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + verify-tag: + name: Verify release tag + if: github.repository == 'wallstop/unity-helpers' || github.repository == 'Ambiguous-Interactive/unity-helpers' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + package-name: ${{ steps.verify.outputs.package-name }} + package-version: ${{ steps.verify.outputs.package-version }} + tag: ${{ steps.verify.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify tag matches package metadata + id: verify + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + if ! printf '%s\n' "${tag}" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; then + echo "::error::Release tags must use unprefixed X.Y.Z semver." + exit 1 + fi + package_name="$(jq -r '.name // empty' package.json)" + package_version="$(jq -r '.version // empty' package.json)" + if [ -z "${package_name}" ] || [ -z "${package_version}" ]; then + echo "::error::package.json name/version is missing." + exit 1 + fi + if [ "${tag}" != "${package_version}" ]; then + echo "::error::Tag ${tag} does not match package.json version ${package_version}." + exit 1 + fi + # shellcheck disable=SC2016 # PowerShell $env:CHANGELOG_VERSION is expanded by pwsh, not bash. + if ! CHANGELOG_VERSION="${package_version}" pwsh -NoProfile -Command ' + $ErrorActionPreference = "Stop" + . ./scripts/release-tools/release-helpers.ps1 + $content = [System.IO.File]::ReadAllText("CHANGELOG.md") + [void](Get-ChangelogSection -Content $content -Version $env:CHANGELOG_VERSION) + '; then + echo "::error::CHANGELOG.md has no exact section with release-note content for ${package_version}." + exit 1 + fi + { + echo "package-name=${package_name}" + echo "package-version=${package_version}" + echo "tag=${tag}" + } >> "${GITHUB_OUTPUT}" + + validate-package: + name: Validate and pack npm package + needs: verify-tag + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + 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: Run release package validation + 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 + + - name: Pack npm package + id: pack + run: | + set -euo pipefail + pack_dir="${RUNNER_TEMP}/npm-pack" + mkdir -p "${pack_dir}" + pack_json="$(npm pack --json --pack-destination "${pack_dir}")" + package_file="$(printf '%s' "${pack_json}" | jq -r '.[0].filename')" + if [ -z "${package_file}" ] || [ ! -f "${pack_dir}/${package_file}" ]; then + echo "::error::npm pack did not produce a tarball." + exit 1 + fi + mkdir -p .artifacts/release + mv "${pack_dir}/${package_file}" ".artifacts/release/${package_file}" + (cd .artifacts/release && sha256sum "${package_file}" > "${package_file}.sha256") + pwsh -NoProfile -File scripts/release-tools/write-release-notes.ps1 \ + -Version "${{ needs.verify-tag.outputs.package-version }}" \ + -Footer \ + -OutputPath .artifacts/release/release-notes.md + echo "package-file=${package_file}" >> "${GITHUB_OUTPUT}" + + - name: Upload release package artifact + uses: actions/upload-artifact@v7 + with: + name: release-package + path: .artifacts/release/ + if-no-files-found: error + overwrite: true + + unitypackage: + name: Export .unitypackage + needs: + - verify-tag + - validate-package + runs-on: ubuntu-latest + timeout-minutes: 360 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Validate Unity license secrets + uses: ./.github/actions/validate-unity-license + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + - name: Acquire organization Unity lock + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: release-${{ needs.verify-tag.outputs.package-version }} + timeout-minutes: "210" + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + - name: Export Unity package + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_VERSION: ${{ vars.RELEASE_UNITY_VERSION }} + run: | + set -euo pipefail + if [ -z "${UNITY_VERSION:-}" ]; then + UNITY_VERSION="$(jq -r '.release' .github/unity-versions.json)" + export UNITY_VERSION + fi + output=".artifacts/unitypackage/${{ needs.verify-tag.outputs.package-name }}-${{ needs.verify-tag.outputs.package-version }}.unitypackage" + bash scripts/unity/export-unitypackage.sh --output "${output}" + + - name: Return Unity license + if: always() + uses: ./.github/actions/return-unity-license + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + - name: Release organization Unity lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: release-${{ needs.verify-tag.outputs.package-version }} + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + - name: Upload .unitypackage artifact + uses: actions/upload-artifact@v7 + with: + name: release-unitypackage + path: .artifacts/unitypackage/ + if-no-files-found: error + overwrite: true + + publish: + name: Publish npm and GitHub Release + needs: + - verify-tag + - validate-package + - unitypackage + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + id-token: write + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + registry-url: "https://registry.npmjs.org" + + - name: Download release package + uses: actions/download-artifact@v8 + with: + name: release-package + path: .artifacts/release + + - name: Download .unitypackage + uses: actions/download-artifact@v8 + with: + name: release-unitypackage + path: .artifacts/unitypackage + + - name: Verify downloaded artifacts + id: artifacts + run: | + set -euo pipefail + mapfile -t packages < <(find .artifacts/release -maxdepth 1 -type f -name '*.tgz' | sort) + mapfile -t unitypackages < <(find .artifacts/unitypackage -maxdepth 1 -type f -name '*.unitypackage' | sort) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::Expected exactly one npm tarball; found ${#packages[@]}." + exit 1 + fi + if [ "${#unitypackages[@]}" -ne 1 ]; then + echo "::error::Expected exactly one .unitypackage; found ${#unitypackages[@]}." + exit 1 + fi + package_file="${packages[0]}" + unitypackage_file="${unitypackages[0]}" + (cd "$(dirname "${package_file}")" && sha256sum -c "$(basename "${package_file}").sha256") + (cd "$(dirname "${unitypackage_file}")" && sha256sum -c "$(basename "${unitypackage_file}").sha256") + echo "package-file=${package_file}" >> "${GITHUB_OUTPUT}" + echo "unitypackage-file=${unitypackage_file}" >> "${GITHUB_OUTPUT}" + + - name: Publish npm package + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PACKAGE_NAME: ${{ needs.verify-tag.outputs.package-name }} + PACKAGE_VERSION: ${{ needs.verify-tag.outputs.package-version }} + PACKAGE_FILE: ${{ steps.artifacts.outputs.package-file }} + run: | + set -euo pipefail + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry "https://registry.npmjs.org" >/dev/null 2>&1; then + echo "::notice::${PACKAGE_NAME}@${PACKAGE_VERSION} already exists on npm; skipping publish." + exit 0 + fi + + if [ -n "${NPM_TOKEN:-}" ]; then + NODE_AUTH_TOKEN="${NPM_TOKEN}" npx --yes --package=npm@^11.5.1 npm publish "${PACKAGE_FILE}" --access public + else + npx --yes --package=npm@^11.5.1 npm publish "${PACKAGE_FILE}" --access public --provenance + fi + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.verify-tag.outputs.tag }} + PACKAGE_FILE: ${{ steps.artifacts.outputs.package-file }} + UNITYPACKAGE_FILE: ${{ steps.artifacts.outputs.unitypackage-file }} + run: | + set -euo pipefail + notes_file=".artifacts/release/release-notes.md" + assets=( + "${PACKAGE_FILE}" + "${PACKAGE_FILE}.sha256" + "${UNITYPACKAGE_FILE}" + "${UNITYPACKAGE_FILE}.sha256" + ) + + if gh release view "${TAG}" >/dev/null 2>&1; then + gh release upload "${TAG}" "${assets[@]}" --clobber + gh release edit "${TAG}" --title "${TAG}" --notes-file "${notes_file}" --draft=false --prerelease=false + else + gh release create "${TAG}" "${assets[@]}" --title "${TAG}" --notes-file "${notes_file}" --verify-tag + fi + + - name: Verify GitHub Release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.verify-tag.outputs.tag }} + PACKAGE_FILE: ${{ steps.artifacts.outputs.package-file }} + UNITYPACKAGE_FILE: ${{ steps.artifacts.outputs.unitypackage-file }} + run: | + set -euo pipefail + asset_json="$(gh release view "${TAG}" --json assets)" + for asset in \ + "$(basename "${PACKAGE_FILE}")" \ + "$(basename "${PACKAGE_FILE}").sha256" \ + "$(basename "${UNITYPACKAGE_FILE}")" \ + "$(basename "${UNITYPACKAGE_FILE}").sha256"; do + if ! printf '%s' "${asset_json}" | jq -e --arg name "${asset}" '.assets[] | select(.name == $name)' >/dev/null; then + echo "::error::Release ${TAG} is missing asset ${asset}." + exit 1 + fi + done diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 9cd3a464c..554bc58a4 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -828,10 +828,10 @@ jobs: # keep time small (no IL2CPP standalone build). Core-only (no integrations): the # single-threaded toggle is a core-runtime concern, so installing the DI packages # would only add cost. It is a SEPARATE job from unity-tests but shares the SAME - # self-hosted Unity seat; the org lock `wallstop-organization-builds` (acquired - # below, released if:always()) is what actually serializes it against the main - # matrix on that single seat -- max-parallel:1 only serializes within this job's - # own (version x mode) legs. Reuses the same secrets gate, runner-preflight, + # self-hosted Unity seat. This job depends on both Unity tiers so same-workflow + # jobs do not contend with each other for the org lock; the external + # `wallstop-organization-builds` lock still serializes this work against other + # workflows and runs. Reuses the same secrets gate, runner-preflight, # provisioning, perf-category exclusion, verify-results, and artifact-upload # scaffolding as unity-tests, with DISTINCT project/Library/artifact paths # (suffix `-single-threaded`) so its differently-compiled Library never collides @@ -841,11 +841,19 @@ jobs: needs: - matrix-config - runner-preflight + - unity-tests + - unity-tests-standalone if: >- ${{ + always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'push' || github.ref_protected) && + needs.matrix-config.result == 'success' && + needs.runner-preflight.result == 'success' && + needs.unity-tests.result == 'success' && + (needs.unity-tests-standalone.result == 'success' || + needs.unity-tests-standalone.result == 'skipped') && needs.matrix-config.outputs.has-required-secrets == 'true' }} runs-on: [self-hosted, Windows, RAM-64GB] diff --git a/.markdownlintignore b/.markdownlintignore index 68bb68cc7..21aa4a5da 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -7,6 +7,8 @@ Samples~/ /.github/ artifacts/ progress/ +PLAN.md +PLAN.md.meta # MkDocs Material files with tabbed content syntax (===) that markdownlint doesn't support docs/index.md diff --git a/.npmignore b/.npmignore index e58a68e2e..686497678 100644 --- a/.npmignore +++ b/.npmignore @@ -17,6 +17,10 @@ scripts/wiki/__pycache__.meta .github/ .devcontainer/ .config/ +.artifacts/ +.artifacts.meta +.cursor/ +.githooks/ .husky/ .llm/ .vscode/ @@ -24,6 +28,12 @@ scripts/wiki/__pycache__.meta .idea/ .claude/ .codex/ +.mcp.json +.lychee.toml +.markdownlint.json +.markdownlint.jsonc +.markdownlintignore +.pre-commit-config.yaml _llm_* _llm_*.meta @@ -36,8 +46,15 @@ _llm_*.meta .gitignore .yamllint.yaml .prettierrc +.prettierrc.json .prettierignore .npmignore.meta +_config.yml +_config.yml.meta +_includes/ +_includes.meta +site/ +site.meta # Agent/AI instruction files (keep for npm but exclude from published package) # Use leading slash to match only root-level files diff --git a/docs/project/contributing.md b/docs/project/contributing.md index a044d4d03..bedd4b160 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -100,4 +100,4 @@ When installing via Git URL, reference versions without the `v` prefix: https://github.com/wallstop/unity-helpers.git#3.1.5 ``` -Releases are drafted automatically via [release-drafter](https://github.com/release-drafter/release-drafter). Maintainers review and publish the draft when ready. +Maintainers prepare releases from the default branch with the **Release Prepare** workflow. Choose a `major`, `minor`, or `patch` bump, review the generated release PR, and squash-merge it with the default `release: X.Y.Z` title. After merge, the release automation tags that commit, validates and packs the npm package, exports the `.unitypackage`, publishes npm, and publishes the GitHub Release assets. diff --git a/package.json b/package.json index 1071ce904..596b0ecc5 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,33 @@ "path": "Samples~/UGUI - EnhancedImage" } ], + "files": [ + "CHANGELOG.md", + "CHANGELOG.md.meta", + "Editor", + "Editor.meta", + "LICENSE", + "LICENSE.meta", + "README.md", + "README.md.meta", + "Runtime", + "Runtime.meta", + "Samples~", + "Shaders", + "Shaders.meta", + "Styles", + "Styles.meta", + "URP", + "URP.meta", + "docs", + "docs.meta", + "link.xml", + "link.xml.meta", + "package.json.meta", + "scripts.meta", + "scripts/postinstall-hooks.js", + "scripts/postinstall-hooks.js.meta" + ], "scripts": { "docs:serve": "bundle exec jekyll serve --baseurl \"\" --livereload", "docs:build": "bundle exec jekyll build --baseurl \"\"", @@ -156,7 +183,7 @@ "test:license-cache": "bash scripts/tests/test-license-cache.sh", "lint:markdown": "node ./scripts/run-node-bin.js markdownlint --config .markdownlint.json --ignore-path .markdownlintignore -- \"**/*.md\" \"**/*.markdown\"", "validate:content": "npm run lint:docs && npm run test:deprecated-external-links && npm run lint:markdown && npm run lint:changelog && npm run lint:yaml && npm run format:check && npm run lint:llm && npm run lint:doc-counts && npm run lint:dependabot && npm run lint:pwsh-invocations && npm run lint:workflow-run-expression-length && npm run validate:lint-error-codes && npm run validate:mcp-config", - "validate:tests": "npm run lint:tests && npm run test:gitignore-docs && npm run test:validate-mcp-config && npm run test:sync-script-contracts && npm run test:agent-preflight && npm run test:git-path-helpers && npm run test:accelerator && npm run test:postinstall-hooks && npm run test:github-pages-sortable && npm run test:add-cspell-word && npm run test:configure-git-defaults && npm run test:validate-git-push-config && npm run test:git-staging-helpers && npm run test:lint-dependabot && npm run test:lint-duplicate-usings && npm run test:lint-pwsh-invocations && npm run test:lint-workflow-run-expression-length && npm run test:unity-workflow-matrix-contract && npm run test:validate-lint-error-codes && npm run test:precommit-integration && npm run test:pre-push-changed-files && npm run test:license-cache && npm run test:wiki-generation && npm run test:npm-package-signature && npm run test:npm-package-changelog", + "validate:tests": "npm run lint:tests && npm run test:gitignore-docs && npm run test:validate-mcp-config && npm run test:sync-script-contracts && npm run test:agent-preflight && npm run test:git-path-helpers && npm run test:accelerator && npm run test:postinstall-hooks && npm run test:github-pages-sortable && npm run test:add-cspell-word && npm run test:configure-git-defaults && npm run test:validate-git-push-config && npm run test:git-staging-helpers && npm run test:lint-dependabot && npm run test:lint-duplicate-usings && npm run test:lint-pwsh-invocations && npm run test:lint-workflow-run-expression-length && npm run test:unity-workflow-matrix-contract && npm run test:validate-lint-error-codes && npm run test:precommit-integration && npm run test:pre-push-changed-files && npm run test:license-cache && npm run test:wiki-generation && npm run test:npm-package-signature && npm run test:npm-package-changelog && npm run test:release-tools", "validate:prepush": "npm run validate:git-push-config && npm run validate:content && npm run lint:spelling && npm run eol:check && npm run validate:tests && npm run lint:csharp-naming && npm run lint:duplicate-usings && npm run lint:spelling:config && npm run validate:devcontainer && npm run validate:hook-sync && npm run validate:hook-perms && npm run validate:hook-spell-parity && npm run validate:cspell-files-parity && npm run test:shell-portability", "validate:devcontainer": "pwsh -NoProfile -File scripts/validate-devcontainer-config.ps1 -VerboseOutput && npm run test:validate-devcontainer-urls && npm run test:post-create", "validate:hook-sync": "pwsh -NoProfile -File scripts/validate-hook-sync-calls.ps1 -VerboseOutput", @@ -183,6 +210,7 @@ "test:shell-portability": "bash scripts/tests/test-shell-portability.sh", "test:npm-package-signature": "pwsh -NoProfile -File scripts/tests/test-npm-package-signature.ps1 -VerboseOutput", "test:npm-package-changelog": "pwsh -NoProfile -File scripts/tests/test-npm-package-changelog.ps1 -VerboseOutput", + "test:release-tools": "pwsh -NoProfile -File scripts/tests/test-release-tools.ps1 -VerboseOutput", "verify:tools": "bash scripts/verify-devcontainer-tools.sh", "codex:login": "bash scripts/codex-login.sh", "codex:login:browser": "bash scripts/codex-login.sh --browser", diff --git a/scripts/lint-asmdef.ps1 b/scripts/lint-asmdef.ps1 index 3bb464fcc..147a0cb5e 100644 --- a/scripts/lint-asmdef.ps1 +++ b/scripts/lint-asmdef.ps1 @@ -30,6 +30,18 @@ function Get-JsonProp($obj, [string]$name, $default = $null) { return $default } +function ConvertTo-RepoRelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $rootPath = [System.IO.Path]::GetFullPath($repoRoot) + $childPath = [System.IO.Path]::GetFullPath($Path) + + return [System.IO.Path]::GetRelativePath($rootPath, $childPath).Replace('\', '/') +} + # Faithfully replicates UnityEditor.Scripting.ScriptCompilation.VersionRanges.ParseExpression # + ExpressionTypeFactory.Create() (UnityCsReference). A versionDefines "expression" that this # grammar rejects is SILENTLY ignored by Unity: the define is never applied, with no compile error. @@ -330,7 +342,7 @@ $checkedCount = 0 foreach ($file in $asmdefFilesToValidate) { $checkedCount++ - $relativePath = $file.FullName.Replace($repoRoot, '').TrimStart('\', '/') + $relativePath = ConvertTo-RepoRelativePath -Path $file.FullName $expectedName = [System.IO.Path]::GetFileNameWithoutExtension($file.Name) Write-Info "Validating: $relativePath" @@ -449,7 +461,7 @@ foreach ($file in $asmdefFilesToValidate) { # 6. Check that assemblies referencing WallstopStudios.UnityHelpers with overrideReferences include Sirenix.Serialization.dll foreach ($file in $asmdefFilesToValidate) { - $relativePath = $file.FullName.Replace($repoRoot, '').TrimStart('\', '/') + $relativePath = ConvertTo-RepoRelativePath -Path $file.FullName try { $content = Get-Content -Path $file.FullName -Raw @@ -541,7 +553,7 @@ if ($asmrefFiles.Count -gt 0) { foreach ($file in $asmrefFiles) { $checkedCount++ - $relativePath = $file.FullName.Replace($repoRoot, '').TrimStart('\', '/') + $relativePath = ConvertTo-RepoRelativePath -Path $file.FullName Write-Info "Validating: $relativePath" diff --git a/scripts/lint-license-headers.ps1 b/scripts/lint-license-headers.ps1 index ce22a631e..3631e63b0 100644 --- a/scripts/lint-license-headers.ps1 +++ b/scripts/lint-license-headers.ps1 @@ -17,6 +17,20 @@ function Write-SuccessMsg($msg) { Write-Host "[lint-license-headers] $msg" -ForegroundColor Green } +$repoRoot = (Get-Item $PSScriptRoot).Parent.FullName + +function ConvertTo-RepoRelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $rootPath = [System.IO.Path]::GetFullPath($repoRoot) + $childPath = [System.IO.Path]::GetFullPath($Path) + + return [System.IO.Path]::GetRelativePath($rootPath, $childPath).Replace('\', '/') +} + # Directories to scan $sourceRoots = @('Runtime', 'Editor', 'Tests') @@ -56,7 +70,7 @@ foreach ($root in $sourceRoots) { foreach ($file in $csFiles) { $checkedCount++ - $relativePath = $file.FullName.Replace((Get-Item $PSScriptRoot).Parent.FullName, '').TrimStart('\', '/') + $relativePath = ConvertTo-RepoRelativePath -Path $file.FullName Write-Info "Checking: $relativePath" diff --git a/scripts/release-tools.meta b/scripts/release-tools.meta new file mode 100644 index 000000000..95dcf2e2b --- /dev/null +++ b/scripts/release-tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 894b3de3764aed46e93775c36f8f7a86 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/release-tools/prepare-release.ps1 b/scripts/release-tools/prepare-release.ps1 new file mode 100644 index 000000000..7fa6e084c --- /dev/null +++ b/scripts/release-tools/prepare-release.ps1 @@ -0,0 +1,40 @@ +Param( + [ValidateSet('major', 'minor', 'patch')] + [string]$Bump = 'patch', + [string]$Version = '', + [string]$Date = (Get-Date -Format 'yyyy-MM-dd'), + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'release-helpers.ps1') + +try { + $result = Invoke-ReleasePreparation ` + -RepoRoot $RepoRoot ` + -Bump $Bump ` + -Version $Version ` + -Date $Date ` + -DryRun:$DryRun + + Write-Host "prepare-release: $($result.CurrentVersion) -> $($result.NextVersion)" + if ($result.DryRun) { + Write-Host 'prepare-release: dry run; no files were written.' + } else { + Write-Host "prepare-release: rewrote $($result.PackageJsonPath)" + if ($result.PackageLockPath) { + Write-Host "prepare-release: rewrote $($result.PackageLockPath)" + } + if ($result.ChangelogRotated) { + Write-Host "prepare-release: rotated $($result.ChangelogPath)" + } else { + Write-Host "prepare-release: $($result.ChangelogPath) already had the target version heading." + } + } +} catch { + Write-Error "prepare-release failed: $($_.Exception.Message)" + exit 1 +} diff --git a/scripts/release-tools/prepare-release.ps1.meta b/scripts/release-tools/prepare-release.ps1.meta new file mode 100644 index 000000000..70356fb39 --- /dev/null +++ b/scripts/release-tools/prepare-release.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 16a3a788065dc9a035b77bf5575258f1 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/release-tools/release-helpers.ps1 b/scripts/release-tools/release-helpers.ps1 new file mode 100644 index 000000000..1db50c116 --- /dev/null +++ b/scripts/release-tools/release-helpers.ps1 @@ -0,0 +1,474 @@ +Set-StrictMode -Version Latest + +$script:ReleaseSemverPattern = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' +$script:ReleaseBumpKinds = @('major', 'minor', 'patch') + +function ConvertTo-ReleaseSemverParts { + param( + [Parameter(Mandatory = $true)] + [string]$Version + ) + + $match = [regex]::Match($Version, $script:ReleaseSemverPattern) + if (-not $match.Success) { + throw "Invalid semver '$Version'. Expected X.Y.Z with numeric components and no leading zeroes." + } + + return [pscustomobject]@{ + Major = [int]$match.Groups[1].Value + Minor = [int]$match.Groups[2].Value + Patch = [int]$match.Groups[3].Value + } +} + +function Compare-ReleaseSemver { + param( + [Parameter(Mandatory = $true)] + [string]$Left, + [Parameter(Mandatory = $true)] + [string]$Right + ) + + $leftParts = ConvertTo-ReleaseSemverParts -Version $Left + $rightParts = ConvertTo-ReleaseSemverParts -Version $Right + + foreach ($partName in @('Major', 'Minor', 'Patch')) { + if ($leftParts.$partName -gt $rightParts.$partName) { + return 1 + } + if ($leftParts.$partName -lt $rightParts.$partName) { + return -1 + } + } + + return 0 +} + +function Get-NextReleaseVersion { + param( + [Parameter(Mandatory = $true)] + [string]$CurrentVersion, + [ValidateSet('major', 'minor', 'patch')] + [string]$Bump = 'patch', + [string]$Version = '' + ) + + $current = ConvertTo-ReleaseSemverParts -Version $CurrentVersion + if (-not [string]::IsNullOrWhiteSpace($Version)) { + [void](ConvertTo-ReleaseSemverParts -Version $Version) + if ((Compare-ReleaseSemver -Left $Version -Right $CurrentVersion) -le 0) { + throw "Explicit version $Version must be strictly greater than the current version $CurrentVersion." + } + return $Version + } + + switch ($Bump) { + 'major' { return "$($current.Major + 1).0.0" } + 'minor' { return "$($current.Major).$($current.Minor + 1).0" } + 'patch' { return "$($current.Major).$($current.Minor).$($current.Patch + 1)" } + default { throw "Unknown bump kind '$Bump'. Expected: $($script:ReleaseBumpKinds -join ', ')." } + } +} + +function Normalize-ReleaseText { + param( + [Parameter(Mandatory = $true)] + [string]$Content + ) + + return ($Content -replace "`r`n", "`n") -replace "`r", "`n" +} + +function Get-ChangelogFenceMask { + param( + [string[]]$Lines + ) + + $mask = New-Object bool[] $Lines.Count + $inFence = $false + $fenceMarker = '' + $fenceLength = 0 + + for ($index = 0; $index -lt $Lines.Count; $index++) { + $line = $Lines[$index] + $trimmed = $line.TrimStart() + $isFenceLine = $false + + if ($trimmed -match '^(?`{3,}|~{3,})') { + $marker = $Matches['marker'] + $markerPrefix = $marker.Substring(0, 1) + if (-not $inFence) { + $inFence = $true + $fenceMarker = $markerPrefix + $fenceLength = $marker.Length + $isFenceLine = $true + } elseif ($fenceMarker -eq $markerPrefix -and $marker.Length -ge $fenceLength) { + $isFenceLine = $true + $mask[$index] = $true + $inFence = $false + $fenceMarker = '' + $fenceLength = 0 + continue + } + } + + if ($inFence -or $isFenceLine) { + $mask[$index] = $true + } + } + + return $mask +} + +function Test-ChangelogVersionHeading { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$Version + ) + + $lines = (Normalize-ReleaseText -Content $Content).Split("`n") + $fenced = Get-ChangelogFenceMask -Lines $lines + $escaped = [regex]::Escape($Version) + + for ($index = 0; $index -lt $lines.Count; $index++) { + if ($fenced[$index]) { + continue + } + if ($lines[$index] -match "^## \[$escaped\](?: - \d{4}-\d{2}-\d{2})?$") { + return $true + } + } + + return $false +} + +function Get-ChangelogSection { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$Version + ) + + $lines = (Normalize-ReleaseText -Content $Content).Split("`n") + $fenced = Get-ChangelogFenceMask -Lines $lines + $escaped = [regex]::Escape($Version) + $headingIndex = -1 + + for ($index = 0; $index -lt $lines.Count; $index++) { + if ($fenced[$index]) { + continue + } + if ($lines[$index] -match "^## \[$escaped\](?: - \d{4}-\d{2}-\d{2})?$") { + $headingIndex = $index + break + } + } + + if ($headingIndex -lt 0) { + throw "CHANGELOG.md has no '## [$Version]' section." + } + + $endIndex = $lines.Count + for ($index = $headingIndex + 1; $index -lt $lines.Count; $index++) { + if (-not $fenced[$index] -and $lines[$index].StartsWith('## ')) { + $endIndex = $index + break + } + } + + $body = @() + if ($headingIndex + 1 -lt $endIndex) { + $body = @($lines[($headingIndex + 1)..($endIndex - 1)]) + } + + while ($body.Count -gt 0 -and [string]::IsNullOrWhiteSpace($body[0])) { + $body = @($body | Select-Object -Skip 1) + } + while ($body.Count -gt 0 -and [string]::IsNullOrWhiteSpace($body[$body.Count - 1])) { + $body = @($body | Select-Object -First ($body.Count - 1)) + } + + $hasContent = $false + foreach ($line in $body) { + if (-not [string]::IsNullOrWhiteSpace($line) -and -not $line.StartsWith('### ')) { + $hasContent = $true + break + } + } + + if (-not $hasContent) { + throw "CHANGELOG.md section '## [$Version]' has no release-note content." + } + + return ($body -join "`n") +} + +function Update-ReleaseChangelogContent { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [string]$Date + ) + + $normalized = Normalize-ReleaseText -Content $Content + $lines = $normalized.Split("`n") + $fenced = Get-ChangelogFenceMask -Lines $lines + $unreleasedIndexes = @() + for ($index = 0; $index -lt $lines.Count; $index++) { + if (-not $fenced[$index] -and $lines[$index] -eq '## [Unreleased]') { + $unreleasedIndexes += $index + } + } + + if ($unreleasedIndexes.Count -ne 1) { + throw "CHANGELOG.md must contain exactly one '## [Unreleased]' heading; found $($unreleasedIndexes.Count)." + } + + $unreleasedIndex = $unreleasedIndexes[0] + $nextHeadingIndex = $lines.Count + for ($index = $unreleasedIndex + 1; $index -lt $lines.Count; $index++) { + if (-not $fenced[$index] -and $lines[$index].StartsWith('## ')) { + $nextHeadingIndex = $index + break + } + } + + $block = @() + if ($unreleasedIndex + 1 -lt $nextHeadingIndex) { + $block = @($lines[($unreleasedIndex + 1)..($nextHeadingIndex - 1)]) + } + + while ($block.Count -gt 0 -and [string]::IsNullOrWhiteSpace($block[0])) { + $block = @($block | Select-Object -Skip 1) + } + while ($block.Count -gt 0 -and [string]::IsNullOrWhiteSpace($block[$block.Count - 1])) { + $block = @($block | Select-Object -First ($block.Count - 1)) + } + + $hasContent = $false + foreach ($line in $block) { + if (-not [string]::IsNullOrWhiteSpace($line) -and -not $line.StartsWith('### ')) { + $hasContent = $true + break + } + } + + if (Test-ChangelogVersionHeading -Content $normalized -Version $Version) { + if ($hasContent) { + throw "CHANGELOG.md already contains '## [$Version]' but '## [Unreleased]' still has release-note content." + } + + [void](Get-ChangelogSection -Content $normalized -Version $Version) + return [pscustomobject]@{ + Content = $normalized.TrimEnd() + "`n" + Rotated = $false + } + } + + if (-not $hasContent) { + throw "The '## [Unreleased]' section has no release-note content." + } + + $rotatedLines = [System.Collections.Generic.List[string]]::new() + for ($index = 0; $index -le $unreleasedIndex; $index++) { + [void]$rotatedLines.Add($lines[$index]) + } + [void]$rotatedLines.Add('') + [void]$rotatedLines.Add("## [$Version] - $Date") + [void]$rotatedLines.Add('') + foreach ($line in $block) { + [void]$rotatedLines.Add($line) + } + + if ($nextHeadingIndex -lt $lines.Count) { + [void]$rotatedLines.Add('') + for ($index = $nextHeadingIndex; $index -lt $lines.Count; $index++) { + [void]$rotatedLines.Add($lines[$index]) + } + } + + return [pscustomobject]@{ + Content = (($rotatedLines -join "`n").TrimEnd() + "`n") + Rotated = $true + } +} + +function Set-ReleaseFileContent { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content + ) + + $encoding = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +function Update-PackageJsonVersionContent { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$CurrentVersion, + [Parameter(Mandatory = $true)] + [string]$NextVersion + ) + + $parsed = $Content | ConvertFrom-Json + if ([string]$parsed.version -ne $CurrentVersion) { + throw "package.json parsed version '$($parsed.version)' did not match expected current version '$CurrentVersion'." + } + + $pattern = '("version"\s*:\s*")' + [regex]::Escape($CurrentVersion) + '(")' + $matches = [regex]::Matches($Content, $pattern) + if ($matches.Count -ne 1) { + throw "Expected exactly one package.json version property for '$CurrentVersion'; found $($matches.Count)." + } + + $updated = [regex]::Replace($Content, $pattern, "`${1}$NextVersion`${2}") + $updatedParsed = $updated | ConvertFrom-Json + if ([string]$updatedParsed.version -ne $NextVersion) { + throw "package.json rewrite verification failed; parsed version is '$($updatedParsed.version)', expected '$NextVersion'." + } + + return (Normalize-ReleaseText -Content $updated).TrimEnd() + "`n" +} + +function Update-PackageLockVersionContent { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$NextVersion + ) + + $lockJson = $Content | ConvertFrom-Json -AsHashtable + if ($lockJson.ContainsKey('version')) { + $lockJson['version'] = $NextVersion + } + + if ($lockJson.ContainsKey('packages') -and $lockJson['packages'].ContainsKey('')) { + $rootPackage = $lockJson['packages'][''] + if ($rootPackage.ContainsKey('version')) { + $rootPackage['version'] = $NextVersion + } + } + + return (($lockJson | ConvertTo-Json -Depth 100) -replace "`r`n", "`n").TrimEnd() + "`n" +} + +function Invoke-ReleasePreparation { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [ValidateSet('major', 'minor', 'patch')] + [string]$Bump = 'patch', + [string]$Version = '', + [string]$Date = (Get-Date -Format 'yyyy-MM-dd'), + [switch]$DryRun + ) + + if ($Date -notmatch '^\d{4}-\d{2}-\d{2}$') { + throw "Release date '$Date' must use YYYY-MM-DD format." + } + + $resolvedRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path + $packageJsonPath = Join-Path $resolvedRepoRoot 'package.json' + $packageLockPath = Join-Path $resolvedRepoRoot 'package-lock.json' + $changelogPath = Join-Path $resolvedRepoRoot 'CHANGELOG.md' + + if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) { + throw "package.json not found at $packageJsonPath." + } + if (-not (Test-Path -LiteralPath $changelogPath -PathType Leaf)) { + throw "CHANGELOG.md not found at $changelogPath." + } + + $packageJsonContent = [System.IO.File]::ReadAllText($packageJsonPath) + $packageJson = $packageJsonContent | ConvertFrom-Json + $currentVersion = [string]$packageJson.version + [void](ConvertTo-ReleaseSemverParts -Version $currentVersion) + $nextVersion = Get-NextReleaseVersion -CurrentVersion $currentVersion -Bump $Bump -Version $Version + + $updatedPackageJson = Update-PackageJsonVersionContent ` + -Content $packageJsonContent ` + -CurrentVersion $currentVersion ` + -NextVersion $nextVersion + + $changelogContent = [System.IO.File]::ReadAllText($changelogPath) + $updatedChangelog = Update-ReleaseChangelogContent ` + -Content $changelogContent ` + -Version $nextVersion ` + -Date $Date + + $updatedPackageLock = $null + $packageLockUpdated = $false + if (Test-Path -LiteralPath $packageLockPath -PathType Leaf) { + $packageLockContent = [System.IO.File]::ReadAllText($packageLockPath) + $updatedPackageLock = Update-PackageLockVersionContent -Content $packageLockContent -NextVersion $nextVersion + $packageLockUpdated = $true + } + + if (-not $DryRun) { + if ($updatedChangelog.Rotated) { + Set-ReleaseFileContent -Path $changelogPath -Content $updatedChangelog.Content + } + Set-ReleaseFileContent -Path $packageJsonPath -Content $updatedPackageJson + if ($packageLockUpdated) { + Set-ReleaseFileContent -Path $packageLockPath -Content $updatedPackageLock + } + } + + return [pscustomobject]@{ + CurrentVersion = $currentVersion + NextVersion = $nextVersion + Date = $Date + PackageJsonPath = $packageJsonPath + PackageLockPath = if ($packageLockUpdated) { $packageLockPath } else { $null } + ChangelogPath = $changelogPath + ChangelogRotated = [bool]$updatedChangelog.Rotated + DryRun = [bool]$DryRun + } +} + +function New-ReleaseNotes { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [string]$Version, + [switch]$Footer + ) + + $resolvedRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path + $packageJsonPath = Join-Path $resolvedRepoRoot 'package.json' + $changelogPath = Join-Path $resolvedRepoRoot 'CHANGELOG.md' + + $packageJson = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json + $packageName = [string]$packageJson.name + $section = Get-ChangelogSection ` + -Content ([System.IO.File]::ReadAllText($changelogPath)) ` + -Version $Version + + if (-not $Footer) { + return $section.TrimEnd() + "`n" + } + + $footerContent = @( + '## Install', + '', + "- Import the attached ``.unitypackage`` into a Unity project, or", + "- install ``$packageName@$Version`` from npm / OpenUPM through Unity Package Manager.", + '', + 'The release includes the npm tarball and the `.unitypackage`, each with a `.sha256` checksum.' + ) -join "`n" + + return $section.TrimEnd() + "`n`n---`n`n" + $footerContent + "`n" +} diff --git a/scripts/release-tools/release-helpers.ps1.meta b/scripts/release-tools/release-helpers.ps1.meta new file mode 100644 index 000000000..f7e38ea71 --- /dev/null +++ b/scripts/release-tools/release-helpers.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 20fed21600858ca9a19d85ee44a08b14 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/release-tools/write-release-notes.ps1 b/scripts/release-tools/write-release-notes.ps1 new file mode 100644 index 000000000..30fcd59ac --- /dev/null +++ b/scripts/release-tools/write-release-notes.ps1 @@ -0,0 +1,29 @@ +Param( + [Parameter(Mandatory = $true)] + [string]$Version, + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path, + [string]$OutputPath = '', + [switch]$Footer +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'release-helpers.ps1') + +try { + $notes = New-ReleaseNotes -RepoRoot $RepoRoot -Version $Version -Footer:$Footer + if ([string]::IsNullOrWhiteSpace($OutputPath)) { + Write-Output $notes + } else { + $parent = Split-Path -Parent $OutputPath + if (-not [string]::IsNullOrWhiteSpace($parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + Set-ReleaseFileContent -Path $OutputPath -Content $notes + Write-Host "write-release-notes: wrote $OutputPath" + } +} catch { + Write-Error "write-release-notes failed: $($_.Exception.Message)" + exit 1 +} diff --git a/scripts/release-tools/write-release-notes.ps1.meta b/scripts/release-tools/write-release-notes.ps1.meta new file mode 100644 index 000000000..550fe6253 --- /dev/null +++ b/scripts/release-tools/write-release-notes.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3721f031b648efc7c69ada3826f8eac8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/tests/test-npm-package-changelog.ps1 b/scripts/tests/test-npm-package-changelog.ps1 index 71b86a606..aaf84f723 100644 --- a/scripts/tests/test-npm-package-changelog.ps1 +++ b/scripts/tests/test-npm-package-changelog.ps1 @@ -205,6 +205,75 @@ if ($changelogExists -and (-not [string]::IsNullOrWhiteSpace($versionValue))) { Write-TestResult -TestName 'CHANGELOG.md starts with markdown heading' -Passed $startsWithHeading -Message "First line should be a markdown heading (e.g., '# Changelog')" } +# ── Section: package validator regressions ────────────────────────────────── + +Write-Host "" +Write-Host " Section: package validator regressions" -ForegroundColor White + +$validatorPath = Join-Path $repoRoot 'scripts/validate-npm-package.ps1' +$caseOnlyCanaryPath = Join-Path $repoRoot 'docs/Index.md' +$caseOnlyCanaryRelativePath = 'docs/Index.md' + +function Invoke-ValidatorExpectingUntrackedPayloadFailure { + param( + [string]$RelativePath, + [string]$TestName + ) + + $canaryPath = Join-Path $repoRoot $RelativePath + if (Test-Path -LiteralPath $canaryPath) { + Write-TestResult -TestName $TestName -Passed $false -Message "Canary path already exists: $RelativePath" + return + } + + $validatorExitCode = 0 + $validatorOutput = $null + try { + $canaryDirectory = Split-Path -Parent $canaryPath + if (-not [string]::IsNullOrWhiteSpace($canaryDirectory)) { + New-Item -ItemType Directory -Path $canaryDirectory -Force | Out-Null + } + + Set-Content -LiteralPath $canaryPath -Value "# Package Validator Canary`n" -NoNewline + Push-Location $repoRoot + try { + $validatorOutput = & pwsh -NoProfile -File $validatorPath *>&1 + $validatorExitCode = $LASTEXITCODE + } + finally { + Pop-Location + } + } + finally { + Remove-Item -LiteralPath $canaryPath -Force -ErrorAction SilentlyContinue + } + + $validatorOutputText = ($validatorOutput | Out-String).Trim() + $expectedMessage = "File in npm package but not tracked in git repo: $RelativePath" + Write-TestResult ` + -TestName $TestName ` + -Passed ($validatorExitCode -ne 0 -and $validatorOutputText.Contains($expectedMessage)) ` + -Message "Expected validator to fail with '$expectedMessage'. Exit: $validatorExitCode. Output: $validatorOutputText" +} + +if (-not (Test-Path -LiteralPath $validatorPath)) { + Write-TestResult -TestName 'validate-npm-package script exists' -Passed $false -Message "Missing validator: $validatorPath" +} +else { + Invoke-ValidatorExpectingUntrackedPayloadFailure ` + -RelativePath 'docs/package-validator-untracked-payload-canary.md' ` + -TestName 'validate-npm-package rejects untracked payload files' + + if (Test-Path -LiteralPath $caseOnlyCanaryPath) { + Write-Info "Skipping case-only payload canary because $caseOnlyCanaryRelativePath resolves on this filesystem." + } + else { + Invoke-ValidatorExpectingUntrackedPayloadFailure ` + -RelativePath $caseOnlyCanaryRelativePath ` + -TestName 'validate-npm-package rejects case-only untracked payload files' + } +} + Write-Host "" Write-Host "Results:" -ForegroundColor Magenta Write-Host " Passed: $script:TestsPassed" diff --git a/scripts/tests/test-release-tools.ps1 b/scripts/tests/test-release-tools.ps1 new file mode 100644 index 000000000..17dc47edb --- /dev/null +++ b/scripts/tests/test-release-tools.ps1 @@ -0,0 +1,349 @@ +Param( + [switch]$VerboseOutput +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:TestsPassed = 0 +$script:TestsFailed = 0 + +function Write-Info { + param([string]$Message) + if ($VerboseOutput) { + Write-Host "[test-release-tools] $Message" -ForegroundColor Cyan + } +} + +function Write-TestResult { + param( + [string]$TestName, + [bool]$Passed, + [string]$Message = '' + ) + + if ($Passed) { + Write-Host " [PASS] $TestName" -ForegroundColor Green + $script:TestsPassed++ + } else { + Write-Host " [FAIL] $TestName" -ForegroundColor Red + if ($Message) { + Write-Host " $Message" -ForegroundColor Yellow + } + $script:TestsFailed++ + } +} + +function New-ReleaseFixture { + param( + [string]$Version = '1.2.3', + [string]$Changelog + ) + + $root = Join-Path ([System.IO.Path]::GetTempPath()) "release-tools-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $root -Force | Out-Null + + if ([string]::IsNullOrWhiteSpace($Changelog)) { + $Changelog = @( + '# Changelog', + '', + '## [Unreleased]', + '', + '### Added', + '', + '- A release-worthy change.', + '', + '## [1.2.3]', + '', + '### Fixed', + '', + '- Old fix.', + '' + ) -join "`n" + } + + $packageJson = @( + '{', + ' "name": "com.example.fixture",', + " `"version`": `"$Version`",", + ' "displayName": "Fixture"', + '}', + '' + ) -join "`n" + + $packageLock = @( + '{', + ' "name": "com.example.fixture",', + ' "version": "0.0.1",', + ' "lockfileVersion": 3,', + ' "packages": {', + ' "": {', + ' "name": "com.example.fixture",', + ' "version": "0.0.1"', + ' }', + ' }', + '}', + '' + ) -join "`n" + + Set-Content -Path (Join-Path $root 'package.json') -Value $packageJson -Encoding UTF8 -NoNewline + Set-Content -Path (Join-Path $root 'package-lock.json') -Value $packageLock -Encoding UTF8 -NoNewline + Set-Content -Path (Join-Path $root 'CHANGELOG.md') -Value $Changelog -Encoding UTF8 -NoNewline + + return $root +} + +function Remove-ReleaseFixture { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + Remove-Item -LiteralPath $Path -Recurse -Force + } +} + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +. (Join-Path $repoRoot 'scripts/release-tools/release-helpers.ps1') + +Write-Host 'Testing release tooling...' -ForegroundColor White + +try { + Write-TestResult ` + -TestName 'Get-NextReleaseVersion bumps semver components' ` + -Passed ( + (Get-NextReleaseVersion -CurrentVersion '1.2.3' -Bump patch) -eq '1.2.4' -and + (Get-NextReleaseVersion -CurrentVersion '1.2.3' -Bump minor) -eq '1.3.0' -and + (Get-NextReleaseVersion -CurrentVersion '1.2.3' -Bump major) -eq '2.0.0' + ) + + $invalidVersionRejected = $false + try { + [void](Get-NextReleaseVersion -CurrentVersion '1.2.3' -Version '1.02.4') + } catch { + $invalidVersionRejected = $_.Exception.Message -match 'Invalid semver' + } + Write-TestResult -TestName 'Explicit versions reject leading zeroes' -Passed $invalidVersionRejected + + $nonIncreasingRejected = $false + try { + [void](Get-NextReleaseVersion -CurrentVersion '1.2.3' -Version '1.2.3') + } catch { + $nonIncreasingRejected = $_.Exception.Message -match 'strictly greater' + } + Write-TestResult -TestName 'Explicit versions must increase' -Passed $nonIncreasingRejected + + $helperContent = Get-Content -Path (Join-Path $repoRoot 'scripts/release-tools/release-helpers.ps1') -Raw + Write-TestResult ` + -TestName 'Package version rewrite uses unambiguous regex replace overload' ` + -Passed (-not ($helperContent -match '\[regex\]::Replace\([^\r\n]+,\s*1\)')) ` + -Message 'Passing 1 to [regex]::Replace selects RegexOptions.IgnoreCase, not a replacement count.' + + $fixture = New-ReleaseFixture + try { + $result = Invoke-ReleasePreparation -RepoRoot $fixture -Bump minor -Date '2026-06-30' + $packageJson = Get-Content -Path (Join-Path $fixture 'package.json') -Raw | ConvertFrom-Json + $packageLock = Get-Content -Path (Join-Path $fixture 'package-lock.json') -Raw | ConvertFrom-Json -AsHashtable + $changelog = Get-Content -Path (Join-Path $fixture 'CHANGELOG.md') -Raw + + Write-TestResult ` + -TestName 'Release preparation updates package files' ` + -Passed ( + $result.NextVersion -eq '1.3.0' -and + $packageJson.version -eq '1.3.0' -and + $packageLock['version'] -eq '1.3.0' -and + $packageLock['packages']['']['version'] -eq '1.3.0' + ) + + Write-TestResult ` + -TestName 'Release preparation rotates changelog with a dated heading' ` + -Passed ( + $changelog -match '(?m)^## \[Unreleased\]\s*\n\s*## \[1\.3\.0\] - 2026-06-30\s*$' -and + $changelog.Contains('- A release-worthy change.') -and + $changelog.Contains('## [1.2.3]') + ) ` + -Message $changelog + + $notes = New-ReleaseNotes -RepoRoot $fixture -Version '1.3.0' -Footer + Write-TestResult ` + -TestName 'Release notes extract the rotated changelog section with footer' ` + -Passed ( + $notes.Contains('- A release-worthy change.') -and + $notes.Contains('com.example.fixture@1.3.0') -and + -not $notes.Contains('Old fix') + ) ` + -Message $notes + } finally { + Remove-ReleaseFixture -Path $fixture + } + + $emptyChangelog = @( + '# Changelog', + '', + '## [Unreleased]', + '', + '### Added', + '', + '## [1.2.3]', + '', + '- Old.', + '' + ) -join "`n" + $fixture = New-ReleaseFixture -Changelog $emptyChangelog + try { + $emptyRejected = $false + try { + [void](Invoke-ReleasePreparation -RepoRoot $fixture -Bump patch -Date '2026-06-30') + } catch { + $emptyRejected = $_.Exception.Message -match 'no release-note content' + } + Write-TestResult -TestName 'Empty Unreleased sections are rejected' -Passed $emptyRejected + } finally { + Remove-ReleaseFixture -Path $fixture + } + + $duplicateTargetChangelog = @( + '# Changelog', + '', + '## [Unreleased]', + '', + '### Added', + '', + '- A pending change.', + '', + '## [1.2.4] - 2026-06-30', + '', + '- Already rotated change.', + '', + '## [1.2.3]', + '', + '- Old.', + '' + ) -join "`n" + $fixture = New-ReleaseFixture -Changelog $duplicateTargetChangelog + try { + $duplicateTargetRejected = $false + try { + [void](Invoke-ReleasePreparation -RepoRoot $fixture -Version '1.2.4' -Date '2026-06-30') + } catch { + $duplicateTargetRejected = $_.Exception.Message -match 'already contains.*Unreleased' + } + Write-TestResult -TestName 'Existing target headings reject pending Unreleased content' -Passed $duplicateTargetRejected + } finally { + Remove-ReleaseFixture -Path $fixture + } + + $emptyDuplicateTargetChangelog = @( + '# Changelog', + '', + '## [Unreleased]', + '', + '### Added', + '', + '## [1.2.4] - 2026-06-30', + '', + '### Fixed', + '', + '## [1.2.3]', + '', + '- Old.', + '' + ) -join "`n" + $fixture = New-ReleaseFixture -Changelog $emptyDuplicateTargetChangelog + try { + $emptyDuplicateTargetRejected = $false + try { + [void](Invoke-ReleasePreparation -RepoRoot $fixture -Version '1.2.4' -Date '2026-06-30') + } catch { + $emptyDuplicateTargetRejected = $_.Exception.Message -match "section '## \[1\.2\.4\]' has no release-note content" + } + Write-TestResult -TestName 'Existing target headings require release-note content' -Passed $emptyDuplicateTargetRejected + } finally { + Remove-ReleaseFixture -Path $fixture + } + + $fencedChangelog = @( + '# Changelog', + '', + '## [Unreleased]', + '', + '### Added', + '', + '- Example:', + '', + '```markdown', + '## [1.2.4]', + '```', + '', + '- Still part of Unreleased.', + '', + '## [1.2.3]', + '', + '- Old.', + '' + ) -join "`n" + $fixture = New-ReleaseFixture -Changelog $fencedChangelog + try { + $result = Invoke-ReleasePreparation -RepoRoot $fixture -Bump patch -Date '2026-06-30' + $changelog = Get-Content -Path (Join-Path $fixture 'CHANGELOG.md') -Raw + Write-TestResult ` + -TestName 'Fenced changelog headings do not block release rotation' ` + -Passed ( + $result.NextVersion -eq '1.2.4' -and + $changelog.Contains('```markdown') -and + $changelog.Contains('## [1.2.4] - 2026-06-30') -and + $changelog.Contains('## [1.2.4]' + "`n" + '```') + ) ` + -Message $changelog + } finally { + Remove-ReleaseFixture -Path $fixture + } + + $longFenceChangelog = @( + '# Changelog', + '', + '## [Unreleased]', + '', + '### Added', + '', + '- Example:', + '', + '````markdown', + '```', + '## [1.2.4]', + '````', + '', + '- Still part of Unreleased.', + '', + '## [1.2.3]', + '', + '- Old.', + '' + ) -join "`n" + $fixture = New-ReleaseFixture -Changelog $longFenceChangelog + try { + $result = Invoke-ReleasePreparation -RepoRoot $fixture -Bump patch -Date '2026-06-30' + $changelog = Get-Content -Path (Join-Path $fixture 'CHANGELOG.md') -Raw + Write-TestResult ` + -TestName 'Long fenced changelog headings require matching close length' ` + -Passed ( + $result.NextVersion -eq '1.2.4' -and + $changelog.Contains('````markdown') -and + $changelog.Contains('## [1.2.4] - 2026-06-30') -and + $changelog.Contains('## [1.2.4]' + "`n" + '````') + ) ` + -Message $changelog + } finally { + Remove-ReleaseFixture -Path $fixture + } +} catch { + Write-TestResult -TestName 'Unexpected release-tool exception' -Passed $false -Message $_.Exception.ToString() +} + +Write-Host '' +Write-Host 'Results:' -ForegroundColor Magenta +Write-Host " Passed: $script:TestsPassed" +Write-Host " Failed: $script:TestsFailed" + +if ($script:TestsFailed -gt 0) { + exit 1 +} + +exit 0 diff --git a/scripts/tests/test-release-tools.ps1.meta b/scripts/tests/test-release-tools.ps1.meta new file mode 100644 index 000000000..6511a36d7 --- /dev/null +++ b/scripts/tests/test-release-tools.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 433d0c6be063cf2015e7ce30c4b44719 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/tests/test-shell-portability.sh b/scripts/tests/test-shell-portability.sh index cf8432f6e..046856d75 100755 --- a/scripts/tests/test-shell-portability.sh +++ b/scripts/tests/test-shell-portability.sh @@ -246,6 +246,112 @@ else "Guard line ${guard_line}, create-test-project line ${create_line}, mkdir line ${mkdir_line}" fi +echo "" +echo "--- B3: Unity package export project stays below artifacts root ---" + +run_test +unity_export_package="$REPO_ROOT/scripts/unity/export-unitypackage.sh" +root_guard_line=$(grep -nF '"${PROJECT_DIR}" == "${ARTIFACTS_ROOT}"' "$unity_export_package" | head -n 1 | cut -d: -f1) +outside_guard_line=$(grep -nF '"${PROJECT_DIR}" != "${ARTIFACTS_ROOT}/"*' "$unity_export_package" | head -n 1 | cut -d: -f1) +delete_line=$(grep -nF 'rm -rf "${PROJECT_DIR}"' "$unity_export_package" | head -n 1 | cut -d: -f1) +if [[ -z "$root_guard_line" || -z "$outside_guard_line" || -z "$delete_line" ]]; then + fail "Unity package export project guard is missing expected structure" \ + "root_guard_line='${root_guard_line}', outside_guard_line='${outside_guard_line}', delete_line='${delete_line}'" +elif (( root_guard_line < delete_line && outside_guard_line < delete_line )); then + pass "Unity package export refuses artifacts root before deleting the project directory" +else + fail "Unity package export validates project path too late" \ + "Root guard line ${root_guard_line}, outside guard line ${outside_guard_line}, delete line ${delete_line}" +fi + +echo "" +echo "--- B4: Unity package export supports bare output filenames ---" + +run_test +dirname_line=$(grep -nF 'string outputDirectory = Path.GetDirectoryName(outputPath);' "$unity_export_package" | head -n 1 | cut -d: -f1) +fallback_line=$(grep -nF 'outputDirectory = Directory.GetCurrentDirectory();' "$unity_export_package" | head -n 1 | cut -d: -f1) +create_line=$(grep -nF 'Directory.CreateDirectory(outputDirectory);' "$unity_export_package" | head -n 1 | cut -d: -f1) +if [[ -z "$dirname_line" || -z "$fallback_line" || -z "$create_line" ]]; then + fail "Unity package export output-directory fallback is missing expected structure" \ + "dirname_line='${dirname_line}', fallback_line='${fallback_line}', create_line='${create_line}'" +elif (( dirname_line < fallback_line && fallback_line < create_line )); then + pass "Unity package export falls back to the current directory for bare output filenames" +else + fail "Unity package export applies output-directory fallback too late" \ + "dirname line ${dirname_line}, fallback line ${fallback_line}, create line ${create_line}" +fi + +echo "" +echo "--- B5: Unity package export stages package content roots ---" + +run_test +stage_project="$REPO_ROOT/.artifacts/unity/shell-portability-unitypackage-stage" +stage_log="$(mktemp)" +rm -rf "$stage_project" +if bash "$unity_export_package" --stage-only --project-dir "$stage_project" >"$stage_log" 2>&1; then + staged_root="$stage_project/Assets/WallstopStudios/UnityHelpers" + required_stage_entries=( + "Runtime" + "Runtime.meta" + "Editor" + "Editor.meta" + "Samples" + "Shaders" + "Shaders.meta" + "Styles" + "Styles.meta" + "URP" + "URP.meta" + "link.xml" + "link.xml.meta" + ) + missing_stage_entries=() + for entry in "${required_stage_entries[@]}"; do + if [[ ! -e "$staged_root/$entry" ]]; then + missing_stage_entries+=("$entry") + fi + done + + if (( ${#missing_stage_entries[@]} == 0 )); then + pass "Unity package export stage contains all shipped package roots" + else + fail "Unity package export stage is missing package roots" \ + "Missing entries: ${missing_stage_entries[*]}" + fi +else + stage_tail="$(tail -n 40 "$stage_log" 2>/dev/null || true)" + fail "Unity package export stage-only command failed" "$stage_tail" +fi +rm -rf "$stage_project" +rm -f "$stage_log" + +echo "" +echo "--- B6: Unity package export rejects incomplete package metadata ---" + +run_test +metadata_fixture="$(mktemp -d)" +metadata_log="$(mktemp)" +mkdir -p "$metadata_fixture/scripts/unity" "$metadata_fixture/.github" +cp "$unity_export_package" "$metadata_fixture/scripts/unity/export-unitypackage.sh" +printf '{ "release": "2022.3.45f1" }\n' > "$metadata_fixture/.github/unity-versions.json" +printf '{ "name": "fixture-package" }\n' > "$metadata_fixture/package.json" +if bash "$metadata_fixture/scripts/unity/export-unitypackage.sh" \ + --stage-only \ + --project-dir "$metadata_fixture/.artifacts/unity/unitypackage-stage" \ + >"$metadata_log" 2>&1; then + fail "Unity package export fails fast when package metadata is incomplete" \ + "Expected export-unitypackage.sh to reject package.json without version." +else + if grep -Fq 'must define non-empty string name and version fields' "$metadata_log"; then + pass "Unity package export fails fast when package metadata is incomplete" + else + metadata_tail="$(tail -n 40 "$metadata_log" 2>/dev/null || true)" + fail "Unity package export reports incomplete package metadata clearly" "$metadata_tail" + fi +fi +rm -rf "$metadata_fixture" +rm -f "$metadata_log" + # ============================================================================= # Section C: Inappropriate stderr suppression in git hooks # ============================================================================= diff --git a/scripts/tests/test-sync-script-contracts.ps1 b/scripts/tests/test-sync-script-contracts.ps1 index 3a0c13e06..04d2dc993 100644 --- a/scripts/tests/test-sync-script-contracts.ps1 +++ b/scripts/tests/test-sync-script-contracts.ps1 @@ -59,6 +59,25 @@ function Get-RepoRoot { return (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) } +function Get-PowerShellSingleQuotedArrayEntries { + param( + [string]$Content, + [string]$VariableName + ) + + $escapedName = [regex]::Escape($VariableName) + $arrayPattern = '(?ms)\${0}\s*=\s*@\((?.*?)\)' -f $escapedName + $match = [regex]::Match($Content, $arrayPattern) + if (-not $match.Success) { + return @() + } + + return @( + [regex]::Matches($match.Groups['body'].Value, "'(?[^']+)'") | + ForEach-Object { $_.Groups['entry'].Value } + ) +} + function Assert-NoNewlineWriteHasFinalLfNormalization { param( [string]$ScriptPath, @@ -785,6 +804,28 @@ function Run-ReleaseDrafterChangelogVersionContractTests { } $workflowContent = Get-Content -Path $workflowPath -Raw + $workflowLines = @($workflowContent -split "`r?`n") + $onBlockLines = @() + $insideOnBlock = $false + foreach ($line in $workflowLines) { + if ($line -ceq 'on:') { + $insideOnBlock = $true + continue + } + if ($insideOnBlock -and $line -match '^\S') { + break + } + if ($insideOnBlock) { + $onBlockLines += $line + } + } + $hasPushTrigger = @($onBlockLines | Where-Object { $_ -match '^\s+push\s*:' }).Count -gt 0 + $hasWorkflowDispatchTrigger = @($onBlockLines | Where-Object { $_ -match '^\s+workflow_dispatch\s*:' }).Count -gt 0 + + Write-TestResult ` + -TestName 'release-drafter is manual-only so release publishes cannot race draft updates' ` + -Passed ((-not $hasPushTrigger) -and $hasWorkflowDispatchTrigger) ` + -Message 'Expected release-drafter.yml to expose workflow_dispatch without an automatic push trigger.' Write-TestResult ` -TestName 'release-drafter extracts latest changelog header before version selection' ` @@ -878,7 +919,6 @@ function Run-ReleaseDrafterChangelogVersionContractTests { -Passed ($workflowContent -match '-F tag_name="\$VERSION"' -and $workflowContent -match '-F name="\$VERSION"') ` -Message 'Expected release PATCH request to include tag_name/name fields from VERSION.' - $workflowLines = @($workflowContent -split "`r?`n") $earlyExitAfterChangelogNotice = $false for ($i = 0; $i -lt $workflowLines.Count; $i++) { if ($workflowLines[$i] -match 'Changelog section already exists') { @@ -906,6 +946,513 @@ function Run-ReleaseDrafterChangelogVersionContractTests { -Message 'Expected current release body to be preserved when changelog section already exists.' } +function Run-ReleaseWorkflowChangelogContractTests { + Write-Host "" + Write-Host "Release workflow changelog heading contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $workflowPaths = @( + Join-Path $repoRoot '.github/workflows/release-tag.yml' + Join-Path $repoRoot '.github/workflows/release.yml' + ) + $publishWorkflowPath = Join-Path $repoRoot '.github/workflows/release.yml' + $publishWorkflowContent = Get-Content -Path $publishWorkflowPath -Raw + + $missingSectionHelper = @() + $rawHeadingGrep = @() + foreach ($workflowPath in $workflowPaths) { + if (-not (Test-Path $workflowPath)) { + $missingSectionHelper += "missing: $workflowPath" + continue + } + + $content = Get-Content -Path $workflowPath -Raw + $relativePath = [System.IO.Path]::GetRelativePath($repoRoot, $workflowPath).Replace('\', '/') + if ($content -notmatch 'Get-ChangelogSection') { + $missingSectionHelper += $relativePath + } + if ($content -match 'grep\s+-Eq\s+"\^##\s+\\\[') { + $rawHeadingGrep += $relativePath + } + } + + Write-TestResult ` + -TestName 'release tag/publish workflows validate changelog release-note content' ` + -Passed ($missingSectionHelper.Count -eq 0) ` + -Message "Missing Get-ChangelogSection usage: $($missingSectionHelper -join '; ')" + + Write-TestResult ` + -TestName 'release tag/publish workflows avoid raw changelog heading grep' ` + -Passed ($rawHeadingGrep.Count -eq 0) ` + -Message "Raw heading grep found in: $($rawHeadingGrep -join '; ')" + + $strictReleaseTagRegex = '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + $acceptedReleaseTags = @('0.0.0', '1.2.3', '10.20.30') + $rejectedReleaseTags = @( + '01.2.3', + '1.02.3', + '1.2.03', + 'v1.2.3', + '1.2', + '1.2.3.4', + '1.2.x', + '1.2.3-alpha' + ) + $verifierHasStrictReleaseTagRegex = $publishWorkflowContent.Contains("grep -Eq '$strictReleaseTagRegex'") + $strictRegexAcceptsExpectedTags = @( + $acceptedReleaseTags | Where-Object { $_ -notmatch $strictReleaseTagRegex } + ).Count -eq 0 + $strictRegexRejectsExpectedTags = @( + $rejectedReleaseTags | Where-Object { $_ -match $strictReleaseTagRegex } + ).Count -eq 0 + + $publishTriggerDelegatesStrictnessToVerifier = ( + $publishWorkflowContent.Contains('- "[0-9]*.[0-9]*.[0-9]*"') -and + -not $publishWorkflowContent.Contains('- "[0-9]+.[0-9]+.[0-9]+"') -and + $publishWorkflowContent.Contains('Release tags must use unprefixed X.Y.Z semver.') -and + $verifierHasStrictReleaseTagRegex -and + $strictRegexAcceptsExpectedTags -and + $strictRegexRejectsExpectedTags + ) + + Write-TestResult ` + -TestName 'release publish workflow uses unambiguous tag glob before strict verification' ` + -Passed $publishTriggerDelegatesStrictnessToVerifier ` + -Message 'Expected release.yml tag filter to use an unambiguous digit-start glob while verify-tag enforces exact no-leading-zero semver.' +} + +function Run-ReleaseWorkflowGitHubCliContractTests { + Write-Host "" + Write-Host "Release workflow GitHub CLI contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $workflowPath = Join-Path $repoRoot '.github/workflows/release.yml' + $workflowContent = Get-Content -Path $workflowPath -Raw + $repoEnvPattern = 'GH_REPO:\s*\$\{\{\s*github\.repository\s*\}\}' + + $publishHasRepo = $workflowContent -match "(?ms)- name: Publish GitHub Release.*?env:.*?${repoEnvPattern}.*?run:" + $verifyHasRepo = $workflowContent -match "(?ms)- name: Verify GitHub Release assets.*?env:.*?${repoEnvPattern}.*?run:" + + Write-TestResult ` + -TestName 'release publish gh commands set repository context' ` + -Passed $publishHasRepo ` + -Message 'Expected Publish GitHub Release to set GH_REPO from github.repository.' + + Write-TestResult ` + -TestName 'release asset verification gh commands set repository context' ` + -Passed $verifyHasRepo ` + -Message 'Expected Verify GitHub Release assets to set GH_REPO from github.repository.' +} + +function Run-ReleasePublishWorkflowBudgetContractTests { + Write-Host "" + Write-Host "Release publish workflow budget contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $workflowPath = Join-Path $repoRoot '.github/workflows/release.yml' + $workflowContent = Get-Content -Path $workflowPath -Raw + $exporterPath = Join-Path $repoRoot 'scripts/unity/export-unitypackage.sh' + $exporterContent = Get-Content -Path $exporterPath -Raw + + $jobTimeoutMatch = [regex]::Match( + $workflowContent, + '(?ms)^\s*unitypackage:\s*.*?^\s*timeout-minutes:\s*(?\d+)\s*$' + ) + $lockTimeoutMatch = [regex]::Match( + $workflowContent, + '(?ms)^\s*- name: Acquire organization Unity lock\s*\r?\n(?:(?!^\s*- name:).)*?^\s+with:\s*\r?\n(?:(?!^\s*- name:).)*?^\s+timeout-minutes:\s*["'']?(?\d+)["'']?\s*$' + ) + $unityTimeoutMatch = [regex]::Match( + $exporterContent, + 'UNITY_TIMEOUT="\$\{UNITY_TIMEOUT:-(?\d+)\}"' + ) + + $jobTimeoutMinutes = if ($jobTimeoutMatch.Success) { [int]$jobTimeoutMatch.Groups['minutes'].Value } else { 0 } + $lockTimeoutMinutes = if ($lockTimeoutMatch.Success) { [int]$lockTimeoutMatch.Groups['minutes'].Value } else { 0 } + $unityTimeoutMinutes = if ($unityTimeoutMatch.Success) { [int][Math]::Ceiling(([int]$unityTimeoutMatch.Groups['seconds'].Value) / 60.0) } else { 0 } + $minimumOverheadMinutes = 30 + $requiredJobTimeoutMinutes = $lockTimeoutMinutes + $unityTimeoutMinutes + $minimumOverheadMinutes + $timeoutBudgetIsCoherent = ( + $jobTimeoutMatch.Success -and + $lockTimeoutMatch.Success -and + $unityTimeoutMatch.Success -and + $jobTimeoutMinutes -ge $requiredJobTimeoutMinutes + ) + + Write-TestResult ` + -TestName 'release unitypackage job timeout covers lock wait and export budget' ` + -Passed $timeoutBudgetIsCoherent ` + -Message "Expected unitypackage job timeout to be at least lock timeout + Unity export timeout + ${minimumOverheadMinutes}m overhead. Job=${jobTimeoutMinutes}m, lock=${lockTimeoutMinutes}m, Unity=${unityTimeoutMinutes}m, required=${requiredJobTimeoutMinutes}m." +} + +function Run-ReleasePrepareWorkflowContractTests { + Write-Host "" + Write-Host "Release prepare workflow contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $workflowPath = Join-Path $repoRoot '.github/workflows/release-prepare.yml' + $workflowContent = Get-Content -Path $workflowPath -Raw + + $usesRobustGitBranchLookup = ( + $workflowContent.Contains('git ls-remote --exit-code --heads origin "${branch}"') -and + $workflowContent.Contains('branch_lookup_exit=$?') -and + $workflowContent.Contains('if [ "${branch_lookup_exit}" -ne 2 ]; then') -and + $workflowContent.Contains('Failed to check whether branch ${branch} already exists.') -and + -not $workflowContent.Contains('git/ref/heads/${branch}') -and + -not $workflowContent.Contains('if git ls-remote --exit-code --heads origin "${branch}"') + ) + $usesRobustGitTagLookup = ( + $workflowContent.Contains('tag_lookup_output="$(gh api -i "repos/${GITHUB_REPOSITORY}/git/ref/tags/${version}" 2>&1)"') -and + $workflowContent.Contains('tag_lookup_exit=$?') -and + $workflowContent.Contains('if [ "${tag_lookup_exit}" -eq 0 ]; then') -and + $workflowContent.Contains('Failed to check whether tag ${version} already exists.') -and + $workflowContent.Contains('"status":"404"') -and + $workflowContent.Contains('grep -E ''(^HTTP/[0-9.]+ 404( |$)|"status":"404")'' >/dev/null') -and + -not $workflowContent.Contains('gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${version}" >/dev/null 2>&1') -and + -not ($workflowContent -match 'gh api[^\r\n]+\|\|\s*true') -and + -not ($workflowContent -match 'grep -Eq .*\bstatus') + ) + $notesIndex = $workflowContent.IndexOf('scripts/release-tools/write-release-notes.ps1') + $branchPushIndex = $workflowContent.IndexOf('push origin "HEAD:refs/heads/${BRANCH}"') + $generatesNotesBeforePushingBranch = ( + $notesIndex -ge 0 -and + $branchPushIndex -ge 0 -and + $notesIndex -lt $branchPushIndex + ) + + Write-TestResult ` + -TestName 'release prepare checks existing release branches with robust git heads lookup' ` + -Passed $usesRobustGitBranchLookup ` + -Message 'Expected release-prepare.yml to treat git ls-remote exit 2 as absent while failing other lookup errors.' + + Write-TestResult ` + -TestName 'release prepare checks existing tags without hiding API failures' ` + -Passed $usesRobustGitTagLookup ` + -Message 'Expected release-prepare.yml to treat tag lookup 404 as absent while failing auth, rate-limit, and other API errors.' + + Write-TestResult ` + -TestName 'release prepare validates release notes before pushing branch' ` + -Passed $generatesNotesBeforePushingBranch ` + -Message 'Expected write-release-notes.ps1 to run before pushing release/X.Y.Z so failed note generation leaves no remote branch.' +} + +function Run-ReleaseTagWorkflowContractTests { + Write-Host "" + Write-Host "Release tag workflow contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $workflowPath = Join-Path $repoRoot '.github/workflows/release-tag.yml' + $workflowContent = Get-Content -Path $workflowPath -Raw + + $hasTagTargetCheck = ( + $workflowContent.Contains('tag_target="$(git rev-list -n 1 "${version}")"') -and + $workflowContent.Contains('[ "${tag_target}" = "${GITHUB_SHA}" ]') -and + $workflowContent.Contains('already exists at ${tag_target}, not release commit ${GITHUB_SHA}') + ) + + $credentialStepIndex = $workflowContent.IndexOf('- name: Check auto-commit GitHub App credentials', [StringComparison]::Ordinal) + $tokenStepIndex = $workflowContent.IndexOf('- name: Generate auto-commit GitHub App token', [StringComparison]::Ordinal) + $hasCredentialCheck = ( + $credentialStepIndex -ge 0 -and + $tokenStepIndex -gt $credentialStepIndex -and + $workflowContent.Contains('AUTO_COMMIT_APP_ID: ${{ secrets.AUTO_COMMIT_APP_ID }}') -and + $workflowContent.Contains('AUTO_COMMIT_APP_PRIVATE_KEY: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }}') -and + $workflowContent.Contains('required to push release tags') + ) + + $hasDefaultBranchGate = ( + $workflowContent.Contains('github.ref_name == github.event.repository.default_branch') -and + $workflowContent -notmatch "(?ms)on:\s*\r?\n\s*push:\s*\r?\n\s*branches:" + ) + + $checkoutStepIndex = $workflowContent.IndexOf('- name: Checkout', [StringComparison]::Ordinal) + $checkoutFetchTagsIndex = if ($checkoutStepIndex -ge 0) { + $workflowContent.IndexOf('fetch-tags: true', $checkoutStepIndex, [StringComparison]::Ordinal) + } else { + -1 + } + $subjectCheckIndex = $workflowContent.IndexOf('subject="$(git log -1 --format=%s)"', [StringComparison]::Ordinal) + $nonReleaseExitIndex = $workflowContent.IndexOf('Head commit is not a release commit; nothing to do.', [StringComparison]::Ordinal) + $existingTagNoOpIndex = $workflowContent.IndexOf('git show-ref --verify --quiet "refs/tags/${version}"', [StringComparison]::Ordinal) + $untaggedWarningIndex = $workflowContent.IndexOf('Version ${version} is untagged and CHANGELOG.md documents it', [StringComparison]::Ordinal) + $nonReleaseProceedFalseIndex = if ($subjectCheckIndex -ge 0) { + $workflowContent.IndexOf('echo "proceed=false" >> "${GITHUB_OUTPUT}"', $subjectCheckIndex, [StringComparison]::Ordinal) + } else { + -1 + } + $nonReleaseExitZeroIndex = if ($nonReleaseProceedFalseIndex -ge 0) { + $workflowContent.IndexOf('exit 0', $nonReleaseProceedFalseIndex, [StringComparison]::Ordinal) + } else { + -1 + } + $releaseHeadingValidationIndex = if ($nonReleaseExitZeroIndex -ge 0) { + $workflowContent.IndexOf('Release commit for ${version} has no CHANGELOG.md section with release-note content.', $nonReleaseExitZeroIndex, [StringComparison]::Ordinal) + } else { + -1 + } + $tagLookupIndex = $workflowContent.IndexOf('tag_lookup_output="$(gh api -i "repos/${GITHUB_REPOSITORY}/git/ref/tags/${version}"', [StringComparison]::Ordinal) + $tagMismatchIndex = $workflowContent.IndexOf('already exists at ${tag_target}, not release commit ${GITHUB_SHA}', [StringComparison]::Ordinal) + $releaseSectionValidationIndex = if ($nonReleaseExitZeroIndex -ge 0) { + $workflowContent.IndexOf('Get-ChangelogSection -Content $content -Version $env:CHANGELOG_VERSION', $nonReleaseExitZeroIndex, [StringComparison]::Ordinal) + } else { + -1 + } + $checksTagsAfterReleaseDetection = ( + $subjectCheckIndex -ge 0 -and + $nonReleaseExitIndex -gt $subjectCheckIndex -and + $nonReleaseProceedFalseIndex -gt $nonReleaseExitIndex -and + $nonReleaseExitZeroIndex -gt $nonReleaseProceedFalseIndex -and + $releaseSectionValidationIndex -gt $nonReleaseExitZeroIndex -and + $releaseHeadingValidationIndex -gt $nonReleaseExitZeroIndex -and + $tagLookupIndex -gt $releaseHeadingValidationIndex -and + $tagMismatchIndex -gt $tagLookupIndex + ) + $checksLocalTagBeforeUntaggedWarning = ( + $subjectCheckIndex -ge 0 -and + $existingTagNoOpIndex -gt $subjectCheckIndex -and + $untaggedWarningIndex -gt $existingTagNoOpIndex -and + $existingTagNoOpIndex -lt $nonReleaseProceedFalseIndex + ) + $fetchesTagsBeforeLocalTaggedNoOp = ( + $checkoutStepIndex -ge 0 -and + $checkoutFetchTagsIndex -gt $checkoutStepIndex -and + $checkoutFetchTagsIndex -lt $existingTagNoOpIndex + ) + $usesRobustTagLookup = ( + $tagLookupIndex -ge 0 -and + $workflowContent.Contains('tag_lookup_exit=$?') -and + $workflowContent.Contains('if [ "${tag_lookup_exit}" -eq 0 ]; then') -and + $workflowContent.Contains('Failed to check whether tag ${version} already exists.') -and + $workflowContent.Contains('"status":"404"') -and + $workflowContent.Contains('grep -E ''(^HTTP/[0-9.]+ 404( |$)|"status":"404")'' >/dev/null') -and + -not ($workflowContent -match 'gh api[^\r\n]+\|\|\s*true') -and + -not ($workflowContent -match 'grep -Eq .*\bstatus') + ) + + Write-TestResult ` + -TestName 'release tag workflow fails when existing tag points elsewhere' ` + -Passed $hasTagTargetCheck ` + -Message 'Expected release-tag.yml to compare existing tag target with GITHUB_SHA and error on mismatches.' + + Write-TestResult ` + -TestName 'release tag workflow checks existing tags only after release detection' ` + -Passed $checksTagsAfterReleaseDetection ` + -Message 'Expected release-tag.yml to exit cleanly for non-release package/changelog pushes before checking existing tag targets.' + + Write-TestResult ` + -TestName 'release tag workflow validates changelog release-note content before tag lookup' ` + -Passed ($releaseSectionValidationIndex -gt $nonReleaseExitZeroIndex -and $releaseSectionValidationIndex -lt $tagLookupIndex) ` + -Message 'Expected release-tag.yml to call Get-ChangelogSection before checking or creating a release tag.' + + Write-TestResult ` + -TestName 'release tag workflow checks existing tags without hiding API failures' ` + -Passed $usesRobustTagLookup ` + -Message 'Expected release-tag.yml to treat tag lookup 404 as absent while failing auth, rate-limit, and other API errors.' + + Write-TestResult ` + -TestName 'release tag workflow suppresses untagged warning for locally-known tags' ` + -Passed $checksLocalTagBeforeUntaggedWarning ` + -Message 'Expected release-tag.yml to check refs/tags/${version} before warning that the documented version is untagged.' + + Write-TestResult ` + -TestName 'release tag workflow fetches tags before local tagged no-op check' ` + -Passed $fetchesTagsBeforeLocalTaggedNoOp ` + -Message 'Expected release-tag.yml checkout to fetch tags before using local refs/tags/${version} for the non-release no-op path.' + + Write-TestResult ` + -TestName 'release tag workflow checks app credentials before token action' ` + -Passed $hasCredentialCheck ` + -Message 'Expected release-tag.yml to validate AUTO_COMMIT_APP_* before create-github-app-token.' + + Write-TestResult ` + -TestName 'release tag workflow runs on repository default branch' ` + -Passed $hasDefaultBranchGate ` + -Message 'Expected release-tag.yml to avoid hard-coded main/master branch filters and gate on github.event.repository.default_branch.' +} + +function Run-ReleasePackageContentContractTests { + Write-Host "" + Write-Host "Release package content contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $requiredUnityPackageEntries = @( + 'Editor', + 'Editor.meta', + 'Runtime', + 'Runtime.meta', + 'Samples~', + 'Shaders', + 'Shaders.meta', + 'Styles', + 'Styles.meta', + 'URP', + 'URP.meta', + 'link.xml', + 'link.xml.meta' + ) + $requiredPackageFilesEntries = @( + $requiredUnityPackageEntries + 'scripts.meta' + 'scripts/postinstall-hooks.js' + 'scripts/postinstall-hooks.js.meta' + ) + $requiredUnityPackageFolders = @( + 'Editor', + 'Runtime', + 'Samples~', + 'Shaders', + 'Styles', + 'URP' + ) + + $packagePath = Join-Path $repoRoot 'package.json' + $package = Get-Content -Path $packagePath -Raw | ConvertFrom-Json + $packageFiles = @($package.files) + $missingPackageFiles = @($requiredPackageFilesEntries | Where-Object { $_ -notin $packageFiles }) + + Write-TestResult ` + -TestName 'package.json files allowlist includes all required release package entries' ` + -Passed ($missingPackageFiles.Count -eq 0) ` + -Message "Missing package.json files entries: $($missingPackageFiles -join ', ')" + + $validatorPath = Join-Path $repoRoot 'scripts/validate-npm-package.ps1' + $validatorContent = Get-Content -Path $validatorPath -Raw + $validatorRequiredEntries = Get-PowerShellSingleQuotedArrayEntries ` + -Content $validatorContent ` + -VariableName 'requiredPackageEntries' + $validatorAllowedTopLevelEntries = Get-PowerShellSingleQuotedArrayEntries ` + -Content $validatorContent ` + -VariableName 'allowedTopLevelEntries' + $validatorUnityFolders = Get-PowerShellSingleQuotedArrayEntries ` + -Content $validatorContent ` + -VariableName 'unityFolders' + $validatorPackageContentRoots = Get-PowerShellSingleQuotedArrayEntries ` + -Content $validatorContent ` + -VariableName 'packageContentRoots' + $missingValidatorRequiredEntries = @($requiredPackageFilesEntries | Where-Object { $_ -notin $validatorRequiredEntries }) + $requiredValidatorAllowedTopLevelEntries = @('scripts', 'scripts.meta') + $missingValidatorAllowedTopLevelEntries = @($requiredValidatorAllowedTopLevelEntries | Where-Object { $_ -notin $validatorAllowedTopLevelEntries }) + $nestedValidatorAllowedTopLevelEntries = @($validatorAllowedTopLevelEntries | Where-Object { $_.Contains('/') -or $_.Contains('\') }) + $missingValidatorUnityFolders = @($requiredUnityPackageFolders | Where-Object { $_ -notin $validatorUnityFolders }) + $missingValidatorPackageContentRoots = @($requiredPackageFilesEntries | Where-Object { $_ -notin $validatorPackageContentRoots }) + $validatorUsesCaseSensitiveMembership = ( + $validatorContent.Contains('$entry -cnotin $allowedTopLevelEntries') -and + $validatorContent.Contains('$entry -cnotin $allowedScriptsEntries') -and + $validatorContent.Contains('$gitFile -cnotin $npmPackageFiles') -and + $validatorContent.Contains('$npmFile -cnotin $gitPackageFiles') + ) + $validatorPreservesCaseOnlyPathVariants = -not $validatorContent.Contains('Sort-Object -Unique') + $validatorComparesWholePackagePayload = ( + $validatorContent.Contains('Get-TrackedPackageFiles -RepoRoot $repoRoot -PackageRoots $packageContentRoots') -and + $validatorContent.Contains('Get-PackedPackageFiles -PackageDir $packageDir') -and + $validatorContent.Contains('Get-ChildItem -LiteralPath $PackageDir -Recurse -File -Force') -and + $validatorContent.Contains('git -C $RepoRoot ls-files -z -- @trackedRoots') -and + -not $validatorContent.Contains('Test-Path -LiteralPath (Join-Path $RepoRoot $_)') -and + $missingValidatorPackageContentRoots.Count -eq 0 + ) + $validatorChecksHiddenScriptEntries = $validatorContent.Contains('Get-ChildItem -LiteralPath $scriptsDir -Recurse -File -Force') + $validatorChecksHiddenPackedCsFiles = $validatorContent.Contains("Get-ChildItem -LiteralPath `$packageDir -Recurse -File -Filter '*.cs' -Force") + $validatorChecksHiddenUnityFolderEntries = $validatorContent.Contains('Get-ChildItem -LiteralPath $folderPath -Recurse -Force') + $validatorUsesStructuredRelativePaths = ( + $validatorContent.Contains('function ConvertTo-PackageRelativePath') -and + $validatorContent.Contains('[System.IO.Path]::GetRelativePath($rootPath, $childPath)') -and + -not $validatorContent.Contains('.FullName.Replace(') + ) + $productionPowerShellScriptsWithStringPathExtraction = @( + Get-ChildItem -LiteralPath (Join-Path $repoRoot 'scripts') -Recurse -File -Filter '*.ps1' | + Where-Object { $_.FullName -notmatch '[\\/](scripts[\\/])?tests[\\/]' } | + Where-Object { (Get-Content -LiteralPath $_.FullName -Raw).Contains('.FullName.Replace(') } | + ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') } + ) + + Write-TestResult ` + -TestName 'npm package validator requires all release package entries' ` + -Passed ($missingValidatorRequiredEntries.Count -eq 0) ` + -Message "Missing validator required entries: $($missingValidatorRequiredEntries -join ', ')" + + Write-TestResult ` + -TestName 'npm package validator allows shipped scripts folder metadata' ` + -Passed ($missingValidatorAllowedTopLevelEntries.Count -eq 0) ` + -Message "Missing validator allowed top-level entries: $($missingValidatorAllowedTopLevelEntries -join ', ')" + + Write-TestResult ` + -TestName 'npm package validator top-level allowlist contains only top-level entries' ` + -Passed ($nestedValidatorAllowedTopLevelEntries.Count -eq 0) ` + -Message "Nested entries in top-level allowlist: $($nestedValidatorAllowedTopLevelEntries -join ', ')" + + Write-TestResult ` + -TestName 'npm package validator compares all shipped Unity roots against git' ` + -Passed ($missingValidatorUnityFolders.Count -eq 0) ` + -Message "Missing validator Unity folders: $($missingValidatorUnityFolders -join ', ')" + + Write-TestResult ` + -TestName 'npm package validator compares whole release payload against git' ` + -Passed $validatorComparesWholePackagePayload ` + -Message "Missing validator package content roots: $($missingValidatorPackageContentRoots -join ', ')" + + Write-TestResult ` + -TestName 'npm package validator checks hidden Unity folder entries for metadata' ` + -Passed $validatorChecksHiddenUnityFolderEntries ` + -Message 'Expected Unity folder metadata validation to enumerate with -LiteralPath and -Force, matching packed payload parity.' + + Write-TestResult ` + -TestName 'npm package validator checks hidden scripts entries against allowlist' ` + -Passed $validatorChecksHiddenScriptEntries ` + -Message 'Expected scripts folder allowlist validation to enumerate with -LiteralPath and -Force.' + + Write-TestResult ` + -TestName 'npm package validator checks hidden C# files for root restrictions' ` + -Passed $validatorChecksHiddenPackedCsFiles ` + -Message 'Expected packed C# root validation to enumerate with -LiteralPath and -Force.' + + Write-TestResult ` + -TestName 'npm package validator uses structured relative path extraction' ` + -Passed $validatorUsesStructuredRelativePaths ` + -Message 'Expected validate-npm-package.ps1 to use GetRelativePath instead of string replacement on FullName.' + + Write-TestResult ` + -TestName 'production PowerShell scripts avoid string-based FullName relative paths' ` + -Passed ($productionPowerShellScriptsWithStringPathExtraction.Count -eq 0) ` + -Message "Scripts still using string-based FullName relative path extraction: $($productionPowerShellScriptsWithStringPathExtraction -join ', ')" + + Write-TestResult ` + -TestName 'npm package validator uses case-sensitive package membership checks' ` + -Passed $validatorUsesCaseSensitiveMembership ` + -Message 'Expected validate-npm-package.ps1 to reject differently-cased package paths with -cnotin.' + + Write-TestResult ` + -TestName 'npm package validator preserves case-only path variants before membership checks' ` + -Passed $validatorPreservesCaseOnlyPathVariants ` + -Message 'Expected validate-npm-package.ps1 not to collapse case-only path variants with Sort-Object -Unique.' + + $exporterPath = Join-Path $repoRoot 'scripts/unity/export-unitypackage.sh' + $exporterContent = Get-Content -Path $exporterPath -Raw + $requiredLoop = [regex]::Match( + $exporterContent, + '(?ms)for entry in \\\s*(?.*?)\s*do\s*\r?\n\s*copy_package_entry "\$\{entry\}" required' + ) + $requiredExportEntries = @() + if ($requiredLoop.Success) { + $requiredExportEntries = @( + $requiredLoop.Groups['entries'].Value -split "`r?`n" | + ForEach-Object { $_.Trim().TrimEnd('\').Trim() } | + Where-Object { $_ } + ) + } + $missingExportEntries = @($requiredUnityPackageEntries | Where-Object { $_ -notin $requiredExportEntries }) + + Write-TestResult ` + -TestName 'Unity package exporter stages all shipped Unity roots from npm pack' ` + -Passed ($requiredLoop.Success -and $missingExportEntries.Count -eq 0) ` + -Message "Missing exporter required entries: $($missingExportEntries -join ', ')" +} + function Print-SummaryAndExit { Write-Host "" Write-Host "Results:" -ForegroundColor Magenta @@ -932,4 +1479,10 @@ Run-HookInstallContractTests Run-RepoLocalPrettierContractTests Run-PrePushLastResortGuidanceContractTests Run-ReleaseDrafterChangelogVersionContractTests +Run-ReleaseWorkflowChangelogContractTests +Run-ReleaseWorkflowGitHubCliContractTests +Run-ReleasePublishWorkflowBudgetContractTests +Run-ReleasePrepareWorkflowContractTests +Run-ReleaseTagWorkflowContractTests +Run-ReleasePackageContentContractTests Print-SummaryAndExit diff --git a/scripts/tests/test-unity-workflow-matrix-contract.ps1 b/scripts/tests/test-unity-workflow-matrix-contract.ps1 index 0f12732cb..ba0d0e834 100644 --- a/scripts/tests/test-unity-workflow-matrix-contract.ps1 +++ b/scripts/tests/test-unity-workflow-matrix-contract.ps1 @@ -23,6 +23,7 @@ if (-not (Test-Path -LiteralPath $workflowPath)) { [string[]]$lines = Get-Content -LiteralPath $workflowPath [bool]$failed = $false [bool]$insideJobs = $false +$jobTexts = @{} for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^jobs:\s*$') { @@ -49,6 +50,7 @@ for ($i = 0; $i -lt $lines.Count; $i++) { [string[]]$jobLines = @($lines[$start..($end - 1)]) [string]$jobText = $jobLines -join "`n" + $jobTexts[$jobId] = $jobText [bool]$hasJobIf = $jobText -match '(?m)^ if:\s*' [bool]$hasMatrixPresenceGate = $hasJobIf -and $jobText -match "matrix-include[^`n]+!=\s*'\[\]'" [bool]$hasDynamicMatrixInclude = $jobText -match 'fromJSON\(needs\.[^)]+\.outputs\.matrix-include' @@ -68,6 +70,49 @@ for ($i = 0; $i -lt $lines.Count; $i++) { $i = $end - 1 } +if (-not $jobTexts.ContainsKey('unity-tests-single-threaded')) { + Write-Host "::error file=.github/workflows/unity-tests.yml::Missing unity-tests-single-threaded job." + $failed = $true +} else { + $singleThreadedJob = $jobTexts['unity-tests-single-threaded'] + $requiredSingleThreadedContracts = @( + @{ + Name = 'needs main Unity matrix' + Pattern = '(?m)^ - unity-tests\s*$' + Message = 'unity-tests-single-threaded must wait for unity-tests so same-workflow jobs do not contend for the org Unity lock.' + }, + @{ + Name = 'needs standalone Unity tier' + Pattern = '(?m)^ - unity-tests-standalone\s*$' + Message = 'unity-tests-single-threaded must wait for unity-tests-standalone so same-workflow jobs do not contend for the org Unity lock after the fast tier.' + }, + @{ + Name = 'uses always for skipped standalone' + Pattern = 'always\(\)' + Message = 'unity-tests-single-threaded must use always() so workflow_dispatch runs with a skipped standalone tier can still evaluate its result gate.' + }, + @{ + Name = 'requires successful main Unity matrix' + Pattern = "needs\.unity-tests\.result\s*==\s*'success'" + Message = 'unity-tests-single-threaded must run only after unity-tests succeeds.' + }, + @{ + Name = 'accepts skipped standalone tier' + Pattern = "needs\.unity-tests-standalone\.result\s*==\s*'skipped'" + Message = 'unity-tests-single-threaded must allow unity-tests-standalone to be skipped for single-mode dispatch pins.' + } + ) + + foreach ($contract in $requiredSingleThreadedContracts) { + if ($singleThreadedJob -notmatch $contract.Pattern) { + Write-Host "::error file=.github/workflows/unity-tests.yml::Unity workflow contract failed ($($contract.Name)): $($contract.Message)" + $failed = $true + } elseif ($VerboseOutput) { + Write-Info "Checked unity-tests-single-threaded contract '$($contract.Name)'." + } + } +} + if ($failed) { exit 1 } diff --git a/scripts/unity/export-unitypackage.sh b/scripts/unity/export-unitypackage.sh new file mode 100755 index 000000000..a88c0edab --- /dev/null +++ b/scripts/unity/export-unitypackage.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +UNITY_VERSION="${UNITY_VERSION:-$(jq -r '.release' "${REPO_ROOT}/.github/unity-versions.json")}" +PROJECT_DIR="${UNITY_PACKAGE_PROJECT_DIR:-${REPO_ROOT}/.artifacts/unity/unitypackage-project}" +OUTPUT_PATH="" +STAGE_ONLY=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + OUTPUT_PATH="$2" + shift 2 + ;; + --project-dir) + PROJECT_DIR="$2" + shift 2 + ;; + --stage-only) + STAGE_ONLY=1 + shift + ;; + *) + echo "ERROR: Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +PACKAGE_JSON="${REPO_ROOT}/package.json" +PACKAGE_NAME="$(jq -r '(.name // empty) | strings | select(test("\\S"))' "${PACKAGE_JSON}")" +PACKAGE_VERSION="$(jq -r '(.version // empty) | strings | select(test("\\S"))' "${PACKAGE_JSON}")" +if [[ -z "${PACKAGE_NAME}" || -z "${PACKAGE_VERSION}" ]]; then + echo "ERROR: ${PACKAGE_JSON} must define non-empty string name and version fields." >&2 + exit 1 +fi + +if [[ -z "${OUTPUT_PATH}" ]]; then + OUTPUT_PATH="${REPO_ROOT}/.artifacts/release/${PACKAGE_NAME}-${PACKAGE_VERSION}.unitypackage" +fi + +ARTIFACTS_ROOT="$(realpath -m "${REPO_ROOT}/.artifacts")" +PROJECT_DIR="$(realpath -m "${PROJECT_DIR}")" +if [[ "${PROJECT_DIR}" == "${ARTIFACTS_ROOT}" || "${PROJECT_DIR}" != "${ARTIFACTS_ROOT}/"* ]]; then + echo "ERROR: Refusing to create the export project unless it is a subdirectory under ${ARTIFACTS_ROOT}: ${PROJECT_DIR}" >&2 + exit 1 +fi + +echo "==> [export-unitypackage] Package: ${PACKAGE_NAME}@${PACKAGE_VERSION}" +echo "==> [export-unitypackage] Unity version: ${UNITY_VERSION}" +echo "==> [export-unitypackage] Project: ${PROJECT_DIR}" +echo "==> [export-unitypackage] Output: ${OUTPUT_PATH}" + +rm -rf "${PROJECT_DIR}" +mkdir -p "${PROJECT_DIR}/Assets/WallstopStudios" "${PROJECT_DIR}/Assets/Editor" "${PROJECT_DIR}/ProjectSettings" "${PROJECT_DIR}/Packages" + +cat > "${PROJECT_DIR}/ProjectSettings/ProjectVersion.txt" << EOF +m_EditorVersion: ${UNITY_VERSION} +EOF + +cat > "${PROJECT_DIR}/ProjectSettings/ProjectSettings.asset" << 'EOF' +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!129 &1 +PlayerSettings: + productName: UnityHelpers-PackageExport + companyName: WallstopStudios + runInBackground: 1 +EOF + +cat > "${PROJECT_DIR}/ProjectSettings/EditorSettings.asset" << 'EOF' +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!159 &1 +EditorSettings: + m_DefaultBehaviorMode: 1 +EOF + +jq '{dependencies: ({"com.unity.test-framework": "1.1.33"} + .modules)}' \ + "${REPO_ROOT}/.github/unity-test-project-modules.json" > "${PROJECT_DIR}/Packages/manifest.json" + +PACK_TEMP="$(mktemp -d)" +cleanup() { + rm -rf "${PACK_TEMP}" +} +trap cleanup EXIT + +PACK_DIR="${PACK_TEMP}/pack" +EXTRACT_DIR="${PACK_TEMP}/extract" +mkdir -p "${PACK_DIR}" "${EXTRACT_DIR}" + +pushd "${REPO_ROOT}" > /dev/null +PACK_JSON="$(npm pack --json --pack-destination "${PACK_DIR}")" +popd > /dev/null + +PACKAGE_FILE="$(printf '%s' "${PACK_JSON}" | jq -r '.[0].filename')" +if [[ -z "${PACKAGE_FILE}" || ! -f "${PACK_DIR}/${PACKAGE_FILE}" ]]; then + echo "ERROR: npm pack did not produce a tarball." >&2 + exit 1 +fi + +tar -xzf "${PACK_DIR}/${PACKAGE_FILE}" -C "${EXTRACT_DIR}" +SOURCE_ROOT="${EXTRACT_DIR}/package" +STAGED_ROOT="${PROJECT_DIR}/Assets/WallstopStudios/UnityHelpers" +mkdir -p "${STAGED_ROOT}" + +copy_package_entry() { + local entry="$1" + local required="$2" + local source="${SOURCE_ROOT}/${entry}" + local target="${STAGED_ROOT}/${entry}" + + if [[ ! -e "${source}" ]]; then + if [[ "${required}" == "required" ]]; then + echo "ERROR: Packed npm package is missing required Unity export entry: ${entry}" >&2 + exit 1 + fi + return 0 + fi + + mkdir -p "$(dirname "${target}")" + cp -a "${source}" "${target}" +} + +for entry in \ + package.json \ + package.json.meta \ + README.md \ + README.md.meta \ + LICENSE \ + LICENSE.meta \ + CHANGELOG.md \ + CHANGELOG.md.meta \ + Runtime \ + Runtime.meta \ + Editor \ + Editor.meta \ + Samples~ \ + Shaders \ + Shaders.meta \ + Styles \ + Styles.meta \ + URP \ + URP.meta \ + link.xml \ + link.xml.meta +do + copy_package_entry "${entry}" required +done + +for entry in docs docs.meta; do + copy_package_entry "${entry}" optional +done + +if [[ -d "${STAGED_ROOT}/Samples~" ]]; then + rm -rf "${STAGED_ROOT}/Samples" + mv "${STAGED_ROOT}/Samples~" "${STAGED_ROOT}/Samples" +fi + +cat > "${PROJECT_DIR}/Assets/Editor/UnityHelpersPackageExporter.cs" << 'EOF' +using System; +using System.IO; +using UnityEditor; + +public static class UnityHelpersPackageExporter +{ + public static void Export() + { + string outputPath = GetArgument("-exportOutput"); + if (string.IsNullOrWhiteSpace(outputPath)) + { + throw new InvalidOperationException("Missing -exportOutput argument."); + } + + string outputDirectory = Path.GetDirectoryName(outputPath); + if (string.IsNullOrWhiteSpace(outputDirectory)) + { + outputDirectory = Directory.GetCurrentDirectory(); + } + + Directory.CreateDirectory(outputDirectory); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + AssetDatabase.ExportPackage( + "Assets/WallstopStudios/UnityHelpers", + outputPath, + ExportPackageOptions.Recurse + ); + + FileInfo exported = new FileInfo(outputPath); + if (!exported.Exists || exported.Length <= 0) + { + throw new InvalidOperationException("Unity package export did not produce a non-empty file: " + outputPath); + } + } + + private static string GetArgument(string name) + { + string[] args = Environment.GetCommandLineArgs(); + for (int index = 0; index < args.Length - 1; index++) + { + if (args[index] == name) + { + return args[index + 1]; + } + } + + return string.Empty; + } +} +EOF + +if [[ "${STAGE_ONLY}" -eq 1 ]]; then + echo "==> [export-unitypackage] Stage-only mode complete." + exit 0 +fi + +INTERNAL_OUTPUT_DIR="${PROJECT_DIR}/unitypackage-output" +INTERNAL_OUTPUT="${INTERNAL_OUTPUT_DIR}/$(basename "${OUTPUT_PATH}")" +mkdir -p "${INTERNAL_OUTPUT_DIR}" "$(dirname "${OUTPUT_PATH}")" + +UNITY_TEST_PROJECT_DIR="${PROJECT_DIR}" \ +UNITY_VERSION="${UNITY_VERSION}" \ +UNITY_TIMEOUT="${UNITY_TIMEOUT:-7200}" \ +"${SCRIPT_DIR}/run-unity-docker.sh" \ + -batchmode -nographics -quit \ + -projectPath /project \ + -executeMethod UnityHelpersPackageExporter.Export \ + -exportOutput "/project/unitypackage-output/$(basename "${OUTPUT_PATH}")" \ + -logFile - + +if [[ ! -s "${INTERNAL_OUTPUT}" ]]; then + echo "ERROR: Unity package was not exported: ${INTERNAL_OUTPUT}" >&2 + exit 1 +fi + +cp -f "${INTERNAL_OUTPUT}" "${OUTPUT_PATH}" +(cd "$(dirname "${OUTPUT_PATH}")" && sha256sum "$(basename "${OUTPUT_PATH}")" > "$(basename "${OUTPUT_PATH}").sha256") + +echo "==> [export-unitypackage] Exported ${OUTPUT_PATH}" diff --git a/scripts/unity/export-unitypackage.sh.meta b/scripts/unity/export-unitypackage.sh.meta new file mode 100644 index 000000000..4b82cc495 --- /dev/null +++ b/scripts/unity/export-unitypackage.sh.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 71bac0afb1843bb5f44894654c2fd6e8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-npm-package.ps1 b/scripts/validate-npm-package.ps1 index ca8577476..677f7ef6a 100644 --- a/scripts/validate-npm-package.ps1 +++ b/scripts/validate-npm-package.ps1 @@ -17,6 +17,83 @@ function Write-Error-Custom($msg) { Write-Host "[validate-npm-package] $msg" -ForegroundColor Red } +function ConvertTo-PackageRelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$BasePath, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $rootPath = [System.IO.Path]::GetFullPath($BasePath) + $childPath = [System.IO.Path]::GetFullPath($Path) + + return [System.IO.Path]::GetRelativePath($rootPath, $childPath).Replace('\', '/') +} + +function Get-TrackedPackageFiles { + param( + [string]$RepoRoot, + [string[]]$PackageRoots + ) + + $trackedRoots = @($PackageRoots | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($trackedRoots.Count -eq 0) { + return @() + } + + $trackedFiles = (& git -C $RepoRoot ls-files -z -- @trackedRoots) -split "`0" | Where-Object { $_ -ne '' } + if ($LASTEXITCODE -ne 0) { + throw "git ls-files failed while collecting tracked package files." + } + + return @( + $trackedFiles | + ForEach-Object { $_ -replace '\\', '/' } | + Sort-Object + ) +} + +function Get-PackedPackageFiles { + param( + [string]$PackageDir + ) + + return @( + Get-ChildItem -LiteralPath $PackageDir -Recurse -File -Force | + ForEach-Object { + ConvertTo-PackageRelativePath -BasePath $PackageDir -Path $_.FullName + } | + Sort-Object + ) +} + +function Test-ExpectedPackageExclusion { + param( + [string]$RelativePath + ) + + $excludePatterns = @( + '.gitkeep', + '*.dll', + '*.pdb', + '*.tmp', + '*.log', + '*.rsp' + ) + + $fileName = Split-Path -Leaf $RelativePath + foreach ($pattern in $excludePatterns) { + if (($RelativePath -like $pattern) -or ($fileName -like $pattern)) { + return $true + } + } + + return $false +} + +$repoRoot = (Get-Location).Path + # Step 1: Create a temporary directory for npm pack $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "npm-package-validation-$(Get-Random)" Write-Info "Creating temporary directory: $tempDir" @@ -55,9 +132,159 @@ try { # Step 5: Validate Unity folders and meta files $errors = @() + + $forbiddenPackageEntries = @( + '.artifacts', + '.cursor', + '.git', + '.github', + '.githooks', + '.llm', + '.mcp.json', + 'node_modules', + 'package-lock.json', + 'Tests' + ) + + $allowedTopLevelEntries = @( + 'CHANGELOG.md', + 'CHANGELOG.md.meta', + 'Editor', + 'Editor.meta', + 'LICENSE', + 'LICENSE.meta', + 'README.md', + 'README.md.meta', + 'Runtime', + 'Runtime.meta', + 'Samples~', + 'Shaders', + 'Shaders.meta', + 'Styles', + 'Styles.meta', + 'URP', + 'URP.meta', + 'docs', + 'docs.meta', + 'link.xml', + 'link.xml.meta', + 'package.json', + 'package.json.meta', + 'scripts', + 'scripts.meta' + ) + + foreach ($entry in $forbiddenPackageEntries) { + $entryPath = Join-Path $packageDir $entry + if (Test-Path $entryPath) { + $errors += "Forbidden development entry included in npm package: $entry" + } + } + + $topLevelEntries = Get-ChildItem -LiteralPath $packageDir -Force | ForEach-Object { $_.Name } + foreach ($entry in $topLevelEntries) { + if ($entry -cnotin $allowedTopLevelEntries) { + $errors += "Unexpected top-level entry included in npm package: $entry" + } + } + + $scriptsDir = Join-Path $packageDir 'scripts' + if (Test-Path -LiteralPath $scriptsDir) { + $allowedScriptsEntries = @( + 'postinstall-hooks.js', + 'postinstall-hooks.js.meta' + ) + $scriptEntries = Get-ChildItem -LiteralPath $scriptsDir -Recurse -File -Force | ForEach-Object { + ConvertTo-PackageRelativePath -BasePath $scriptsDir -Path $_.FullName + } + foreach ($entry in $scriptEntries) { + if ($entry -cnotin $allowedScriptsEntries) { + $errors += "Unexpected script included in npm package: scripts/$entry" + } + } + } + + $requiredPackageEntries = @( + 'CHANGELOG.md', + 'CHANGELOG.md.meta', + 'Editor', + 'Editor.meta', + 'LICENSE', + 'LICENSE.meta', + 'README.md', + 'README.md.meta', + 'Runtime', + 'Runtime.meta', + 'Samples~', + 'scripts.meta', + 'scripts/postinstall-hooks.js', + 'scripts/postinstall-hooks.js.meta', + 'Shaders', + 'Shaders.meta', + 'Styles', + 'Styles.meta', + 'URP', + 'URP.meta', + 'link.xml', + 'link.xml.meta', + 'package.json', + 'package.json.meta' + ) + foreach ($entry in $requiredPackageEntries) { + $entryPath = Join-Path $packageDir $entry + if (-not (Test-Path -LiteralPath $entryPath)) { + $errors += "Missing required package entry: $entry" + } + } + + $packageContentRoots = @( + 'CHANGELOG.md', + 'CHANGELOG.md.meta', + 'Editor', + 'Editor.meta', + 'LICENSE', + 'LICENSE.meta', + 'README.md', + 'README.md.meta', + 'Runtime', + 'Runtime.meta', + 'Samples~', + 'Shaders', + 'Shaders.meta', + 'Styles', + 'Styles.meta', + 'URP', + 'URP.meta', + 'docs', + 'docs.meta', + 'link.xml', + 'link.xml.meta', + 'package.json', + 'package.json.meta', + 'scripts.meta', + 'scripts/postinstall-hooks.js', + 'scripts/postinstall-hooks.js.meta' + ) + + $allowedCsRoots = @('Runtime/', 'Editor/', 'Samples~/', 'Styles/') + $packedCsFiles = Get-ChildItem -LiteralPath $packageDir -Recurse -File -Filter '*.cs' -Force | ForEach-Object { + ConvertTo-PackageRelativePath -BasePath $packageDir -Path $_.FullName + } + foreach ($entry in $packedCsFiles) { + $isAllowed = $false + foreach ($root in $allowedCsRoots) { + if ($entry.StartsWith($root, [System.StringComparison]::Ordinal)) { + $isAllowed = $true + break + } + } + if (-not $isAllowed) { + $errors += "C# source outside Unity package roots included in npm package: $entry" + } + } # Folders that should be in the npm package - $unityFolders = @('Runtime', 'Editor') + $unityFolders = @('Runtime', 'Editor', 'Samples~', 'Shaders', 'Styles', 'URP') foreach ($folder in $unityFolders) { $folderPath = Join-Path $packageDir $folder @@ -69,19 +296,21 @@ try { Write-Info "Validating folder: $folder" - # Check if folder has .meta file - $folderMetaPath = "$folderPath.meta" - if (-not (Test-Path $folderMetaPath)) { - $errors += "Missing .meta file for folder: $folder" + # Check if folder has .meta file. Samples~ is the Unity package-manager + # convention for samples and intentionally has no root folder .meta. + if ($folder -ne 'Samples~') { + $folderMetaPath = "$folderPath.meta" + if (-not (Test-Path $folderMetaPath)) { + $errors += "Missing .meta file for folder: $folder" + } } # Get all files and subdirectories in this folder (recursively) - $items = Get-ChildItem -Path $folderPath -Recurse + $items = Get-ChildItem -LiteralPath $folderPath -Recurse -Force foreach ($item in $items) { # Get relative path for better error messages - $relativePath = $item.FullName.Replace("$packageDir\", "").Replace("$packageDir/", "") - $relativePath = $relativePath -replace '\\', '/' + $relativePath = ConvertTo-PackageRelativePath -BasePath $packageDir -Path $item.FullName # Skip .meta files themselves if ($item.Name -like "*.meta") { @@ -102,67 +331,21 @@ try { } } - # Step 6: Validate that Runtime and Editor content matches git repo + # Step 6: Validate that packed release payload matches git repo Write-Info "Validating that npm package content matches git repository..." - foreach ($folder in $unityFolders) { - $gitFolderPath = Join-Path (Get-Location) $folder - $npmFolderPath = Join-Path $packageDir $folder - - if (-not (Test-Path $gitFolderPath)) { - Write-Info "Git folder does not exist: $folder (skipping comparison)" - continue - } - - if (-not (Test-Path $npmFolderPath)) { - $errors += "Folder missing in npm package: $folder" - continue - } - - # Get all files in git repo for this folder - $gitFiles = Get-ChildItem -Path $gitFolderPath -Recurse -File | ForEach-Object { - $_.FullName.Replace("$gitFolderPath\", "").Replace("$gitFolderPath/", "") -replace '\\', '/' - } - - # Get all files in npm package for this folder - $npmFiles = Get-ChildItem -Path $npmFolderPath -Recurse -File | ForEach-Object { - $_.FullName.Replace("$npmFolderPath\", "").Replace("$npmFolderPath/", "") -replace '\\', '/' - } - - # Check for files in git that are missing in npm - foreach ($gitFile in $gitFiles) { - if ($gitFile -notin $npmFiles) { - # Check if this is an expected exclusion - $isExcluded = $false - - # Excluded patterns from the build process - $excludePatterns = @( - '*.dll', # Built DLLs in Editor/Analyzers - '*.pdb', # Debug symbols - '*.tmp', # Temporary files - '*.log', # Log files - '*.rsp' # Response files - ) - - foreach ($pattern in $excludePatterns) { - if ($gitFile -like $pattern) { - $isExcluded = $true - break - } - } - - if (-not $isExcluded) { - $errors += "File in git repo but missing in npm package: $folder/$gitFile" - } - } + $gitPackageFiles = Get-TrackedPackageFiles -RepoRoot $repoRoot -PackageRoots $packageContentRoots + $npmPackageFiles = Get-PackedPackageFiles -PackageDir $packageDir + + foreach ($gitFile in $gitPackageFiles) { + if (($gitFile -cnotin $npmPackageFiles) -and (-not (Test-ExpectedPackageExclusion -RelativePath $gitFile))) { + $errors += "File in git repo but missing in npm package: $gitFile" } - - # Check for files in npm that shouldn't be there (extra files not in git) - foreach ($npmFile in $npmFiles) { - if ($npmFile -notin $gitFiles) { - # This might be OK (e.g., generated files), but worth noting - Write-Info "File in npm package but not in git repo: $folder/$npmFile" - } + } + + foreach ($npmFile in $npmPackageFiles) { + if ($npmFile -cnotin $gitPackageFiles) { + $errors += "File in npm package but not tracked in git repo: $npmFile" } }