diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0439729c..877c12bd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -110,6 +110,7 @@ body: - "2.0.3" - "2.0.1" - "2.0.0" + - "1.0.1" - "Other" # validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 5ce75687..3a1fb30f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -94,6 +94,7 @@ body: - "2.0.3" - "2.0.1" - "2.0.0" + - "1.0.1" - "Other" # validations: diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index fbeb0695..54022841 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -7,6 +7,10 @@ on: - main workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: actionlint: name: Lint GitHub Actions diff --git a/.github/workflows/asmdef-lint.yml b/.github/workflows/asmdef-lint.yml index a547fd15..62faedec 100644 --- a/.github/workflows/asmdef-lint.yml +++ b/.github/workflows/asmdef-lint.yml @@ -24,6 +24,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-asmdef: runs-on: ubuntu-latest diff --git a/.github/workflows/changelog-lint.yml b/.github/workflows/changelog-lint.yml index 0ab245fb..35df5592 100644 --- a/.github/workflows/changelog-lint.yml +++ b/.github/workflows/changelog-lint.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-changelog: runs-on: ubuntu-latest diff --git a/.github/workflows/csharp-naming-lint.yml b/.github/workflows/csharp-naming-lint.yml index 06b4374f..96c0a151 100644 --- a/.github/workflows/csharp-naming-lint.yml +++ b/.github/workflows/csharp-naming-lint.yml @@ -23,6 +23,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-csharp-naming: runs-on: ubuntu-latest diff --git a/.github/workflows/csharp-no-regions-lint.yml b/.github/workflows/csharp-no-regions-lint.yml index fcbb0cea..c4cb31f1 100644 --- a/.github/workflows/csharp-no-regions-lint.yml +++ b/.github/workflows/csharp-no-regions-lint.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-no-regions: runs-on: ubuntu-latest diff --git a/.github/workflows/csharpier-autofix.yml b/.github/workflows/csharpier-autofix.yml index 2d76761a..e3923675 100644 --- a/.github/workflows/csharpier-autofix.yml +++ b/.github/workflows/csharpier-autofix.yml @@ -9,6 +9,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: format: name: Format and propose changes diff --git a/.github/workflows/dependabot-lint.yml b/.github/workflows/dependabot-lint.yml index bcc19715..bc0b7af9 100644 --- a/.github/workflows/dependabot-lint.yml +++ b/.github/workflows/dependabot-lint.yml @@ -20,6 +20,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: dependabot-lint: name: Dependabot Config Lint diff --git a/.github/workflows/doc-code-samples.yml b/.github/workflows/doc-code-samples.yml index 3cb7e437..a379d903 100644 --- a/.github/workflows/doc-code-samples.yml +++ b/.github/workflows/doc-code-samples.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-code-samples: name: Validate Code Samples diff --git a/.github/workflows/drawer-multiobject-lint.yml b/.github/workflows/drawer-multiobject-lint.yml index 4117edfe..b77500c5 100644 --- a/.github/workflows/drawer-multiobject-lint.yml +++ b/.github/workflows/drawer-multiobject-lint.yml @@ -23,6 +23,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-drawer-multiobject: runs-on: ubuntu-latest diff --git a/.github/workflows/license-header-lint.yml b/.github/workflows/license-header-lint.yml index 245c92cc..391be970 100644 --- a/.github/workflows/license-header-lint.yml +++ b/.github/workflows/license-header-lint.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-license-headers: runs-on: ubuntu-latest diff --git a/.github/workflows/license-year-audit.yml b/.github/workflows/license-year-audit.yml index 918b74c0..ce94d582 100644 --- a/.github/workflows/license-year-audit.yml +++ b/.github/workflows/license-year-audit.yml @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: audit-license-years: runs-on: ubuntu-latest diff --git a/.github/workflows/lint-doc-links.yml b/.github/workflows/lint-doc-links.yml index 572c9328..65e0e48c 100644 --- a/.github/workflows/lint-doc-links.yml +++ b/.github/workflows/lint-doc-links.yml @@ -25,6 +25,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: # ── INTERNAL links + linter self-tests: gates every PR ────────────────────── internal-links: diff --git a/.github/workflows/llm-instructions-lint.yml b/.github/workflows/llm-instructions-lint.yml index 70bbe901..fc59b938 100644 --- a/.github/workflows/llm-instructions-lint.yml +++ b/.github/workflows/llm-instructions-lint.yml @@ -25,6 +25,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-llm-instructions: # Run on BOTH Ubuntu and Windows so the skills-index generator's cross-OS diff --git a/.github/workflows/markdown-json.yml b/.github/workflows/markdown-json.yml index dbab0895..9d8fe969 100644 --- a/.github/workflows/markdown-json.yml +++ b/.github/workflows/markdown-json.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate: runs-on: ubuntu-latest diff --git a/.github/workflows/meta-file-lint.yml b/.github/workflows/meta-file-lint.yml index 632fb620..6d7f0496 100644 --- a/.github/workflows/meta-file-lint.yml +++ b/.github/workflows/meta-file-lint.yml @@ -37,6 +37,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-meta-files: runs-on: ubuntu-latest diff --git a/.github/workflows/odin-undo-safety-lint.yml b/.github/workflows/odin-undo-safety-lint.yml index e1e2771f..26c1fae0 100644 --- a/.github/workflows/odin-undo-safety-lint.yml +++ b/.github/workflows/odin-undo-safety-lint.yml @@ -23,6 +23,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-odin-undo-safety: runs-on: ubuntu-latest diff --git a/.github/workflows/prettier-autofix.yml b/.github/workflows/prettier-autofix.yml index 18ce07a4..43c1921a 100644 --- a/.github/workflows/prettier-autofix.yml +++ b/.github/workflows/prettier-autofix.yml @@ -9,6 +9,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: autofix: name: Format and propose changes diff --git a/.github/workflows/pwsh-invocations-lint.yml b/.github/workflows/pwsh-invocations-lint.yml index 68101671..68dbb3d4 100644 --- a/.github/workflows/pwsh-invocations-lint.yml +++ b/.github/workflows/pwsh-invocations-lint.yml @@ -26,6 +26,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: pwsh-invocations-lint: name: PowerShell Invocations Lint diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 77c5fb4b..edeceece 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,8 +1,8 @@ name: Release Drafter on: - # Manual-only: release-tag.yml and release.yml own release merges. - # Running this draft updater on push can race tag-triggered publishing. + # Manual-only: release.yml owns release publishing and tag creation. + # Running this draft updater on push can race manual publishing. workflow_dispatch: permissions: diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 13ffc9d8..bc604776 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -226,7 +226,9 @@ jobs: 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." + echo "Run the Release Publish workflow on \`${DEFAULT_BRANCH}\` with version \`${VERSION}\`." + echo "Leave \`source_ref\` as \`${DEFAULT_BRANCH}\` for the normal path." + echo "Release Publish validates, packs npm, exports the \`.unitypackage\`, creates the release tag if needed, publishes npm, and publishes the GitHub Release assets." } > "${pr_body_file}" GH_TOKEN="${GH_PUSH_TOKEN}" gh pr create \ diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml deleted file mode 100644 index ad846882..00000000 --- a/.github/workflows/release-tag.yml +++ /dev/null @@ -1,143 +0,0 @@ -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@v7 - 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 "::error::${message}" - exit 1 - 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 index 8ef533df..1de27e2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,27 @@ 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]*" + workflow_dispatch: + inputs: + version: + description: "Release version to publish, as strict X.Y.Z semver." + type: string + required: true + source_ref: + description: "Branch, tag, or commit to build release assets from." + type: string + required: true + default: main + allow_tag_recovery: + description: "Retarget an existing tag only if npm and GitHub Release are both still unpublished." + type: boolean + default: false permissions: contents: read concurrency: - group: release-${{ github.ref_name }} + group: release-${{ inputs.version }} cancel-in-progress: false jobs: @@ -20,26 +30,90 @@ jobs: if: github.repository == 'wallstop/unity-helpers' || github.repository == 'Ambiguous-Interactive/unity-helpers' runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read outputs: package-name: ${{ steps.verify.outputs.package-name }} package-version: ${{ steps.verify.outputs.package-version }} + source-ref: ${{ steps.verify.outputs.source-ref }} + source-sha: ${{ steps.verify.outputs.source-sha }} tag: ${{ steps.verify.outputs.tag }} + tag-action: ${{ steps.verify.outputs.tag-action }} steps: + - name: Validate source ref input + env: + INPUT_SOURCE_REF: ${{ inputs.source_ref }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + tag="${INPUT_VERSION}" + source_ref="${INPUT_SOURCE_REF}" + if [ -z "${tag}" ]; then + echo "::error::Release version is required." + exit 1 + fi + if [[ "${tag}" == *$'\n'* || "${tag}" == *$'\r'* ]]; then + echo "::error::Release version must be a single line." + exit 1 + fi + if [ -z "${source_ref}" ]; then + echo "::error::Release source ref is required." + exit 1 + fi + if [[ "${source_ref}" == *$'\n'* || "${source_ref}" == *$'\r'* ]]; then + echo "::error::Release source ref must be a single line." + exit 1 + fi + - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 + fetch-tags: true + ref: ${{ inputs.source_ref }} persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + - name: Verify tag matches package metadata id: verify + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_ALLOW_TAG_RECOVERY: ${{ inputs.allow_tag_recovery }} + INPUT_SOURCE_REF: ${{ inputs.source_ref }} + INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail - tag="${GITHUB_REF_NAME}" + tag="${INPUT_VERSION}" + source_ref="${INPUT_SOURCE_REF}" + allow_tag_recovery="${INPUT_ALLOW_TAG_RECOVERY}" + + if [ -z "${tag}" ]; then + echo "::error::Release version is required." + exit 1 + fi + if [[ "${tag}" == *$'\n'* || "${tag}" == *$'\r'* ]]; then + echo "::error::Release version must be a single line." + exit 1 + fi 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 + if [ -z "${source_ref}" ]; then + echo "::error::Release source ref is required." + exit 1 + fi + if [[ "${source_ref}" == *$'\n'* || "${source_ref}" == *$'\r'* ]]; then + echo "::error::Release source ref must be a single line." + exit 1 + fi + + source_sha="$(git rev-parse HEAD)" package_name="$(jq -r '.name // empty' package.json)" package_version="$(jq -r '.version // empty' package.json)" if [ -z "${package_name}" ] || [ -z "${package_version}" ]; then @@ -60,15 +134,127 @@ jobs: echo "::error::CHANGELOG.md has no exact section with release-note content for ${package_version}." exit 1 fi + + tag_action="none" + tag_ref_file="${RUNNER_TEMP}/release-tag-ref.json" + tag_ref_error="${RUNNER_TEMP}/release-tag-ref.err" + set +e + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" > "${tag_ref_file}" 2> "${tag_ref_error}" + tag_lookup_exit=$? + set -e + if [ "${tag_lookup_exit}" -eq 0 ]; then + tag_object_sha="$(jq -r '.object.sha // empty' "${tag_ref_file}")" + tag_object_type="$(jq -r '.object.type // empty' "${tag_ref_file}")" + if [ "${tag_object_type}" = "tag" ]; then + tag_target="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}" --jq '.object.sha')" + elif [ "${tag_object_type}" = "commit" ]; then + tag_target="${tag_object_sha}" + else + echo "::error::Tag ${tag} points at unsupported object type '${tag_object_type}'." + exit 1 + fi + + if [ "${tag_target}" != "${source_sha}" ]; then + if [ "${allow_tag_recovery}" != "true" ]; then + message="Tag ${tag} already points at ${tag_target}, not selected source ${source_sha}. " + message+="Re-run with allow_tag_recovery=true only after confirming npm and GitHub Release are unpublished." + echo "::error::${message}" + exit 1 + fi + tag_action="retarget" + fi + else + tag_lookup_output="$(cat "${tag_ref_error}")" + if ! printf '%s\n' "${tag_lookup_output}" | grep -E '(HTTP 404|Not Found|"status":"404")' >/dev/null; then + echo "::error::Failed to check whether tag ${tag} exists." + printf '%s\n' "${tag_lookup_output}" + exit "${tag_lookup_exit}" + fi + echo "::notice::Tag ${tag} does not exist and will be created after package validation and .unitypackage export succeed." + tag_action="create" + fi + + if [ "${tag_action}" != "none" ]; then + npm_published="false" + npm_view_file="${RUNNER_TEMP}/release-npm-view.txt" + npm_view_error="${RUNNER_TEMP}/release-npm-view.err" + set +e + npm view "${package_name}@${package_version}" version --registry "https://registry.npmjs.org" > "${npm_view_file}" 2> "${npm_view_error}" + npm_view_exit=$? + set -e + if [ "${npm_view_exit}" -eq 0 ]; then + npm_published="true" + else + npm_view_output="$(cat "${npm_view_error}")" + if ! printf '%s\n' "${npm_view_output}" | grep -E '(E404|404 Not Found|is not in this registry)' >/dev/null; then + echo "::error::Failed to verify npm publication state for ${package_name}@${package_version}." + printf '%s\n' "${npm_view_output}" + exit "${npm_view_exit}" + fi + fi + + github_release_exists="false" + release_view_file="${RUNNER_TEMP}/release-view.json" + release_view_error="${RUNNER_TEMP}/release-view.err" + set +e + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "${release_view_file}" 2> "${release_view_error}" + release_lookup_exit=$? + set -e + if [ "${release_lookup_exit}" -eq 0 ]; then + github_release_exists="true" + else + release_lookup_output="$(cat "${release_view_error}")" + if ! printf '%s\n' "${release_lookup_output}" | grep -E '(HTTP 404|Not Found|"status":"404")' >/dev/null; then + echo "::error::Failed to verify GitHub Release state for ${tag}." + printf '%s\n' "${release_lookup_output}" + exit "${release_lookup_exit}" + fi + fi + + if [ "${npm_published}" = "true" ] || [ "${github_release_exists}" = "true" ]; then + echo "::error::Published artifacts already exist; refusing to create or retarget tag ${tag}. npm=${npm_published}, github_release=${github_release_exists}." + exit 1 + fi + fi + + { + printf 'source-ref=%s\n' "${source_ref}" + echo "source-sha=${source_sha}" + echo "tag-action=${tag_action}" + } >> "${GITHUB_OUTPUT}" { echo "package-name=${package_name}" echo "package-version=${package_version}" echo "tag=${tag}" } >> "${GITHUB_OUTPUT}" + release-ready: + name: Confirm release publish inputs + needs: verify-tag + runs-on: ubuntu-latest + timeout-minutes: 1 + outputs: + package-name: ${{ needs.verify-tag.outputs.package-name }} + package-version: ${{ needs.verify-tag.outputs.package-version }} + source-ref: ${{ needs.verify-tag.outputs.source-ref }} + source-sha: ${{ needs.verify-tag.outputs.source-sha }} + tag: ${{ needs.verify-tag.outputs.tag }} + tag-action: ${{ needs.verify-tag.outputs.tag-action }} + steps: + - name: Confirm release inputs + env: + PACKAGE_NAME: ${{ needs.verify-tag.outputs.package-name }} + PACKAGE_VERSION: ${{ needs.verify-tag.outputs.package-version }} + SOURCE_REF: ${{ needs.verify-tag.outputs.source-ref }} + SOURCE_SHA: ${{ needs.verify-tag.outputs.source-sha }} + TAG: ${{ needs.verify-tag.outputs.tag }} + run: | + set -euo pipefail + echo "Release ${TAG} will publish ${PACKAGE_NAME}@${PACKAGE_VERSION} from ${SOURCE_REF} (${SOURCE_SHA})." + validate-package: name: Validate and pack npm package - needs: verify-tag + needs: release-ready runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -76,6 +262,7 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 + ref: ${{ needs.release-ready.outputs.source-sha }} persist-credentials: false - name: Setup Node.js @@ -117,7 +304,7 @@ jobs: 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 }}" \ + -Version "${{ needs.release-ready.outputs.package-version }}" \ -Footer \ -OutputPath .artifacts/release/release-notes.md echo "package-file=${package_file}" >> "${GITHUB_OUTPUT}" @@ -133,7 +320,7 @@ jobs: unitypackage: name: Export .unitypackage needs: - - verify-tag + - release-ready - validate-package runs-on: ubuntu-latest timeout-minutes: 360 @@ -142,6 +329,7 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 + ref: ${{ needs.release-ready.outputs.source-sha }} persist-credentials: false - name: Setup Node.js @@ -160,7 +348,7 @@ jobs: 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 }} + holder-id-suffix: release-${{ needs.release-ready.outputs.package-version }} timeout-minutes: "210" env: BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} @@ -177,7 +365,7 @@ jobs: 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" + output=".artifacts/unitypackage/${{ needs.release-ready.outputs.package-name }}-${{ needs.release-ready.outputs.package-version }}.unitypackage" bash scripts/unity/export-unitypackage.sh --output "${output}" - name: Return Unity license @@ -192,7 +380,7 @@ jobs: 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 }} + holder-id-suffix: release-${{ needs.release-ready.outputs.package-version }} env: BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} @@ -225,12 +413,145 @@ jobs: if-no-files-found: error overwrite: true + prepare-tag: + name: Prepare release tag + needs: + - release-ready + - validate-package + - unitypackage + if: ${{ needs.release-ready.outputs.tag-action != 'none' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Recheck release artifacts before tag mutation + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PACKAGE_NAME: ${{ needs.release-ready.outputs.package-name }} + PACKAGE_VERSION: ${{ needs.release-ready.outputs.package-version }} + TAG: ${{ needs.release-ready.outputs.tag }} + run: | + set -euo pipefail + npm_published="false" + npm_view_file="${RUNNER_TEMP}/release-npm-view.txt" + npm_view_error="${RUNNER_TEMP}/release-npm-view.err" + set +e + npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry "https://registry.npmjs.org" > "${npm_view_file}" 2> "${npm_view_error}" + npm_view_exit=$? + set -e + if [ "${npm_view_exit}" -eq 0 ]; then + npm_published="true" + else + npm_view_output="$(cat "${npm_view_error}")" + if ! printf '%s\n' "${npm_view_output}" | grep -E '(E404|404 Not Found|is not in this registry)' >/dev/null; then + echo "::error::Failed to verify npm publication state for ${PACKAGE_NAME}@${PACKAGE_VERSION}." + printf '%s\n' "${npm_view_output}" + exit "${npm_view_exit}" + fi + fi + + github_release_exists="false" + release_view_file="${RUNNER_TEMP}/release-view.json" + release_view_error="${RUNNER_TEMP}/release-view.err" + set +e + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > "${release_view_file}" 2> "${release_view_error}" + release_lookup_exit=$? + set -e + if [ "${release_lookup_exit}" -eq 0 ]; then + github_release_exists="true" + else + release_lookup_output="$(cat "${release_view_error}")" + if ! printf '%s\n' "${release_lookup_output}" | grep -E '(HTTP 404|Not Found|"status":"404")' >/dev/null; then + echo "::error::Failed to verify GitHub Release state for ${TAG}." + printf '%s\n' "${release_lookup_output}" + exit "${release_lookup_exit}" + fi + fi + + if [ "${npm_published}" = "true" ] || [ "${github_release_exists}" = "true" ]; then + echo "::error::Published artifacts already exist; refusing to create or retarget tag ${TAG}. npm=${npm_published}, github_release=${github_release_exists}." + exit 1 + fi + + - name: Create or retarget release tag + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_SHA: ${{ needs.release-ready.outputs.source-sha }} + TAG: ${{ needs.release-ready.outputs.tag }} + TAG_ACTION: ${{ needs.release-ready.outputs.tag-action }} + run: | + set -euo pipefail + + gh_api_or_error() { + local description="$1" + shift + local stdout_file="${RUNNER_TEMP}/tag-gh-api-stdout.txt" + local stderr_file="${RUNNER_TEMP}/tag-gh-api-stderr.txt" + set +e + gh api "$@" > "${stdout_file}" 2> "${stderr_file}" + local exit_code=$? + set -e + if [ "${exit_code}" -ne 0 ]; then + { + echo "::error::${description} failed. The release tag may have changed after verification, or the GitHub token may lack contents:write." + if [ -s "${stderr_file}" ]; then + cat "${stderr_file}" + fi + if [ -s "${stdout_file}" ]; then + cat "${stdout_file}" + fi + } >&2 + exit "${exit_code}" + fi + cat "${stdout_file}" + } + + created_tag_sha="$( + gh_api_or_error "Creating annotated tag object ${TAG}" --method POST "repos/${GITHUB_REPOSITORY}/git/tags" \ + --field tag="${TAG}" \ + --field message="Release ${TAG}" \ + --field object="${SOURCE_SHA}" \ + --field type="commit" \ + --jq '.sha' + )" + if [ "${TAG_ACTION}" = "create" ]; then + gh_api_or_error "Creating tag ref refs/tags/${TAG}" --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + --field ref="refs/tags/${TAG}" \ + --field sha="${created_tag_sha}" >/dev/null + echo "::notice::Created release tag ${TAG} at ${SOURCE_SHA}." + elif [ "${TAG_ACTION}" = "retarget" ]; then + gh_api_or_error "Retargeting tag ref refs/tags/${TAG}" --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" \ + --field sha="${created_tag_sha}" \ + --field force=true >/dev/null + echo "::notice::Retargeted release tag ${TAG} to ${SOURCE_SHA}." + else + echo "::error::Unsupported tag action '${TAG_ACTION}'." + exit 1 + fi + publish: name: Publish npm and GitHub Release needs: - - verify-tag + - release-ready - validate-package - unitypackage + - prepare-tag + if: >- + ${{ + always() && + needs.release-ready.result == 'success' && + needs.validate-package.result == 'success' && + needs.unitypackage.result == 'success' && + (needs.release-ready.outputs.tag-action == 'none' || needs.prepare-tag.result == 'success') + }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -258,8 +579,8 @@ jobs: - name: Verify downloaded artifacts id: artifacts env: - EXPECTED_PACKAGE_NAME: ${{ needs.verify-tag.outputs.package-name }} - EXPECTED_PACKAGE_VERSION: ${{ needs.verify-tag.outputs.package-version }} + EXPECTED_PACKAGE_NAME: ${{ needs.release-ready.outputs.package-name }} + EXPECTED_PACKAGE_VERSION: ${{ needs.release-ready.outputs.package-version }} run: | set -euo pipefail mapfile -t packages < <(find .artifacts/release -maxdepth 1 -type f -name '*.tgz' | sort) @@ -294,19 +615,69 @@ jobs: echo "package-file=${package_file}" >> "${GITHUB_OUTPUT}" echo "unitypackage-file=${unitypackage_file}" >> "${GITHUB_OUTPUT}" + - name: Verify release tag target before publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + SOURCE_SHA: ${{ needs.release-ready.outputs.source-sha }} + TAG: ${{ needs.release-ready.outputs.tag }} + run: | + set -euo pipefail + tag_ref_file="${RUNNER_TEMP}/publish-tag-ref.json" + tag_ref_error="${RUNNER_TEMP}/publish-tag-ref.err" + set +e + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" > "${tag_ref_file}" 2> "${tag_ref_error}" + tag_lookup_exit=$? + set -e + if [ "${tag_lookup_exit}" -ne 0 ]; then + tag_lookup_output="$(cat "${tag_ref_error}")" + echo "::error::Failed to verify release tag ${TAG} before publish." + printf '%s\n' "${tag_lookup_output}" + exit "${tag_lookup_exit}" + fi + + tag_object_sha="$(jq -r '.object.sha // empty' "${tag_ref_file}")" + tag_object_type="$(jq -r '.object.type // empty' "${tag_ref_file}")" + if [ "${tag_object_type}" = "tag" ]; then + tag_target="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}" --jq '.object.sha')" + elif [ "${tag_object_type}" = "commit" ]; then + tag_target="${tag_object_sha}" + else + echo "::error::Tag ${TAG} points at unsupported object type '${tag_object_type}'." + exit 1 + fi + + if [ "${tag_target}" != "${SOURCE_SHA}" ]; then + echo "::error::Tag ${TAG} points at ${tag_target}, not selected source ${SOURCE_SHA}." + exit 1 + fi + - 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_NAME: ${{ needs.release-ready.outputs.package-name }} + PACKAGE_VERSION: ${{ needs.release-ready.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 + npm_view_file="${RUNNER_TEMP}/publish-npm-view.txt" + npm_view_error="${RUNNER_TEMP}/publish-npm-view.err" + set +e + npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry "https://registry.npmjs.org" > "${npm_view_file}" 2> "${npm_view_error}" + npm_view_exit=$? + set -e + if [ "${npm_view_exit}" -eq 0 ]; then echo "::notice::${PACKAGE_NAME}@${PACKAGE_VERSION} already exists on npm; skipping publish." exit 0 fi + npm_view_output="$(cat "${npm_view_error}")" + if ! printf '%s\n' "${npm_view_output}" | grep -E '(E404|404 Not Found|is not in this registry)' >/dev/null; then + echo "::error::Failed to verify npm publication state for ${PACKAGE_NAME}@${PACKAGE_VERSION}." + printf '%s\n' "${npm_view_output}" + exit "${npm_view_exit}" + 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 @@ -317,7 +688,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - TAG: ${{ needs.verify-tag.outputs.tag }} + TAG: ${{ needs.release-ready.outputs.tag }} PACKAGE_FILE: ${{ steps.artifacts.outputs.package-file }} UNITYPACKAGE_FILE: ${{ steps.artifacts.outputs.unitypackage-file }} run: | @@ -330,18 +701,32 @@ jobs: "${UNITYPACKAGE_FILE}.sha256" ) - if gh release view "${TAG}" >/dev/null 2>&1; then + release_view_file="${RUNNER_TEMP}/publish-release-view.json" + release_view_error="${RUNNER_TEMP}/publish-release-view.err" + set +e + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > "${release_view_file}" 2> "${release_view_error}" + release_lookup_exit=$? + set -e + if [ "${release_lookup_exit}" -eq 0 ]; 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 + exit 0 fi + release_lookup_output="$(cat "${release_view_error}")" + if ! printf '%s\n' "${release_lookup_output}" | grep -E '(HTTP 404|Not Found|"status":"404")' >/dev/null; then + echo "::error::Failed to verify GitHub Release state for ${TAG}." + printf '%s\n' "${release_lookup_output}" + exit "${release_lookup_exit}" + fi + + gh release create "${TAG}" "${assets[@]}" --title "${TAG}" --notes-file "${notes_file}" --verify-tag + - name: Verify GitHub Release assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - TAG: ${{ needs.verify-tag.outputs.tag }} + TAG: ${{ needs.release-ready.outputs.tag }} PACKAGE_FILE: ${{ steps.artifacts.outputs.package-file }} UNITYPACKAGE_FILE: ${{ steps.artifacts.outputs.unitypackage-file }} run: | diff --git a/.github/workflows/spelling-check.yml b/.github/workflows/spelling-check.yml index 7a6c6c75..1ebd9d2d 100644 --- a/.github/workflows/spelling-check.yml +++ b/.github/workflows/spelling-check.yml @@ -41,6 +41,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: spelling: name: Check Spelling diff --git a/.github/workflows/sync-banner-version.yml b/.github/workflows/sync-banner-version.yml index bd7554c8..1b571e23 100644 --- a/.github/workflows/sync-banner-version.yml +++ b/.github/workflows/sync-banner-version.yml @@ -21,6 +21,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-banner-version: runs-on: ubuntu-latest diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml index 322ab7eb..271e3842 100644 --- a/.github/workflows/test-lint.yml +++ b/.github/workflows/test-lint.yml @@ -37,6 +37,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-tests: runs-on: ubuntu-latest diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index dcfd79c5..e38e9d70 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -312,9 +312,11 @@ jobs: # PR suite at the REDUCED default sample count (Tests/Runtime/Random/ # RandomTestBase.cs). This dedicated leg re-runs the Random assembly at the # FULL statistical sample count so the thorough bias-detection coverage the - # fast default trades away is recovered weekly in CI. editmode-only (the - # Random tests are pure C# EditMode); runs inside the org-lock section. - # This is the ONLY place UH_RANDOM_SAMPLE_COUNT is consumed. + # fast default trades away is recovered weekly in CI. The same lane restores + # the historical broad NextNoiseMap sweep; PR CI keeps representative fixed + # dimensions only. editmode-only (the Random tests are pure C# EditMode); + # runs inside the org-lock section. This is the ONLY CI lane that sets + # UH_RANDOM_SAMPLE_COUNT / UH_RANDOM_NOISE_MAP_ITERATIONS. - name: Run Random suite at full sample count id: run_random_thorough if: ${{ matrix.test-mode == 'editmode' && steps.compute.outputs.is-empty != 'true' }} @@ -328,6 +330,7 @@ jobs: # No perf-category filter: run the Fast-tagged Random tests by assembly. UH_UNITY_TEST_CATEGORY: "" UH_RANDOM_SAMPLE_COUNT: "12750000" + UH_RANDOM_NOISE_MAP_ITERATIONS: "1000" run: | ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` diff --git a/.github/workflows/unity-file-naming-lint.yml b/.github/workflows/unity-file-naming-lint.yml index bae176a1..e4959e72 100644 --- a/.github/workflows/unity-file-naming-lint.yml +++ b/.github/workflows/unity-file-naming-lint.yml @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-unity-file-naming: runs-on: ubuntu-latest diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 1bfdfbd7..ea122dcb 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -43,6 +43,10 @@ permissions: contents: read checks: write +concurrency: + group: unity-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: matrix-config: name: Resolve Unity test matrix @@ -465,15 +469,15 @@ jobs: -Il2CppCompilerConfiguration '${{ matrix.test-mode == 'standalone' && 'Debug' || 'Release' }}' # Surface the slowest fixtures/cases every run (the measurement backbone for - # keeping the suite fast). Report-only for now; pass -FixtureBudgetSeconds N - # -FailOverBudget here to turn it into a hard wall-clock gate once baselined. + # keeping the suite fast). Warn at 120s/fixture; add -FailOverBudget once the + # budget is tight enough to enforce as a hard wall-clock gate. - name: Report slowest tests if: ${{ always() && steps.compute.outputs.is-empty != 'true' }} shell: pwsh run: | $results = '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}/results.xml' if (Test-Path -LiteralPath $results) { - ./scripts/unity/report-slow-tests.ps1 -ResultsPath $results -Top 30 -StepSummary + ./scripts/unity/report-slow-tests.ps1 -ResultsPath $results -Top 30 -FixtureBudgetSeconds 120 -StepSummary } else { Write-Host "::notice::No results.xml at $results to report slow tests." } @@ -743,15 +747,15 @@ jobs: -Il2CppCompilerConfiguration '${{ matrix.test-mode == 'standalone' && 'Debug' || 'Release' }}' # Surface the slowest fixtures/cases every run (the measurement backbone for - # keeping the suite fast). Report-only for now; pass -FixtureBudgetSeconds N - # -FailOverBudget here to turn it into a hard wall-clock gate once baselined. + # keeping the suite fast). Warn at 120s/fixture; add -FailOverBudget once the + # budget is tight enough to enforce as a hard wall-clock gate. - name: Report slowest tests if: ${{ always() && steps.compute.outputs.is-empty != 'true' }} shell: pwsh run: | $results = '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}/results.xml' if (Test-Path -LiteralPath $results) { - ./scripts/unity/report-slow-tests.ps1 -ResultsPath $results -Top 30 -StepSummary + ./scripts/unity/report-slow-tests.ps1 -ResultsPath $results -Top 30 -FixtureBudgetSeconds 120 -StepSummary } else { Write-Host "::notice::No results.xml at $results to report slow tests." } @@ -1007,14 +1011,14 @@ jobs: -ReleasePlayerBuild # Surface the slowest fixtures/cases for the SINGLE_THREADED leg (the 40-min - # leg the optimization targets). Report-only; see the main job's note. + # leg the optimization targets). Warn-only; see the main job's note. - name: Report slowest tests if: ${{ always() && steps.compute.outputs.is-empty != 'true' }} shell: pwsh run: | $results = '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded/results.xml' if (Test-Path -LiteralPath $results) { - ./scripts/unity/report-slow-tests.ps1 -ResultsPath $results -Top 30 -StepSummary + ./scripts/unity/report-slow-tests.ps1 -ResultsPath $results -Top 30 -FixtureBudgetSeconds 120 -StepSummary } else { Write-Host "::notice::No results.xml at $results to report slow tests." } diff --git a/.github/workflows/validate-devcontainer.yml b/.github/workflows/validate-devcontainer.yml index b75656ca..6a58203a 100644 --- a/.github/workflows/validate-devcontainer.yml +++ b/.github/workflows/validate-devcontainer.yml @@ -25,6 +25,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-devcontainer: runs-on: ubuntu-latest diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml index fecde2a8..bda60cb7 100644 --- a/.github/workflows/validate-docs.yml +++ b/.github/workflows/validate-docs.yml @@ -32,6 +32,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-links: name: Validate Internal Links diff --git a/.github/workflows/validate-formatting.yml b/.github/workflows/validate-formatting.yml index 106720ec..583bf714 100644 --- a/.github/workflows/validate-formatting.yml +++ b/.github/workflows/validate-formatting.yml @@ -29,6 +29,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-formatting: runs-on: ubuntu-latest diff --git a/.github/workflows/validate-hooks.yml b/.github/workflows/validate-hooks.yml index 8375db82..33e63ecd 100644 --- a/.github/workflows/validate-hooks.yml +++ b/.github/workflows/validate-hooks.yml @@ -35,6 +35,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-hooks: runs-on: ubuntu-latest diff --git a/.github/workflows/validate-mcp-config.yml b/.github/workflows/validate-mcp-config.yml index 1c4980b8..dca53dd9 100644 --- a/.github/workflows/validate-mcp-config.yml +++ b/.github/workflows/validate-mcp-config.yml @@ -31,6 +31,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-mcp-config: runs-on: ubuntu-latest diff --git a/.github/workflows/validate-npm-package.yml b/.github/workflows/validate-npm-package.yml index 8b871c18..084d7136 100644 --- a/.github/workflows/validate-npm-package.yml +++ b/.github/workflows/validate-npm-package.yml @@ -31,6 +31,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate-npm-package: runs-on: ubuntu-latest diff --git a/.github/workflows/validate-wiki-links.yml b/.github/workflows/validate-wiki-links.yml index 8f7a0ff3..4397b02e 100644 --- a/.github/workflows/validate-wiki-links.yml +++ b/.github/workflows/validate-wiki-links.yml @@ -25,6 +25,10 @@ name: Validate Wiki Links permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: validate: name: Validate wiki structure diff --git a/.github/workflows/yaml-format-lint.yml b/.github/workflows/yaml-format-lint.yml index 06d007b2..e577de1e 100644 --- a/.github/workflows/yaml-format-lint.yml +++ b/.github/workflows/yaml-format-lint.yml @@ -7,6 +7,10 @@ on: - main workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: yaml-checks: name: Prettier and yamllint diff --git a/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs b/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs index 197f543b..98d57894 100644 --- a/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs +++ b/Tests/Core/Editor/AssetDatabaseBatchScopeTests.cs @@ -387,7 +387,7 @@ public void RefreshOnDisposeTrueIsDefault() [TestCase(1, TestName = "SequentialScopes.Count1")] [TestCase(5, TestName = "SequentialScopes.Count5")] [TestCase(10, TestName = "SequentialScopes.Count10")] - [TestCase(50, TestName = "SequentialScopes.Count50")] + [TestCase(50, TestName = "SequentialScopes.Count50", Category = "Stress")] public void MultipleSequentialScopesWorkCorrectly(int scopeCount) { for (int i = 0; i < scopeCount; i++) @@ -558,9 +558,8 @@ private static IEnumerable DeeplyNestedScopeCases() } [Test] - [TestCase(100, TestName = "RapidCycles.Count100")] - [TestCase(500, TestName = "RapidCycles.Count500")] - [TestCase(1000, TestName = "RapidCycles.Count1000")] + [TestCase(10, TestName = "RapidUnityCycles.Count10")] + [TestCase(50, TestName = "RapidUnityCycles.Count50", Category = "Stress")] public void RapidOpenCloseCyclesWorkCorrectly(int cycleCount) { for (int cycle = 0; cycle < cycleCount; cycle++) @@ -588,8 +587,8 @@ public void RapidOpenCloseCyclesWorkCorrectly(int cycleCount) } [Test] - [TestCase(50, TestName = "RapidNestedCycles.Count50")] - [TestCase(100, TestName = "RapidNestedCycles.Count100")] + [TestCase(10, TestName = "RapidNestedUnityCycles.Count10")] + [TestCase(50, TestName = "RapidNestedUnityCycles.Count50", Category = "Stress")] public void RapidNestedOpenCloseCyclesWorkCorrectly(int cycleCount) { for (int cycle = 0; cycle < cycleCount; cycle++) @@ -1703,28 +1702,38 @@ public void BeginBatchDefaultParameterIsTrue() } [Test] - [TestCase(100, TestName = "ExtremeRapidCycles.Count100")] - [TestCase(1000, TestName = "ExtremeRapidCycles.Count1000")] - public void ExtremelyRapidOpenCloseCyclesDoNotBreakState(int cycleCount) + [TestCase(100, TestName = "ExtremeRapidCounterCycles.Count100")] + [TestCase(1000, TestName = "ExtremeRapidCounterCycles.Count1000")] + public void ExtremelyRapidCounterCyclesDoNotBreakState(int cycleCount) { - RunRapidOpenCloseCycles(cycleCount); + RunRapidCounterCycles(cycleCount); } [Test] [NUnit.Framework.Category("Stress")] [Timeout(60000)] - [TestCase(5000, TestName = "ExtremeRapidCycles.Count5000")] - public void ExtremelyRapidOpenCloseCyclesDoNotBreakStateStress(int cycleCount) + [TestCase(5000, TestName = "ExtremeRapidCounterCycles.Count5000")] + public void ExtremelyRapidCounterCyclesDoNotBreakStateStress(int cycleCount) { - RunRapidOpenCloseCycles(cycleCount); + RunRapidCounterCycles(cycleCount); } - private static void RunRapidOpenCloseCycles(int cycleCount) + private static void RunRapidCounterCycles(int cycleCount) { for (int i = 0; i < cycleCount; i++) { - AssetDatabaseBatchScope scope = AssetDatabaseBatchHelper.BeginBatch(); - scope.Dispose(); + bool isOutermost = AssetDatabaseBatchHelper.IncrementBatchDepthWithUnityCall(); + if (!isOutermost) + { + Assert.Fail($"Cycle {i} should start from an outermost batch counter state."); + } + + bool shouldCleanUpUnity = + AssetDatabaseBatchHelper.DecrementBatchDepthWithUnityCleanup(); + if (!shouldCleanUpUnity) + { + Assert.Fail($"Cycle {i} should return to an outermost batch cleanup state."); + } } Assert.That( @@ -1737,6 +1746,11 @@ private static void RunRapidOpenCloseCycles(int cycleCount) Is.False, "Should not be batching after extreme rapid cycles" ); + Assert.That( + AssetDatabaseBatchHelper.ActualUnityBatchDepth, + Is.EqualTo(0), + "Actual Unity batch depth should be 0 after extreme rapid cycles" + ); } [Test] diff --git a/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs b/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs index 7435081d..38bb46c6 100644 --- a/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs +++ b/Tests/Editor/AssetProcessors/DetectAssetChangePrefabAndSceneTests.cs @@ -312,6 +312,7 @@ public void PrefabHandlerFindsNestedComponents() } [Test] + [NUnit.Framework.Category("Stress")] public void PrefabHandlerFindsMultipleComponentsOnSamePrefab() { // Arrange - Use shared multiple handlers fixture (prefab with multiple handler components) @@ -488,6 +489,7 @@ public void SceneHandlerInvokesInstanceMethodWhenAssetCreated() } [Test] + [NUnit.Framework.Category("Stress")] public void SceneHandlerInvokesInstanceMethodWhenAssetDeleted() { // Arrange @@ -586,6 +588,7 @@ public void SceneHandlerFindsNestedChildComponents() } [Test] + [NUnit.Framework.Category("Stress")] public void SceneHandlerFindsMultipleHandlersInScene() { // Arrange - Create multiple handlers @@ -634,6 +637,7 @@ public void SceneHandlerFindsMultipleHandlersInScene() } [Test] + [NUnit.Framework.Category("Stress")] public void SceneHandlerFindsInactiveObjects() { // Arrange - Create inactive handler @@ -736,6 +740,7 @@ public void CombinedHandlerFindsBothPrefabAndSceneObjects() } [Test] + [NUnit.Framework.Category("Stress")] public void CombinedHandlerDoesNotDuplicateWhenSameInstanceInPrefabAndScene() { // Arrange - Use shared CombinedHandler fixture and instantiate it in scene @@ -786,6 +791,7 @@ public void CombinedHandlerDoesNotDuplicateWhenSameInstanceInPrefabAndScene() } [Test] + [NUnit.Framework.Category("Stress")] public void HandlerHandlesNullComponentsGracefully() { // This tests the null checks in the enumeration code @@ -809,6 +815,7 @@ public void HandlerHandlesNullComponentsGracefully() } [Test] + [NUnit.Framework.Category("Stress")] public void HandlerHandlesEmptyScenesGracefully() { // Arrange - No handlers in scene @@ -830,6 +837,7 @@ public void HandlerHandlesEmptyScenesGracefully() } [Test] + [NUnit.Framework.Category("Stress")] public void HandlerHandlesDestroyedObjectsDuringEnumeration() { // Arrange - Use CreateTrackedSceneObject to ensure proper cleanup if destroy fails @@ -880,8 +888,14 @@ public void HandlerNonComponentTypeDoesNotSearchPrefabs() } [TestCase(true, false, 1, TestName = "SearchOptions.PrefabOnly.FindsPrefab")] - [TestCase(false, true, 1, TestName = "SearchOptions.SceneOnly.FindsScene")] - [TestCase(true, true, 2, TestName = "SearchOptions.Both.FindsBoth")] + [TestCase( + false, + true, + 1, + TestName = "SearchOptions.SceneOnly.FindsScene", + Category = "Stress" + )] + [TestCase(true, true, 2, TestName = "SearchOptions.Both.FindsBoth", Category = "Stress")] public void SearchOptionsFindsCorrectInstances( bool usePrefab, bool createSceneObject, @@ -959,33 +973,24 @@ private static readonly (Type HandlerType, string HumanName)[] HandlerTypesUnder (typeof(TestNestedPrefabHandler), "TestNestedPrefabHandler"), }; - public static IEnumerable HandlerCanBeAddedToGameObjectCases() - { - foreach ((Type handlerType, string humanName) in HandlerTypesUnderTest) - { - yield return new TestCaseData(handlerType).SetName( - $"HandlerCanBeAddedToGameObject({humanName})" - ); - } - } - /// /// Verifies that each MonoBehaviour handler test double lives in a non-Editor - /// folder so Unity permits attaching it to GameObjects. Consolidated from four - /// copy-pasted tests via . + /// folder so Unity permits attaching it to GameObjects. /// [Test] - [TestCaseSource(nameof(HandlerCanBeAddedToGameObjectCases))] - public void HandlerCanBeAddedToGameObject(Type handlerType) + public void HandlersCanBeAddedToGameObjects() { - GameObject go = CreateTrackedSceneObject("TestAddComponent"); - Component handler = go.AddComponent(handlerType); - Assert.IsTrue( - handler != null, - $"{handlerType.Name} must NOT be in an Editor folder. " - + "MonoBehaviours in Editor folders cannot be attached to GameObjects. " - + "Move it to a non-Editor folder (e.g., Tests/Runtime/)." - ); + foreach ((Type handlerType, string humanName) in HandlerTypesUnderTest) + { + GameObject go = CreateTrackedSceneObject(humanName + "AddComponent"); + Component handler = go.AddComponent(handlerType); + Assert.IsTrue( + handler != null, + $"{handlerType.Name} must NOT be in an Editor folder. " + + "MonoBehaviours in Editor folders cannot be attached to GameObjects. " + + "Move it to a non-Editor folder (e.g., Tests/Runtime/)." + ); + } } [Test] diff --git a/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs b/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs index fecd98c6..2e5e7dc8 100644 --- a/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs +++ b/Tests/Editor/AssetProcessors/DetectAssetChangeProcessorTests.cs @@ -429,7 +429,7 @@ public void ChangeBatchesWithGapsLongerThanWindowAreNotSuppressed() DetectAssetChangeProcessor.TimeProvider = () => fakeTime; DetectAssetChangeProcessor.LoopWindowSecondsOverride = 5d; - int iterations = DetectAssetChangeProcessor.MaxConsecutiveChangeSetsWithinWindow + 5; + int iterations = DetectAssetChangeProcessor.MaxConsecutiveChangeSetsWithinWindow + 1; for (int i = 0; i < iterations; i++) { fakeTime += 6d; diff --git a/Tests/Runtime/Extensions/UnityExtensionsGridConcaveHullTests.cs b/Tests/Runtime/Extensions/UnityExtensionsGridConcaveHullTests.cs index 180beef5..bac92c91 100644 --- a/Tests/Runtime/Extensions/UnityExtensionsGridConcaveHullTests.cs +++ b/Tests/Runtime/Extensions/UnityExtensionsGridConcaveHullTests.cs @@ -538,11 +538,11 @@ public void ConcaveHullRepairMetricsRemainBoundedOnRepresentativeSamples() Track(owner); List samples = new(); - for (int y = 0; y < 72; ++y) + for (int y = 0; y < 40; ++y) { - for (int x = 0; x < 72; ++x) + for (int x = 0; x < 40; ++x) { - if (x > 18 && x < 54 && y > 18 && y < 54) + if (x > 10 && x < 30 && y > 10 && y < 30) { continue; } @@ -550,11 +550,11 @@ public void ConcaveHullRepairMetricsRemainBoundedOnRepresentativeSamples() } } - Assert.GreaterOrEqual(samples.Count, 3500); + Assert.GreaterOrEqual(samples.Count, 1000); UnityExtensions.ConcaveHullOptions options = UnityExtensions.ConcaveHullOptions.ForEdgeSplit( - bucketSize: 32, + bucketSize: 20, angleThreshold: 220f ); @@ -572,10 +572,10 @@ public void ConcaveHullRepairMetricsRemainBoundedOnRepresentativeSamples() FastVector3Int[] expectedCavityCorners = { - new(18, 18, 0), - new(18, 54, 0), - new(54, 18, 0), - new(54, 54, 0), + new(10, 10, 0), + new(10, 30, 0), + new(30, 10, 0), + new(30, 30, 0), }; AssertRequiredVertices( "RepresentativeSample cavity corners", @@ -657,12 +657,12 @@ public void ConcaveHullRepairMetricsRemainBoundedAcrossMultipleCavities() Track(owner); List samples = new(); - for (int y = 0; y < 84; ++y) + for (int y = 0; y < 48; ++y) { - for (int x = 0; x < 84; ++x) + for (int x = 0; x < 48; ++x) { - bool inFirstCavity = x > 14 && x < 34 && y > 14 && y < 34; - bool inSecondCavity = x > 52 && x < 70 && y > 40 && y < 66; + bool inFirstCavity = x > 8 && x < 19 && y > 8 && y < 19; + bool inSecondCavity = x > 30 && x < 40 && y > 24 && y < 40; if (inFirstCavity || inSecondCavity) { continue; @@ -673,7 +673,7 @@ public void ConcaveHullRepairMetricsRemainBoundedAcrossMultipleCavities() UnityExtensions.ConcaveHullOptions options = UnityExtensions.ConcaveHullOptions.ForEdgeSplit( - bucketSize: 40, + bucketSize: 24, angleThreshold: 240f ); @@ -695,14 +695,14 @@ public void ConcaveHullRepairMetricsRemainBoundedAcrossMultipleCavities() FastVector3Int[] expectedCorners = { - new(14, 14, 0), - new(14, 34, 0), - new(34, 14, 0), - new(34, 34, 0), - new(52, 40, 0), - new(52, 66, 0), - new(70, 40, 0), - new(70, 66, 0), + new(8, 8, 0), + new(8, 19, 0), + new(19, 8, 0), + new(19, 19, 0), + new(30, 24, 0), + new(30, 40, 0), + new(40, 24, 0), + new(40, 40, 0), }; AssertRequiredVertices("Multi-cavity corners", expectedCorners, hull); @@ -861,182 +861,182 @@ private static IEnumerable CavityShapeCases() // Single rectangular cavity (like the large samples test) yield return new TestCaseData( "SingleRectangularCavity", - 60, // gridWidth - 60, // gridHeight - new[] { new CavityRect(15, 45, 15, 45) }, - new[] { FV(14, 14), FV(14, 46), FV(46, 14), FV(46, 46) }, - 32, // bucketSize + 36, // gridWidth + 36, // gridHeight + new[] { new CavityRect(9, 27, 9, 27) }, + new[] { FV(8, 8), FV(8, 28), FV(28, 8), FV(28, 28) }, + 20, // bucketSize 220f // angleThreshold ).SetName("ConcaveHullCavityShape.SingleRectangular"); // Multiple disjoint cavities yield return new TestCaseData( "MultipleDisjointCavities", - 80, - 80, - new[] { new CavityRect(10, 25, 10, 25), new CavityRect(50, 70, 50, 70) }, + 48, + 48, + new[] { new CavityRect(7, 16, 7, 16), new CavityRect(31, 40, 31, 40) }, new[] { // First cavity corners - FV(9, 9), - FV(9, 26), - FV(26, 9), - FV(26, 26), + FV(6, 6), + FV(6, 17), + FV(17, 6), + FV(17, 17), // Second cavity corners - FV(49, 49), - FV(49, 71), - FV(71, 49), - FV(71, 71), + FV(30, 30), + FV(30, 41), + FV(41, 30), + FV(41, 41), }, - 48, + 24, 240f ).SetName("ConcaveHullCavityShape.MultipleDisjoint"); // L-shaped cavity (two overlapping rectangles forming an L) yield return new TestCaseData( "LShapedCavity", - 50, - 50, + 36, + 36, new[] { - new CavityRect(10, 20, 10, 35), // Vertical part of L - new CavityRect(10, 35, 10, 20), // Horizontal part of L + new CavityRect(7, 14, 7, 25), // Vertical part of L + new CavityRect(7, 25, 7, 14), // Horizontal part of L }, new[] { // Outer corners of L - FV(9, 9), - FV(9, 36), - FV(21, 36), - FV(21, 21), - FV(36, 21), - FV(36, 9), + FV(6, 6), + FV(6, 26), + FV(15, 26), + FV(15, 15), + FV(26, 15), + FV(26, 6), }, - 32, + 20, 230f ).SetName("ConcaveHullCavityShape.LShaped"); // U-shaped cavity (three rectangles forming a U) yield return new TestCaseData( "UShapedCavity", - 60, - 50, + 44, + 36, new[] { - new CavityRect(10, 20, 10, 35), // Left arm of U - new CavityRect(10, 50, 10, 20), // Bottom of U - new CavityRect(40, 50, 10, 35), // Right arm of U + new CavityRect(7, 14, 7, 25), // Left arm of U + new CavityRect(7, 36, 7, 14), // Bottom of U + new CavityRect(29, 36, 7, 25), // Right arm of U }, new[] { // U shape outer corners - FV(9, 9), - FV(9, 36), - FV(21, 36), - FV(21, 21), - FV(39, 21), - FV(39, 36), - FV(51, 36), - FV(51, 9), + FV(6, 6), + FV(6, 26), + FV(15, 26), + FV(15, 15), + FV(28, 15), + FV(28, 26), + FV(37, 26), + FV(37, 6), }, - 40, + 24, 235f ).SetName("ConcaveHullCavityShape.UShaped"); // Irregular cavity boundary (staircase pattern via multiple small rectangles) yield return new TestCaseData( "IrregularStaircaseCavity", - 50, - 50, + 36, + 36, new[] { - new CavityRect(10, 15, 10, 40), - new CavityRect(15, 20, 15, 40), - new CavityRect(20, 25, 20, 40), - new CavityRect(25, 30, 25, 40), + new CavityRect(7, 10, 7, 29), + new CavityRect(10, 14, 11, 29), + new CavityRect(14, 18, 15, 29), + new CavityRect(18, 22, 19, 29), }, new[] { // Staircase corners (outer edges) - FV(9, 9), - FV(9, 41), - FV(16, 41), - FV(16, 14), - FV(21, 14), - FV(21, 19), - FV(26, 19), - FV(26, 24), - FV(31, 24), - FV(31, 41), + FV(6, 6), + FV(6, 30), + FV(11, 30), + FV(11, 10), + FV(15, 10), + FV(15, 14), + FV(19, 14), + FV(19, 18), + FV(23, 18), + FV(23, 30), }, - 32, + 20, 225f ).SetName("ConcaveHullCavityShape.IrregularStaircase"); // Concentric frame (outer rectangle with inner rectangle, like a picture frame) yield return new TestCaseData( "ConcentricFrameCavity", - 70, - 70, - new[] { new CavityRect(20, 50, 20, 50) }, - new[] { FV(19, 19), FV(19, 51), FV(51, 19), FV(51, 51) }, - 40, + 44, + 44, + new[] { new CavityRect(13, 31, 13, 31) }, + new[] { FV(12, 12), FV(12, 32), FV(32, 12), FV(32, 32) }, + 24, 220f ).SetName("ConcaveHullCavityShape.ConcentricFrame"); // T-shaped cavity yield return new TestCaseData( "TShapedCavity", - 60, - 50, + 44, + 36, new[] { - new CavityRect(10, 50, 30, 40), // Top bar of T - new CavityRect(25, 35, 10, 40), // Vertical stem of T + new CavityRect(7, 36, 22, 29), // Top bar of T + new CavityRect(19, 25, 7, 29), // Vertical stem of T }, new[] { // T shape corners - FV(9, 29), - FV(9, 41), - FV(24, 41), - FV(24, 9), - FV(36, 9), - FV(36, 41), - FV(51, 41), - FV(51, 29), + FV(6, 21), + FV(6, 30), + FV(18, 30), + FV(18, 6), + FV(26, 6), + FV(26, 30), + FV(37, 30), + FV(37, 21), }, - 36, + 24, 230f ).SetName("ConcaveHullCavityShape.TShaped"); // Cross/Plus-shaped cavity yield return new TestCaseData( "CrossShapedCavity", - 60, - 60, + 44, + 44, new[] { - new CavityRect(20, 40, 10, 50), // Vertical bar - new CavityRect(10, 50, 20, 40), // Horizontal bar + new CavityRect(15, 29, 7, 36), // Vertical bar + new CavityRect(7, 36, 15, 29), // Horizontal bar }, new[] { // Cross corners (12 total for a plus shape) - FV(19, 9), - FV(19, 19), - FV(9, 19), - FV(9, 41), - FV(19, 41), - FV(19, 51), - FV(41, 51), - FV(41, 41), - FV(51, 41), - FV(51, 19), - FV(41, 19), - FV(41, 9), + FV(14, 6), + FV(14, 14), + FV(6, 14), + FV(6, 30), + FV(14, 30), + FV(14, 37), + FV(30, 37), + FV(30, 30), + FV(37, 30), + FV(37, 14), + FV(30, 14), + FV(30, 6), }, - 40, + 24, 235f ).SetName("ConcaveHullCavityShape.CrossShaped"); } diff --git a/Tests/Runtime/Extensions/UnityExtensionsVector2HullTests.cs b/Tests/Runtime/Extensions/UnityExtensionsVector2HullTests.cs index ff8998aa..d7843742 100644 --- a/Tests/Runtime/Extensions/UnityExtensionsVector2HullTests.cs +++ b/Tests/Runtime/Extensions/UnityExtensionsVector2HullTests.cs @@ -532,16 +532,13 @@ public void ConcaveHullVector2RandomSetsRespectInvariants() } } - // Allocation-bounds test over a 64x64 (4096-point) cloud run multiple times (warmup + - // measured). The KNN concave-hull work runs markedly slower under IL2CPP than under the Mono - // editor, exceeding the default 60s case timeout in the standalone player. Raise the budget - // (still far below the CI no-output watchdog); the allocation assertion is unchanged. + // Keep PR coverage representative; the excluded Performance suite covers 128x128 clouds. [Test] [Timeout(300000)] - public void ConcaveHullVector2LargePointCloudAllocationsStayBounded() + public void ConcaveHullVector2RepresentativePointCloudAllocationsStayBounded() { - const int width = 64; - const int height = 64; + const int width = 32; + const int height = 32; List points = new(width * height); for (int y = 0; y < height; ++y) { diff --git a/Tests/Runtime/Random/RandomTestBase.cs b/Tests/Runtime/Random/RandomTestBase.cs index a098c89c..9a897594 100644 --- a/Tests/Runtime/Random/RandomTestBase.cs +++ b/Tests/Runtime/Random/RandomTestBase.cs @@ -31,17 +31,37 @@ public abstract class RandomTestBase { private const int NumGeneratorChecks = 1_000; private const int NormalIterations = 1_000; + private const int DefaultFastNoiseMapIterations = 6; + private const int NoiseMapExclusiveMaxDimension = 75; + private const string RandomSampleCountEnvironmentVariable = "UH_RANDOM_SAMPLE_COUNT"; + private const string NoiseMapIterationsEnvironmentVariable = + "UH_RANDOM_NOISE_MAP_ITERATIONS"; // The distribution tests dominate suite runtime: each loops SampleCount // times across ~21 PRNG subclasses (~half a billion iterations at the // historical count). The fast default keeps the MAIN suite quick while // staying statistically valid (see the sqrt deviation floor in - // TestAndVerify); the perf/stress CI job exports UH_RANDOM_SAMPLE_COUNT = - // ThoroughSampleCount to restore the original, tighter bias-detection - // sensitivity. Override the env var for a thorough local run. + // TestAndVerify); the weekly benchmark Random lane exports the sample + // count and noise-map iteration env vars to restore the original, + // broader coverage. Override the env vars for a thorough local run. private const int DefaultFastSampleCount = 250_000; - private const int ThoroughSampleCount = 12_750_000; - private static readonly int SampleCount = ResolveSampleCount(); + private static readonly int SampleCount = ResolvePositiveIntEnvironmentVariable( + RandomSampleCountEnvironmentVariable, + DefaultFastSampleCount + ); + private static readonly int NoiseMapIterationCount = ResolvePositiveIntEnvironmentVariable( + NoiseMapIterationsEnvironmentVariable, + DefaultFastNoiseMapIterations + ); + private static readonly (int Width, int Height)[] FastNoiseMapDimensions = + { + (1, 1), + (1, NoiseMapExclusiveMaxDimension - 1), + (NoiseMapExclusiveMaxDimension - 1, 1), + (8, 8), + (17, 31), + (NoiseMapExclusiveMaxDimension - 1, NoiseMapExclusiveMaxDimension - 1), + }; // Floor for the allowed per-bin deviation, in standard deviations. Counts // over `sampleLength` bins are ~Poisson(average) with stddev sqrt(average), @@ -795,17 +815,25 @@ public void NextEnumExcept() public void NextNoiseMap() { IRandom random = NewRandom(); - for (int i = 0; i < NormalIterations; ++i) + int iteration = 0; + foreach ((int width, int height) in EnumerateNoiseMapDimensions(random)) { - int width = random.Next(1, 75); - int height = random.Next(1, 75); float[,] noise = new float[width, height]; random.NextNoiseMap(noise); foreach (float value in noise) { - Assert.LessOrEqual(0f, value); - Assert.GreaterOrEqual(1.1f, value); + Assert.GreaterOrEqual( + value, + 0f, + $"Noise value underflow at iteration {iteration}, size {width}x{height}." + ); + Assert.LessOrEqual( + value, + 1f, + $"Noise value overflow at iteration {iteration}, size {width}x{height}." + ); } + ++iteration; } } @@ -842,15 +870,41 @@ protected int GetSampleLength(int? sampleLength = null) return Math.Min(_samples.Length, sampleLength ?? _samples.Length); } - private static int ResolveSampleCount() + private static int ResolvePositiveIntEnvironmentVariable( + string variableName, + int defaultValue + ) { - string raw = Environment.GetEnvironmentVariable("UH_RANDOM_SAMPLE_COUNT"); + string raw = Environment.GetEnvironmentVariable(variableName); if (!string.IsNullOrWhiteSpace(raw) && int.TryParse(raw, out int parsed) && parsed > 0) { return parsed; } - return DefaultFastSampleCount; + return defaultValue; + } + + private static IEnumerable<(int Width, int Height)> EnumerateNoiseMapDimensions( + IRandom random + ) + { + if (NoiseMapIterationCount <= FastNoiseMapDimensions.Length) + { + for (int i = 0; i < NoiseMapIterationCount; ++i) + { + yield return FastNoiseMapDimensions[i]; + } + + yield break; + } + + for (int i = 0; i < NoiseMapIterationCount; ++i) + { + yield return ( + random.Next(1, NoiseMapExclusiveMaxDimension), + random.Next(1, NoiseMapExclusiveMaxDimension) + ); + } } private void TestAndVerify( diff --git a/package.json b/package.json index 3ff66278..e42020e2 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "test:lint-duplicate-usings": "pwsh -NoProfile -File scripts/tests/test-lint-duplicate-usings.ps1 -VerboseOutput", "test:lint-pwsh-invocations": "pwsh -NoProfile -File scripts/tests/test-lint-pwsh-invocations.ps1 -VerboseOutput", "test:lint-workflow-run-expression-length": "pwsh -NoProfile -File scripts/tests/test-lint-workflow-run-expression-length.ps1 -VerboseOutput", + "test:pr-workflow-concurrency": "pwsh -NoProfile -File scripts/tests/test-pr-workflow-concurrency.ps1 -VerboseOutput", "test:unity-workflow-matrix-contract": "pwsh -NoProfile -File scripts/tests/test-unity-workflow-matrix-contract.ps1 -VerboseOutput", "test:git-path-helpers": "pwsh -NoProfile -File scripts/tests/test-git-path-helpers.ps1 -VerboseOutput", "test:accelerator": "pwsh -NoProfile -File scripts/tests/test-accelerator.ps1 -VerboseOutput", @@ -183,7 +184,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 && npm run test:release-tools", + "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:pr-workflow-concurrency && 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", diff --git a/scripts/tests/test-pr-workflow-concurrency.ps1 b/scripts/tests/test-pr-workflow-concurrency.ps1 new file mode 100644 index 00000000..d2521f73 --- /dev/null +++ b/scripts/tests/test-pr-workflow-concurrency.ps1 @@ -0,0 +1,70 @@ +#!/usr/bin/env pwsh +# Contract test: pull_request workflows should cancel superseded PR iterations. +[CmdletBinding()] +param([switch]$VerboseOutput) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Info { + param([string]$Message) + if ($VerboseOutput) { + Write-Host "[test-pr-workflow-concurrency] $Message" -ForegroundColor Cyan + } +} + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$workflowDir = Join-Path $repoRoot '.github/workflows' +$failed = $false + +if (-not (Test-Path -LiteralPath $workflowDir)) { + Write-Host "::error::.github/workflows directory not found." + exit 1 +} + +$workflowFiles = @(Get-ChildItem -LiteralPath $workflowDir -Filter '*.yml' -File | Sort-Object Name) +foreach ($file in $workflowFiles) { + $relativePath = ".github/workflows/$($file.Name)" + $content = Get-Content -LiteralPath $file.FullName -Raw + + if ($content -notmatch '(?m)^ pull_request(_target)?:\s*$') { + Write-Info "Skipping $relativePath because it has no pull_request trigger." + continue + } + + $concurrencyMatch = [regex]::Match($content, '(?ms)^concurrency:\s*\r?\n(?.*?)(?=^[A-Za-z0-9_-]+:|\z)') + if (-not $concurrencyMatch.Success) { + Write-Host "::error file=$relativePath::pull_request workflows must define top-level concurrency so superseded PR iterations are cancelled." + $failed = $true + continue + } + + $body = $concurrencyMatch.Groups['body'].Value + $hasUsableGroup = + $body -match '(?m)^ group:\s*.+' -and + ($body.Contains('github.event.pull_request.number') -or $body.Contains('github.ref')) + $cancelsPullRequests = + $body -match "(?m)^ cancel-in-progress:\s*true\s*$" -or + $body -match "(?m)^ cancel-in-progress:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'\s*\}\}\s*$" + + if (-not $hasUsableGroup) { + Write-Host "::error file=$relativePath::pull_request workflow concurrency must group by the PR number or Git ref." + $failed = $true + } + + if (-not $cancelsPullRequests) { + Write-Host "::error file=$relativePath::pull_request workflow concurrency must cancel superseded pull_request runs." + $failed = $true + } + + if ($hasUsableGroup -and $cancelsPullRequests) { + Write-Info "Checked $relativePath." + } +} + +if ($failed) { + exit 1 +} + +Write-Host "[test-pr-workflow-concurrency] OK: pull_request workflows cancel superseded PR runs." -ForegroundColor Green +exit 0 diff --git a/scripts/tests/test-pr-workflow-concurrency.ps1.meta b/scripts/tests/test-pr-workflow-concurrency.ps1.meta new file mode 100644 index 00000000..a7cbfc32 --- /dev/null +++ b/scripts/tests/test-pr-workflow-concurrency.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f9aa121a184d826feef79acef31e605d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/tests/test-sync-script-contracts.ps1 b/scripts/tests/test-sync-script-contracts.ps1 index 7f5afe5f..26187856 100644 --- a/scripts/tests/test-sync-script-contracts.ps1 +++ b/scripts/tests/test-sync-script-contracts.ps1 @@ -362,6 +362,63 @@ function Run-PowerShellPathBindingContractTests { } } +function Run-UnityCiScriptContractTests { + Write-Host "" + Write-Host "Unity CI script contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $runnerPath = Join-Path $repoRoot 'scripts/unity/run-ci-tests.ps1' + + if (-not (Test-Path $runnerPath)) { + Write-TestResult ` + -TestName 'run-ci-tests.ps1 exists for Unity CI script contracts' ` + -Passed $false ` + -Message "Missing file: $runnerPath" + return + } + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($runnerPath, [ref]$tokens, [ref]$errors) + if ($errors -and $errors.Count -gt 0) { + $parseErrors = @($errors | ForEach-Object { "$($_.Extent.StartLineNumber): $($_.Message)" }) + Write-TestResult ` + -TestName 'run-ci-tests.ps1 parses for Unity CI script contracts' ` + -Passed $false ` + -Message "Parse errors: $($parseErrors -join '; ')" + return + } + + $definedFunctionNames = @( + $ast.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, + $true + ) | ForEach-Object { $_.Name } + ) + $ciCommandNames = @( + $ast.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, + $true + ) | + ForEach-Object { $_.GetCommandName() } | + Where-Object { $_ -match '^Write-Ci[A-Za-z0-9_]*$' } | + Sort-Object -Unique + ) + $missingDefinitions = @($ciCommandNames | Where-Object { $_ -notin $definedFunctionNames }) + + Write-TestResult ` + -TestName 'run-ci-tests.ps1 defines every Write-Ci* helper it calls' ` + -Passed ($missingDefinitions.Count -eq 0) ` + -Message "Missing helper definitions: $($missingDefinitions -join ', ')" +} + function Run-HookInstallContractTests { Write-Host "" Write-Host "Hook install contracts:" -ForegroundColor Magenta @@ -953,7 +1010,6 @@ function Run-ReleaseWorkflowChangelogContractTests { $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' @@ -978,12 +1034,12 @@ function Run-ReleaseWorkflowChangelogContractTests { } Write-TestResult ` - -TestName 'release tag/publish workflows validate changelog release-note content' ` + -TestName 'release publish workflow validates 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' ` + -TestName 'release publish workflow avoids raw changelog heading grep' ` -Passed ($rawHeadingGrep.Count -eq 0) ` -Message "Raw heading grep found in: $($rawHeadingGrep -join '; ')" @@ -1007,19 +1063,39 @@ function Run-ReleaseWorkflowChangelogContractTests { $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 + $publishVerifierKeepsStrictSemver = ( $publishWorkflowContent.Contains('Release tags must use unprefixed X.Y.Z semver.') -and $verifierHasStrictReleaseTagRegex -and $strictRegexAcceptsExpectedTags -and $strictRegexRejectsExpectedTags ) + $workflowLines = @($publishWorkflowContent -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 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.' + -TestName 'release publish workflow is manual-only' ` + -Passed ((-not $hasPushTrigger) -and $hasWorkflowDispatchTrigger) ` + -Message 'Expected release.yml to expose workflow_dispatch without an automatic push/tag trigger.' + + Write-TestResult ` + -TestName 'release publish workflow verifies strict release semver' ` + -Passed $publishVerifierKeepsStrictSemver ` + -Message 'Expected release.yml verify-tag to enforce exact no-leading-zero semver.' } function Run-ReleaseWorkflowGitHubCliContractTests { @@ -1031,16 +1107,39 @@ function Run-ReleaseWorkflowGitHubCliContractTests { $workflowPath = Join-Path $repoRoot '.github/workflows/release.yml' $workflowContent = Get-Content -Path $workflowPath -Raw $repoEnvPattern = 'GH_REPO:\s*\$\{\{\s*github\.repository\s*\}\}' + $publishJobBlock = [regex]::Match( + $workflowContent, + '(?ms)^\s*publish:\s*.*?\z' + ) $publishHasRepo = $workflowContent -match "(?ms)- name: Publish GitHub Release.*?env:.*?${repoEnvPattern}.*?run:" $verifyHasRepo = $workflowContent -match "(?ms)- name: Verify GitHub Release assets.*?env:.*?${repoEnvPattern}.*?run:" $verifyDownloadedArtifactsChecksIdentity = ( - $workflowContent.Contains('EXPECTED_PACKAGE_NAME: ${{ needs.verify-tag.outputs.package-name }}') -and - $workflowContent.Contains('EXPECTED_PACKAGE_VERSION: ${{ needs.verify-tag.outputs.package-version }}') -and + $workflowContent.Contains('EXPECTED_PACKAGE_NAME: ${{ needs.release-ready.outputs.package-name }}') -and + $workflowContent.Contains('EXPECTED_PACKAGE_VERSION: ${{ needs.release-ready.outputs.package-version }}') -and $workflowContent.Contains('notes_file=".artifacts/release/release-notes.md"') -and $workflowContent.Contains('tar -xOf "${package_file}" package/package.json') -and $workflowContent.Contains('Npm tarball identity mismatch.') ) + $publishChecksFailClosed = ( + $publishJobBlock.Success -and + $publishJobBlock.Value.Contains('npm_view_exit=$?') -and + $publishJobBlock.Value.Contains('Failed to verify npm publication state for ${PACKAGE_NAME}@${PACKAGE_VERSION}.') -and + $publishJobBlock.Value.Contains('(E404|404 Not Found|is not in this registry)') -and + $publishJobBlock.Value.Contains('gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"') -and + $publishJobBlock.Value.Contains('release_lookup_exit=$?') -and + $publishJobBlock.Value.Contains('Failed to verify GitHub Release state for ${TAG}.') -and + $publishJobBlock.Value.Contains('(HTTP 404|Not Found|"status":"404")') + ) + $publishRechecksTagTargetBeforePublish = ( + $publishJobBlock.Success -and + $publishJobBlock.Value.Contains('Verify release tag target before publish') -and + $publishJobBlock.Value.Contains('SOURCE_SHA: ${{ needs.release-ready.outputs.source-sha }}') -and + $publishJobBlock.Value.Contains('gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}"') -and + $publishJobBlock.Value.Contains('git/tags/${tag_object_sha}') -and + $publishJobBlock.Value.Contains('Tag ${TAG} points at ${tag_target}, not selected source ${SOURCE_SHA}.') -and + $publishJobBlock.Value.IndexOf('Verify release tag target before publish') -lt $publishJobBlock.Value.IndexOf('Publish npm package') + ) Write-TestResult ` -TestName 'release publish gh commands set repository context' ` @@ -1056,6 +1155,265 @@ function Run-ReleaseWorkflowGitHubCliContractTests { -TestName 'release publish verifies downloaded artifact identity before publish' ` -Passed $verifyDownloadedArtifactsChecksIdentity ` -Message 'Expected Verify downloaded artifacts to require non-empty release notes and matching npm tarball package name/version.' + + Write-TestResult ` + -TestName 'release publish artifact existence checks fail closed' ` + -Passed $publishChecksFailClosed ` + -Message 'Expected publish-time npm and GitHub Release existence checks to continue only after success or recognizable not-found responses.' + + Write-TestResult ` + -TestName 'release publish rechecks release tag target before publishing' ` + -Passed $publishRechecksTagTargetBeforePublish ` + -Message 'Expected publish to fail if the release tag no longer points at the verified source SHA.' +} + +function Run-ReleasePublishTagPreparationContractTests { + Write-Host "" + Write-Host "Release publish tag-preparation contracts:" -ForegroundColor Magenta + Write-Host "" + + $repoRoot = Get-RepoRoot + $workflowPath = Join-Path $repoRoot '.github/workflows/release.yml' + $workflowContent = Get-Content -Path $workflowPath -Raw + + $hasManualReleaseInputs = ( + $workflowContent.Contains('workflow_dispatch:') -and + $workflowContent.Contains('version:') -and + $workflowContent.Contains('source_ref:') -and + $workflowContent.Contains('allow_tag_recovery:') -and + $workflowContent.Contains('Release version to publish, as strict X.Y.Z semver.') -and + $workflowContent.Contains('Retarget an existing tag only if npm and GitHub Release are both still unpublished.') + ) + + $usesSourceRefForVerification = ( + $workflowContent.Contains('ref: ${{ inputs.source_ref }}') -and + $workflowContent.Contains('INPUT_SOURCE_REF: ${{ inputs.source_ref }}') -and + $workflowContent.Contains('source_sha="$(git rev-parse HEAD)"') -and + $workflowContent.Contains('printf ''source-ref=%s\n'' "${source_ref}"') -and + $workflowContent.Contains('echo "source-sha=${source_sha}"') + ) + + $sourceRefOutputIsInjectionSafe = ( + $workflowContent.Contains('Validate source ref input') -and + $workflowContent.Contains('source_ref="${INPUT_SOURCE_REF}"') -and + $workflowContent.Contains('Release source ref must be a single line.') -and + $workflowContent.Contains('printf ''source-ref=%s\n'' "${source_ref}"') + ) + + $versionOutputIsInjectionSafe = ( + $workflowContent.Contains('INPUT_VERSION: ${{ inputs.version }}') -and + $workflowContent.Contains('tag="${INPUT_VERSION}"') -and + $workflowContent.Contains('Release version is required.') -and + $workflowContent.Contains('Release version must be a single line.') -and + $workflowContent.Contains('echo "tag=${tag}"') -and + $workflowContent.IndexOf('Release version must be a single line.') -lt $workflowContent.IndexOf('echo "tag=${tag}"') + ) + + $downstreamJobsCheckoutVerifiedSha = ( + ([regex]::Matches($workflowContent, [regex]::Escape('ref: ${{ needs.release-ready.outputs.source-sha }}')).Count -ge 2) -and + $workflowContent.Contains('source-ref: ${{ steps.verify.outputs.source-ref }}') -and + $workflowContent.Contains('source-sha: ${{ needs.verify-tag.outputs.source-sha }}') -and + $workflowContent.Contains('tag-action: ${{ needs.verify-tag.outputs.tag-action }}') + ) + + $tagPreparationChecksExistingTagTarget = ( + $workflowContent.Contains('gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}"') -and + $workflowContent.Contains('tag_object_type="$(jq -r ''.object.type // empty'' "${tag_ref_file}")"') -and + $workflowContent.Contains('tag_target="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}" --jq ''.object.sha'')"') -and + $workflowContent.Contains('[ "${tag_target}" != "${source_sha}" ]') -and + $workflowContent.Contains('Tag ${tag} already points at ${tag_target}, not selected source ${source_sha}.') -and + $workflowContent.Contains('Re-run with allow_tag_recovery=true only after confirming npm and GitHub Release are unpublished.') + ) + + $missingTagCreationIsNormalPath = ( + $workflowContent.Contains('Tag ${tag} does not exist and will be created after package validation and .unitypackage export succeed.') -and + $workflowContent.Contains('tag_action="create"') -and + -not $workflowContent.Contains('Tag ${tag} does not exist. Use Release Tag') + ) + + $tagRetargetRequiresExplicitOptIn = ( + $workflowContent.Contains('[ "${allow_tag_recovery}" != "true" ]') -and + $workflowContent.Contains('tag_action="retarget"') -and + $workflowContent.Contains('allow_tag_recovery=true only after confirming npm and GitHub Release are unpublished') + ) + + $tagPreparationRefusesPublishedArtifacts = ( + $workflowContent.Contains('npm view "${package_name}@${package_version}" version --registry "https://registry.npmjs.org"') -and + $workflowContent.Contains('npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry "https://registry.npmjs.org"') -and + $workflowContent.Contains('npm_view_exit=$?') -and + $workflowContent.Contains('(E404|404 Not Found|is not in this registry)') -and + $workflowContent.Contains('Failed to verify npm publication state for ${package_name}@${package_version}.') -and + $workflowContent.Contains('Failed to verify npm publication state for ${PACKAGE_NAME}@${PACKAGE_VERSION}.') -and + $workflowContent.Contains('gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}"') -and + $workflowContent.Contains('gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"') -and + $workflowContent.Contains('release_lookup_exit=$?') -and + $workflowContent.Contains('(HTTP 404|Not Found|"status":"404")') -and + $workflowContent.Contains('Failed to verify GitHub Release state for ${tag}.') -and + $workflowContent.Contains('Failed to verify GitHub Release state for ${TAG}.') -and + $workflowContent.Contains('Published artifacts already exist; refusing to create or retarget tag ${tag}.') -and + $workflowContent.Contains('Published artifacts already exist; refusing to create or retarget tag ${TAG}.') + ) + + $verifyTagBlock = [regex]::Match( + $workflowContent, + '(?ms)^\s*verify-tag:\s*.*?^\s*release-ready:' + ) + $prepareTagBlock = [regex]::Match( + $workflowContent, + '(?ms)^\s*prepare-tag:\s*.*?^\s*publish:' + ) + $publishBlock = [regex]::Match( + $workflowContent, + '(?ms)^\s*publish:\s*.*?\z' + ) + $missingTagLookupAcceptsJson404 = ( + $verifyTagBlock.Success -and + $verifyTagBlock.Value.Contains('tag_lookup_output="$(cat "${tag_ref_error}")"') -and + $verifyTagBlock.Value.Contains('(HTTP 404|Not Found|"status":"404")') -and + $verifyTagBlock.Value.Contains('tag_action="create"') + ) + + $tagPreparationRunsAfterArtifactJobs = ( + $prepareTagBlock.Success -and + $prepareTagBlock.Value.Contains('name: Prepare release tag') -and + $prepareTagBlock.Value.Contains('needs:') -and + $prepareTagBlock.Value.Contains(' - release-ready') -and + $prepareTagBlock.Value.Contains(' - validate-package') -and + $prepareTagBlock.Value.Contains(' - unitypackage') -and + $prepareTagBlock.Value.Contains('if: ${{ needs.release-ready.outputs.tag-action != ''none'' }}') + ) + + $tagPreparationSetsUpNodeForNpmChecks = ( + $verifyTagBlock.Success -and + $prepareTagBlock.Success -and + $verifyTagBlock.Value.Contains('uses: actions/setup-node@v6') -and + $verifyTagBlock.Value.Contains('node-version: "22.18.0"') -and + $verifyTagBlock.Value.IndexOf('Setup Node.js') -lt $verifyTagBlock.Value.IndexOf('Verify tag matches package metadata') -and + $prepareTagBlock.Value.Contains('uses: actions/setup-node@v6') -and + $prepareTagBlock.Value.Contains('node-version: "22.18.0"') -and + $prepareTagBlock.Value.IndexOf('Setup Node.js') -lt $prepareTagBlock.Value.IndexOf('Recheck release artifacts before tag mutation') + ) + + $tagPreparationRechecksPublishedArtifactsBeforeMutation = ( + $prepareTagBlock.Success -and + $prepareTagBlock.Value.Contains('Recheck release artifacts before tag mutation') -and + $prepareTagBlock.Value.Contains('npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry "https://registry.npmjs.org"') -and + $prepareTagBlock.Value.Contains('Failed to verify npm publication state for ${PACKAGE_NAME}@${PACKAGE_VERSION}.') -and + $prepareTagBlock.Value.Contains('gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"') -and + $prepareTagBlock.Value.Contains('Failed to verify GitHub Release state for ${TAG}.') -and + $prepareTagBlock.Value.Contains('Published artifacts already exist; refusing to create or retarget tag ${TAG}.') -and + $prepareTagBlock.Value.IndexOf('Recheck release artifacts before tag mutation') -lt $prepareTagBlock.Value.IndexOf('Create or retarget release tag') + ) + + $tagPreparationCreatesAnnotatedTags = ( + $workflowContent.Contains('gh_api_or_error "Creating annotated tag object ${TAG}" --method POST "repos/${GITHUB_REPOSITORY}/git/tags"') -and + $workflowContent.Contains('--field message="Release ${TAG}"') -and + $workflowContent.Contains('--field object="${SOURCE_SHA}"') -and + $workflowContent.Contains('gh_api_or_error "Creating tag ref refs/tags/${TAG}" --method POST "repos/${GITHUB_REPOSITORY}/git/refs"') -and + $workflowContent.Contains('gh_api_or_error "Retargeting tag ref refs/tags/${TAG}" --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}"') -and + $workflowContent.Contains('--field force=true') + ) + + $tagPreparationReportsMutationFailures = ( + $prepareTagBlock.Success -and + $prepareTagBlock.Value.Contains('gh_api_or_error()') -and + $prepareTagBlock.Value.Contains('The release tag may have changed after verification, or the GitHub token may lack contents:write.') -and + $prepareTagBlock.Value.Contains('tag-gh-api-stderr.txt') -and + $prepareTagBlock.Value.Contains('tag-gh-api-stdout.txt') + ) + + $publishWaitsForPreparedTag = ( + $publishBlock.Success -and + $publishBlock.Value.Contains(' - prepare-tag') -and + $publishBlock.Value.Contains("needs.release-ready.outputs.tag-action == 'none' || needs.prepare-tag.result == 'success'") + ) + + $hasWritePermissionOnlyWhereNeeded = ( + ($workflowContent -match '(?m)^permissions:\r?\n contents: read\r?$') -and + ($workflowContent -match '(?ms)^\s*verify-tag:\s*.*?^\s*permissions:\s*\r?\n\s*contents:\s*read\s*\r?\n\s*outputs:') -and + ($workflowContent -match '(?ms)^\s*prepare-tag:\s*.*?^\s*permissions:\s*\r?\n\s*contents:\s*write\s*\r?\n\s*steps:') -and + ($workflowContent -match '(?ms)^\s*publish:\s*.*?^\s*permissions:\s*\r?\n\s*contents:\s*write\s*\r?\n\s*id-token:\s*write\s*\r?\n\s*steps:') + ) + + Write-TestResult ` + -TestName 'release publish exposes manual version/source/tag recovery inputs' ` + -Passed $hasManualReleaseInputs ` + -Message 'Expected release.yml workflow_dispatch inputs for version, source_ref, and allow_tag_recovery.' + + Write-TestResult ` + -TestName 'release publish verifies selected source ref and freezes downstream checkouts to its SHA' ` + -Passed ($usesSourceRefForVerification -and $downstreamJobsCheckoutVerifiedSha) ` + -Message 'Expected verify-tag to checkout the selected source ref, output its resolved SHA, and downstream jobs to checkout that SHA.' + + Write-TestResult ` + -TestName 'release publish rejects multiline source refs before writing outputs' ` + -Passed $sourceRefOutputIsInjectionSafe ` + -Message 'Expected release.yml to reject multiline source_ref values before writing source-ref to GITHUB_OUTPUT.' + + Write-TestResult ` + -TestName 'release publish rejects multiline versions before writing outputs' ` + -Passed $versionOutputIsInjectionSafe ` + -Message 'Expected release.yml to reject empty or multiline version values before writing tag to GITHUB_OUTPUT.' + + Write-TestResult ` + -TestName 'release publish checks existing tag targets before publish' ` + -Passed $tagPreparationChecksExistingTagTarget ` + -Message 'Expected release.yml to compare existing tag targets with the selected source SHA before continuing.' + + Write-TestResult ` + -TestName 'release publish creates missing tags as the normal manual path' ` + -Passed $missingTagCreationIsNormalPath ` + -Message 'Expected missing release tags to be created by Release Publish without the retired Release Tag workflow.' + + Write-TestResult ` + -TestName 'release publish missing tag lookup accepts GitHub JSON 404s' ` + -Passed $missingTagLookupAcceptsJson404 ` + -Message 'Expected missing release tag detection to accept the JSON 404 shape returned by gh api.' + + Write-TestResult ` + -TestName 'release publish requires explicit opt-in before retargeting tags' ` + -Passed $tagRetargetRequiresExplicitOptIn ` + -Message 'Expected existing mismatched release tags to require allow_tag_recovery=true before retargeting.' + + Write-TestResult ` + -TestName 'release publish refuses tag mutation after npm or GitHub Release publication' ` + -Passed $tagPreparationRefusesPublishedArtifacts ` + -Message 'Expected tag preparation to query npm and GitHub Release state before creating or retargeting a tag.' + + Write-TestResult ` + -TestName 'release publish prepares tags only after package and unitypackage artifacts' ` + -Passed $tagPreparationRunsAfterArtifactJobs ` + -Message 'Expected prepare-tag to need validate-package and unitypackage so failed artifact production cannot create a release tag.' + + Write-TestResult ` + -TestName 'release publish tag preparation sets up Node before npm publication checks' ` + -Passed $tagPreparationSetsUpNodeForNpmChecks ` + -Message 'Expected verify-tag and prepare-tag to set up pinned Node before running npm view publication checks.' + + Write-TestResult ` + -TestName 'release publish tag preparation rechecks publication state before mutation' ` + -Passed $tagPreparationRechecksPublishedArtifactsBeforeMutation ` + -Message 'Expected prepare-tag to re-check npm and GitHub Release state immediately before creating or retargeting a tag.' + + Write-TestResult ` + -TestName 'release publish tag preparation creates annotated tags' ` + -Passed $tagPreparationCreatesAnnotatedTags ` + -Message 'Expected release.yml to create annotated release tags and force-update only through the guarded retarget path.' + + Write-TestResult ` + -TestName 'release publish tag preparation reports mutation failures clearly' ` + -Passed $tagPreparationReportsMutationFailures ` + -Message 'Expected prepare-tag to capture gh api output and emit actionable tag mutation errors.' + + Write-TestResult ` + -TestName 'release publish waits for prepared tag before publishing' ` + -Passed $publishWaitsForPreparedTag ` + -Message 'Expected publish to wait for prepare-tag when tag_action is create or retarget.' + + Write-TestResult ` + -TestName 'release publish keeps verification permissions read-only while tag preparation can write refs' ` + -Passed $hasWritePermissionOnlyWhereNeeded ` + -Message 'Expected workflow-level contents: read, verify-tag contents: read, prepare-tag contents: write, and publish contents: write.' } function Run-ReleasePublishWorkflowBudgetContractTests { @@ -1191,6 +1549,11 @@ function Run-ReleasePrepareWorkflowContractTests { -not $workflowContent.Contains('$arguments = @(') -and -not $workflowContent.Contains('@arguments') ) + $prBodyUsesManualPublishWorkflow = ( + $workflowContent.Contains('Run the Release Publish workflow on \`${DEFAULT_BRANCH}\` with version \`${VERSION}\`.') -and + $workflowContent.Contains('creates the release tag if needed') -and + -not $workflowContent.Contains('The Release Tag workflow pushes tag') + ) Write-TestResult ` -TestName 'release prepare checks existing release branches with robust git heads lookup' ` @@ -1211,153 +1574,51 @@ function Run-ReleasePrepareWorkflowContractTests { -TestName 'release prepare invokes prepare-release with explicit PowerShell parameters' ` -Passed $usesExplicitPrepareReleaseInvocation ` -Message 'Expected release-prepare.yml to avoid positional array splatting that can bind "-Bump" as the Bump value.' + + Write-TestResult ` + -TestName 'release prepare points operators at the manual Release Publish button' ` + -Passed $prBodyUsesManualPublishWorkflow ` + -Message 'Expected release PR body to tell operators to run Release Publish after merge, without referencing the retired Release Tag workflow.' } -function Run-ReleaseTagWorkflowContractTests { +function Run-ReleaseTagWorkflowRetirementContractTests { Write-Host "" - Write-Host "Release tag workflow contracts:" -ForegroundColor Magenta + Write-Host "Release tag workflow retirement 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) - $untaggedErrorIndex = $workflowContent.IndexOf('Version ${version} is untagged and CHANGELOG.md documents it', [StringComparison]::Ordinal) - $untaggedErrorExitIndex = if ($untaggedErrorIndex -ge 0) { - $workflowContent.IndexOf('exit 1', $untaggedErrorIndex, [StringComparison]::Ordinal) - } else { - -1 - } - $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 - ) - $checksLocalTagBeforeUntaggedError = ( - $subjectCheckIndex -ge 0 -and - $existingTagNoOpIndex -gt $subjectCheckIndex -and - $untaggedErrorIndex -gt $existingTagNoOpIndex -and - $untaggedErrorExitIndex -gt $untaggedErrorIndex -and - $untaggedErrorExitIndex -lt $nonReleaseProceedFalseIndex -and - $existingTagNoOpIndex -lt $nonReleaseProceedFalseIndex - ) - $untaggedDocumentedVersionIsHardFailure = ( - $checksLocalTagBeforeUntaggedError -and - $workflowContent.Contains('echo "::error::${message}"') -and - -not $workflowContent.Contains('echo "::warning::${message}"') - ) - $fetchesTagsBeforeLocalTaggedNoOp = ( - $checkoutStepIndex -ge 0 -and - $checkoutFetchTagsIndex -gt $checkoutStepIndex -and - $checkoutFetchTagsIndex -lt $existingTagNoOpIndex + $tagWorkflowPath = Join-Path $repoRoot '.github/workflows/release-tag.yml' + $publishWorkflowPath = Join-Path $repoRoot '.github/workflows/release.yml' + $publishWorkflowContent = Get-Content -Path $publishWorkflowPath -Raw + $prepareWorkflowPath = Join-Path $repoRoot '.github/workflows/release-prepare.yml' + $prepareWorkflowContent = Get-Content -Path $prepareWorkflowPath -Raw + + $tagWorkflowRetired = -not (Test-Path $tagWorkflowPath) + $publishOwnsTagCreation = ( + $publishWorkflowContent.Contains('name: Prepare release tag') -and + $publishWorkflowContent.Contains('tag_action="create"') -and + $publishWorkflowContent.Contains('Create or retarget release tag') -and + $publishWorkflowContent.Contains('gh_api_or_error "Creating annotated tag object ${TAG}" --method POST "repos/${GITHUB_REPOSITORY}/git/tags"') ) - $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') + $releasePrepareNoLongerMentionsReleaseTag = ( + $prepareWorkflowContent.Contains('Run the Release Publish workflow') -and + -not $prepareWorkflowContent.Contains('Release Tag workflow') ) 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 fails for untagged documented versions with non-release subjects' ` - -Passed $untaggedDocumentedVersionIsHardFailure ` - -Message 'Expected release-tag.yml to fail when CHANGELOG.md documents the package version, no local tag exists, and the commit subject is not a release subject.' - - 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.' + -TestName 'release tag workflow is retired' ` + -Passed $tagWorkflowRetired ` + -Message 'Expected .github/workflows/release-tag.yml to be absent because Release Publish owns tag creation.' 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.' + -TestName 'release publish owns release tag creation' ` + -Passed $publishOwnsTagCreation ` + -Message 'Expected release.yml prepare-tag job to create annotated release tags when the manual release tag is missing.' 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.' + -TestName 'release prepare instructions no longer mention Release Tag workflow' ` + -Passed $releasePrepareNoLongerMentionsReleaseTag ` + -Message 'Expected release-prepare.yml PR body to instruct operators to run Release Publish, not Release Tag.' } function Run-ReleasePackageContentContractTests { @@ -1572,14 +1833,16 @@ Run-SyncScriptContractTests Run-CspellContractTests Run-AgentValidationContractTests Run-PowerShellPathBindingContractTests +Run-UnityCiScriptContractTests Run-HookInstallContractTests Run-RepoLocalPrettierContractTests Run-PrePushLastResortGuidanceContractTests Run-ReleaseDrafterChangelogVersionContractTests Run-ReleaseWorkflowChangelogContractTests Run-ReleaseWorkflowGitHubCliContractTests +Run-ReleasePublishTagPreparationContractTests Run-ReleasePublishWorkflowBudgetContractTests Run-ReleasePrepareWorkflowContractTests -Run-ReleaseTagWorkflowContractTests +Run-ReleaseTagWorkflowRetirementContractTests 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 7d03498a..bd53444a 100644 --- a/scripts/tests/test-unity-workflow-matrix-contract.ps1 +++ b/scripts/tests/test-unity-workflow-matrix-contract.ps1 @@ -21,10 +21,30 @@ if (-not (Test-Path -LiteralPath $workflowPath)) { } [string[]]$lines = Get-Content -LiteralPath $workflowPath +[string]$workflowContent = $lines -join "`n" [bool]$failed = $false [bool]$insideJobs = $false $jobTexts = @{} +$hasPrCancelConcurrency = ( + $workflowContent.Contains('group: unity-tests-${{ github.event.pull_request.number || github.ref }}') -and + $workflowContent.Contains('cancel-in-progress: ${{ github.event_name == ''pull_request'' }}') +) +if (-not $hasPrCancelConcurrency) { + Write-Host "::error file=.github/workflows/unity-tests.yml::Unity Tests must cancel superseded pull_request runs so old iterations do not keep the organization Unity runner occupied." + $failed = $true +} elseif ($VerboseOutput) { + Write-Info "Checked Unity Tests pull_request concurrency cancellation contract." +} + +$slowReportBudgetCount = ([regex]::Matches($workflowContent, [regex]::Escape('-FixtureBudgetSeconds 120'))).Count +if ($slowReportBudgetCount -lt 3) { + Write-Host "::error file=.github/workflows/unity-tests.yml::Unity slow-test reports must include a warn-only 120s fixture budget for main, standalone, and single-threaded legs." + $failed = $true +} elseif ($VerboseOutput) { + Write-Info "Checked Unity slow-test warn-only fixture budget contract." +} + for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^jobs:\s*$') { $insideJobs = $true diff --git a/scripts/unity/run-ci-tests.ps1 b/scripts/unity/run-ci-tests.ps1 index 5b4f1cf0..7c5baf2d 100644 --- a/scripts/unity/run-ci-tests.ps1 +++ b/scripts/unity/run-ci-tests.ps1 @@ -126,6 +126,11 @@ function Write-CiNotice { Write-Host "::notice::$Message" } +function Write-CiWarning { + param([Parameter(Mandatory = $true)][string]$Message) + Write-Host "::warning::$Message" +} + # SINGLE SOURCE OF TRUTH for the catastrophic-pattern list that both # Write-UnityCatastrophicErrorAnnotations (new ::error:: annotation surface) # AND Write-UnityResultFailureDiagnostics (older line-numbered selected-line