diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml index 697534139..64247fcc5 100644 --- a/.github/workflows/build-matrix.yml +++ b/.github/workflows/build-matrix.yml @@ -17,6 +17,7 @@ on: branches: - main - net10.0 + - net11.0 paths: - 'workload/**' - '.github/workflows/build-matrix.yml' @@ -24,6 +25,7 @@ on: branches: - main - net10.0 + - net11.0 paths: - 'workload/**' - '.github/workflows/build-matrix.yml' @@ -37,9 +39,22 @@ jobs: validate-metadata: name: Validate workload metadata runs-on: ubuntu-22.04 + outputs: + net11_sdk: ${{ steps.versions.outputs.net11_sdk }} steps: - uses: actions/checkout@v3 + - name: Resolve .NET 11 SDK version from Versions.props + id: versions + run: | + NET11=$(grep -oP '(?<=)[^<]+' workload/build/Versions.props) + if [ -z "$NET11" ]; then + echo "::error::DotNet11SdkVersion not found in workload/build/Versions.props" + exit 1 + fi + echo "net11_sdk=$NET11" >> "$GITHUB_OUTPUT" + echo "::notice ::.NET 11 SDK for CI: $NET11" + - name: Authenticate GitHub Packages NuGet source run: | dotnet nuget update source github \ @@ -51,10 +66,45 @@ jobs: - name: Run validate-workload-metadata.py run: python3 workload/scripts/validate-workload-metadata.py + - name: Run test-matrix.sh --self-test + run: bash workload/scripts/test-matrix.sh --self-test + + - name: Run test-version-band.sh + run: bash workload/scripts/test-version-band.sh + + - name: Run test-template-conditions.sh + run: bash workload/scripts/test-template-conditions.sh + + - name: Run test-package-fallback.sh + run: bash workload/scripts/test-package-fallback.sh + + - name: Run test-release-workflow.sh + run: bash workload/scripts/test-release-workflow.sh + + - name: Run test-install-failure.sh + run: bash workload/scripts/test-install-failure.sh + test-matrix: - name: Multi-TFM build matrix + name: Multi-TFM build matrix (${{ matrix.name }}) needs: validate-metadata runs-on: ubuntu-22.04 + # The .NET 11 leg BLOCKS by default. The SDK version is pinned exactly + # (DotNet11SdkVersion), so the leg is deterministic - and a branch whose purpose is + # .NET 11 support gains nothing from an advisory-only .NET 11 gate. + # + # Set the repository variable TIZEN_NET11_ADVISORY=true to temporarily downgrade it, + # e.g. while chasing an upstream preview-SDK regression. + continue-on-error: ${{ matrix.experimental && vars.TIZEN_NET11_ADVISORY == 'true' }} + strategy: + fail-fast: false + matrix: + include: + - name: .NET 10 + dotnet_version: '' + experimental: false + - name: .NET 11 preview + dotnet_version: ${{ needs.validate-metadata.outputs.net11_sdk }} + experimental: true steps: - uses: actions/checkout@v3 with: @@ -71,6 +121,7 @@ jobs: - name: Run make test-matrix env: PULLREQUEST_ID: ${{ github.event.number }} + DOTNET_VERSION: ${{ matrix.dotnet_version }} working-directory: ./workload run: make test-matrix @@ -78,7 +129,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: test-matrix-logs + name: test-matrix-logs-${{ matrix.name }} path: | workload/.tmp/matrix/**/build.log workload/.tmp/matrix/**/dotnet-new.log diff --git a/.github/workflows/build-workload.yml b/.github/workflows/build-workload.yml index 067d51f3e..165ea29b5 100644 --- a/.github/workflows/build-workload.yml +++ b/.github/workflows/build-workload.yml @@ -5,6 +5,7 @@ on: branches: - main - net10.0 + - net11.0 paths: - 'workload/**' - '.github/workflows/**' @@ -12,6 +13,7 @@ on: branches: - main - net10.0 + - net11.0 paths: - 'workload/**' - '.github/workflows/**' @@ -41,10 +43,31 @@ jobs: --store-password-in-clear-text \ --configfile workload/NuGet.config + # On a net11.0 branch (or a PR into one) .NET 11 is the product being built, so the + # workload must be built against the .NET 11 SDK rather than the Versions.props + # default. DotNet11SdkVersion in Versions.props is the single source of truth. + - name: Select target SDK band + id: sdk + run: | + TARGET_BRANCH="${{ github.base_ref || github.ref_name }}" + if [ "$TARGET_BRANCH" = "net11.0" ]; then + NET11=$(grep -oP '(?<=)[^<]+' workload/build/Versions.props) + if [ -z "$NET11" ]; then + echo "::error::DotNet11SdkVersion not found in workload/build/Versions.props" + exit 1 + fi + echo "dotnet_version=$NET11" >> "$GITHUB_OUTPUT" + echo "::notice ::Branch '$TARGET_BRANCH' builds against .NET 11 SDK $NET11" + else + echo "dotnet_version=" >> "$GITHUB_OUTPUT" + echo "::notice ::Branch '$TARGET_BRANCH' builds against the Versions.props default SDK band" + fi + - name: Build env: PULLREQUEST_ID: ${{ github.event.number }} PRERELEASE_TAG: ${{ github.event.inputs.prerelease }} + DOTNET_VERSION: ${{ steps.sdk.outputs.dotnet_version }} run: make test working-directory: ./workload diff --git a/.github/workflows/release-workload.yml b/.github/workflows/release-workload.yml index 0ddb29d79..ee476a8d9 100644 --- a/.github/workflows/release-workload.yml +++ b/.github/workflows/release-workload.yml @@ -21,6 +21,14 @@ permissions: contents: write packages: read +# Only one release may be in flight at a time. Combined with the durable reservation ref +# below, this prevents two runs (different branches/bands) from selecting the same global +# workload version. `cancel-in-progress: false` so an in-flight release is never torn down +# half-published. +concurrency: + group: tizen-workload-release + cancel-in-progress: false + jobs: release: runs-on: ubuntu-22.04 @@ -37,68 +45,342 @@ jobs: --store-password-in-clear-text \ --configfile workload/NuGet.config - - name: Bump TizenWorkloadVersion (global sequential, NuGet-derived) + # Resolve, RESERVE and pin the release commit - before anything is built. + # + # Three problems are solved together here: + # + # * Artifact provenance. The Makefile embeds `git log -1` into every package, so + # building before the version-bump commit made the packages carry a different SHA + # than the tag - and a resumed run, building from a different commit, produced + # packages that disagreed with the ones already published. The release commit is + # therefore created FIRST and everything is built from that exact SHA. + # + # * Concurrency. The global workload version is shared by every branch/band, so two + # runs could pick the same V. Ownership is claimed by atomically creating a + # reservation ref (`refs/tizen-release/v`); git rejects a non-fast-forward + # create, so exactly one run wins. A run that finds a foreign reservation aborts. + # + # * Re-run drift. A re-run checks out the SHA of the original event and a fresh + # dispatch checks out whatever the branch points at now, so the reserved SHA is + # recorded and explicitly checked out below. + - name: Resolve and reserve release version id: bump working-directory: ./workload + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -e + set -euo pipefail + SDK="${{ github.event.inputs.net_sdk_version }}" + eval "$(sed -n '/# BEGIN VERSION BAND DETECTION/,/# END VERSION BAND DETECTION/p' scripts/workload-install.sh)" + BAND="$(compute_target_version_band "$SDK")" + MANIFEST_ID="Samsung.NET.Sdk.Tizen.Manifest-$BAND" + PACKAGES="$MANIFEST_ID,Samsung.NETCore.App.Runtime.tizen,Samsung.Tizen.Sdk,Samsung.Tizen.Templates" + BRANCH="${GITHUB_REF_NAME}" + { + echo "band=$BAND" + echo "manifest_id=$MANIFEST_ID" + echo "packages=$PACKAGES" + } >> "$GITHUB_OUTPUT" + OLD=$(grep -oP '(?<=)[^<]+(?=)' build/Versions.props) - NEW=$(python3 scripts/next-workload-version.py) - echo "Current: $OLD" - echo "Next: $NEW" - if [ "$OLD" = "$NEW" ]; then - echo "ERROR: computed next == current. NuGet already has this version published." + echo "Branch version at the checked-out commit: $OLD" + + # A reference-only run publishes no manifest: fully non-mutating, no reservation. + if [ "${{ github.event.inputs.release_manifest }}" != "true" ]; then + echo "::notice ::Reference-only run: leaving TizenWorkloadVersion at $OLD (non-mutating)." + echo "workload_version=$OLD" >> "$GITHUB_OUTPUT" + echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # ------------------------------------------------------------------ + # Find an outstanding reservation to resume. + # + # Resuming used to be decided from $OLD - the version in Versions.props at the + # commit this job checked out. That is the wrong source of truth for a re-run: + # `Re-run jobs` checks out the SHA of the ORIGINAL event, which is the commit + # BEFORE the bump, so $OLD is the previous, already-released version. The re-run + # therefore saw "nothing to resume", computed the same candidate again, found its + # own reservation ref and aborted as though a stranger held it - leaving the + # interrupted release permanently unfinishable. + # + # The reservation refs are the durable record, so they are enumerated directly and + # matched against THIS run's inputs. The checked-out SHA no longer matters. + # ------------------------------------------------------------------ + git fetch --force origin "+refs/tizen-release/*:refs/tizen-release/*" || true + + RESUME_VER="" + RESUME_SHA="" + for ref in $(git for-each-ref --format='%(refname)' 'refs/tizen-release/v*'); do + V="${ref##*/v}" + git cat-file -p "$ref" > "$RUNNER_TEMP/payload.txt" 2>/dev/null || continue + + # Only consider reservations made for these exact release inputs. A reservation + # for another branch, SDK or band belongs to a different release and must never + # be adopted by this one. + if ! python3 scripts/release-reservation.py verify \ + --payload-file "$RUNNER_TEMP/payload.txt" \ + --expect-version "$V" \ + --expect-sdk "$SDK" \ + --expect-band "$BAND" \ + --expect-branch "$BRANCH" \ + --expect-manifest-id "$MANIFEST_ID" \ + --expect-packages "$PACKAGES" >/dev/null 2>&1; then + continue + fi + + # It is ours. Is it finished? + STATE_OUT="$(python3 scripts/release-state.py --version "$V" --band "$BAND")" + STATE="$(echo "$STATE_OUT" | sed -n 's/^state=//p')" + if gh release view "v$V" >/dev/null 2>&1; then RELEASED=true; else RELEASED=false; fi + if [ "$STATE" = "published" ] && [ "$RELEASED" = "true" ]; then + continue + fi + + if [ -n "$RESUME_VER" ]; then + echo "::error::More than one unfinished reservation matches these inputs (v$RESUME_VER and v$V). Resolve this by hand." exit 1 + fi + RESUME_VER="$V" + RESUME_SHA="$(python3 scripts/release-reservation.py verify \ + --payload-file "$RUNNER_TEMP/payload.txt" --print-field sha)" + echo "::warning ::Reservation v$V is unfinished (state=$STATE, released=$RELEASED). Resuming it." + done + + if [ -n "$RESUME_VER" ]; then + echo "Resuming v$RESUME_VER from reserved commit $RESUME_SHA" + echo "workload_version=$RESUME_VER" >> "$GITHUB_OUTPUT" + echo "release_sha=$RESUME_SHA" >> "$GITHUB_OUTPUT" + echo "resumed=true" >> "$GITHUB_OUTPUT" + exit 0 fi - python3 scripts/next-workload-version.py --apply --verbose + + # ------------------------------------------------------------------ + # Fresh release. Compute the candidate ONCE and write that exact value; calling + # the script again with --apply would re-query the feed and could persist a + # different version if another release landed in between. + # ------------------------------------------------------------------ + NEW=$(python3 scripts/next-workload-version.py) + echo "Candidate: $NEW" + + if git ls-remote --exit-code origin "refs/tizen-release/v$NEW" >/dev/null 2>&1; then + echo "::error::Version $NEW is already reserved by a release with different inputs. Aborting." + exit 1 + fi + + # An explicit --set-version equal to the value already on disk is a no-op, not a + # failure: a previous run bumped the file but never reached the reservation. This + # runs OUTSIDE a condition under `set -e`, so a non-zero exit here would kill the + # step and make the recovery path below unreachable. + python3 scripts/next-workload-version.py --apply --set-version "$NEW" --verbose + cd .. + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add workload/build/Versions.props + if git diff --cached --quiet; then + echo "::notice ::TizenWorkloadVersion is already $NEW; reserving the current commit." + else + git commit -m "chore: bump TizenWorkloadVersion to ${NEW}" + fi + RELEASE_SHA="$(git rev-parse HEAD)" + + # The reservation carries the complete release input set, so a later run can prove + # it is entitled to continue this exact release and cannot adopt it for a different + # SDK/band/branch. + PAYLOAD="$(python3 workload/scripts/release-reservation.py encode \ + --version "$NEW" --sha "$RELEASE_SHA" --sdk "$SDK" --band "$BAND" \ + --manifest-id "$MANIFEST_ID" --packages "$PACKAGES" --branch "$BRANCH")" + git tag -f -a "tizen-reserve-$NEW" -m "tizen-release reservation v$NEW + + $PAYLOAD" "$RELEASE_SHA" + + # Atomic compare-and-swap CREATE: an empty expected value requires the ref to not + # exist yet, so if two runs race exactly one wins and the loser aborts here rather + # than publishing over someone else's version. + if ! git push origin "refs/tags/tizen-reserve-$NEW:refs/tizen-release/v$NEW" \ + --force-with-lease="refs/tizen-release/v$NEW:"; then + echo "::error::Failed to reserve $NEW (another release won the race). Aborting." + exit 1 + fi + git push origin "HEAD:${GITHUB_REF_NAME}" || true + echo "workload_version=$NEW" >> "$GITHUB_OUTPUT" - echo "::notice ::Bumped TizenWorkloadVersion: $OLD -> $NEW" + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + echo "resumed=false" >> "$GITHUB_OUTPUT" + echo "::notice ::Reserved and bumped TizenWorkloadVersion: $OLD -> $NEW at $RELEASE_SHA" + + # Build from the RESERVED commit, never from whatever the branch or the re-run event + # happens to point at. + - name: Check out the reserved release commit + run: | + set -euo pipefail + SHA="${{ steps.bump.outputs.release_sha }}" + if [ -z "$SHA" ]; then + echo "::error::No release SHA was resolved." + exit 1 + fi + git fetch origin "$SHA" --depth=1 2>/dev/null || git fetch origin + git checkout --detach "$SHA" + ACTUAL="$(git rev-parse HEAD)" + if [ "$ACTUAL" != "$SHA" ]; then + echo "::error::Checked out $ACTUAL but expected $SHA." + exit 1 + fi + # The released sources must carry the released version. + VER="${{ steps.bump.outputs.workload_version }}" + ON_DISK=$(grep -oP '(?<=)[^<]+(?=)' workload/build/Versions.props) + if [ "${{ github.event.inputs.release_manifest }}" = "true" ] && [ "$ON_DISK" != "$VER" ]; then + echo "::error::Source drift: $SHA carries $ON_DISK but this release is $VER." + exit 1 + fi + echo "Building from $SHA (TizenWorkloadVersion=$ON_DISK)" - name: Install Wix toolset run: sudo apt-get install -y wixl + # A release must never publish a half-built or stale set of packages. The tree is + # cleaned first so the output directory can only contain this run's artifacts, and + # the build is NOT continue-on-error: a failed build must abort the release. + - name: Clean previous output + run: make clean + working-directory: ./workload + - name: Build env: PRERELEASE_TAG: "stable" run: make install -d DOTNET_VERSION=${{ github.event.inputs.net_sdk_version }} working-directory: ./workload - continue-on-error: true - - name: Push Manifest/SDK/Runtime packs + # Stage into an isolated directory and verify the expected artifacts exist, so the + # push steps operate on an explicit, audited list rather than a glob over whatever + # happens to be on disk. + - name: Stage and verify packages + id: stage + run: | + set -euo pipefail + STAGING="$RUNNER_TEMP/staging" + rm -rf "$STAGING" && mkdir -p "$STAGING" + shopt -s nullglob + pkgs=(./workload/out/nuget-unsigned/*.nupkg) + if [ ${#pkgs[@]} -eq 0 ]; then + echo "::error::Build produced no packages." + exit 1 + fi + cp "${pkgs[@]}" "$STAGING/" + # Single source: the band the resolve step already computed (via the installer's + # own function). Recomputing it here would be a second place to drift. + BAND="${{ steps.bump.outputs.band }}" + if [ -z "$BAND" ]; then + echo "::error::Resolve step did not publish a band output." + exit 1 + fi + echo "Resolved SDK feature band: $BAND" + if ! ls "$STAGING/Samsung.NET.Sdk.Tizen.Manifest-$BAND."*.nupkg >/dev/null 2>&1; then + echo "::error::No Samsung.NET.Sdk.Tizen.Manifest-$BAND package was produced." + ls -1 "$STAGING" + exit 1 + fi + MANIFEST_PKG="$(basename "$(ls "$STAGING/Samsung.NET.Sdk.Tizen.Manifest-$BAND."*.nupkg | head -1)")" + echo "staging=$STAGING" >> "$GITHUB_OUTPUT" + echo "band=$BAND" >> "$GITHUB_OUTPUT" + echo "manifest_id=Samsung.NET.Sdk.Tizen.Manifest-$BAND" >> "$GITHUB_OUTPUT" + echo "manifest_pkg=$MANIFEST_PKG" >> "$GITHUB_OUTPUT" + # Every package embeds +sha. (see Directory.Build.targets). They must all + # carry the SAME commit, and it must be the reserved release commit - otherwise a + # resumed run could publish a pack built from a different tree alongside packs + # already on the feed, and --skip-duplicate would happily preserve the mixture. + EXPECTED_SHA="$(git rev-parse --short "${{ steps.bump.outputs.release_sha }}")" + BAD=0 + for pkg in "$STAGING"/*.nupkg; do + name="$(basename "$pkg")" + # Ref packs are versioned by TizenFX, not the workload version; skip them. + case "$name" in Samsung.Tizen.Ref.*) continue ;; esac + if [[ "$name" != *"+sha."* && "$name" != *"sha"* ]]; then + # Version metadata is stripped from the file name by NuGet; read the nuspec. + embedded="$(unzip -p "$pkg" '*.nuspec' 2>/dev/null | grep -oE '[^<]+' | sed 's///' | head -1)" + else + embedded="$name" + fi + case "$embedded" in + *"+sha.$EXPECTED_SHA"*) ;; + *) + echo "::error::$name embeds '$embedded', expected +sha.$EXPECTED_SHA" + BAD=1 + ;; + esac + done + if [ $BAD -ne 0 ]; then + echo "::error::Staged packages do not all originate from the reserved release commit." + exit 1 + fi + echo "All staged packages carry +sha.$EXPECTED_SHA" + echo "Staged packages:"; ls -1 "$STAGING" + + # Publish every versioned pack. `--skip-duplicate` makes each push idempotent: NuGet + # packages are immutable, so an id+version that already exists IS the intended artifact + # and is simply skipped. That is what allows a partially published version to be resumed + # by re-running with the same version. + # + # The pushes are ordered so the MANIFEST GOES LAST. The manifest is what + # next-workload-version.py keys off, so publishing it only after its companion packs + # means an interruption cannot leave a version that looks advanceable while its packs are + # missing. Combined with the resume detection above, an interrupted release re-runs + # cleanly from any point. + - name: Push SDK/Runtime/Templates packs if: ${{ github.event.inputs.release_manifest == 'true' }} run: | - echo "Pushing Manifest packs for version ${{ github.event.inputs.net_sdk_version }}"... - dotnet nuget push ./workload/out/nuget-unsigned/Samsung.NET.Sdk.Tizen.Manifest-*.nupkg \ - -k ${{ secrets.NUGET_APIKEY }} \ - -s https://api.nuget.org/v3/index.json \ - -t 3000 \ - --skip-duplicate - dotnet nuget push ./workload/out/nuget-unsigned/Samsung.Tizen.Sdk.*.nupkg \ - -k ${{ secrets.NUGET_APIKEY }} \ - -s https://api.nuget.org/v3/index.json \ - -t 3000 \ - --skip-duplicate - dotnet nuget push ./workload/out/nuget-unsigned/Samsung.NETCore.App.Runtime.*.nupkg \ - -k ${{ secrets.NUGET_APIKEY }} \ - -s https://api.nuget.org/v3/index.json \ - -t 3000 \ - --skip-duplicate - dotnet nuget push ./workload/out/nuget-unsigned/Samsung.Tizen.Templates.*.nupkg \ + set -euo pipefail + VER="${{ steps.bump.outputs.workload_version }}" + echo "Publishing companion packs for workload version $VER..." + for pattern in Samsung.Tizen.Sdk Samsung.NETCore.App.Runtime Samsung.Tizen.Templates; do + dotnet nuget push ${{ steps.stage.outputs.staging }}/$pattern.*.nupkg \ + -k ${{ secrets.NUGET_APIKEY }} \ + -s https://api.nuget.org/v3/index.json \ + -t 3000 \ + --skip-duplicate + done + + - name: Push Manifest pack + if: ${{ github.event.inputs.release_manifest == 'true' }} + run: | + set -euo pipefail + echo "Publishing manifest for band ${{ steps.bump.outputs.band }}..." + dotnet nuget push ${{ steps.stage.outputs.staging }}/Samsung.NET.Sdk.Tizen.Manifest-*.nupkg \ -k ${{ secrets.NUGET_APIKEY }} \ -s https://api.nuget.org/v3/index.json \ -t 3000 \ --skip-duplicate + # Confirm the full expected set is actually on the feed before tagging, so a release is + # never created for an incomplete publication. + - name: Verify publication is complete + if: ${{ github.event.inputs.release_manifest == 'true' }} + working-directory: ./workload + run: | + set -euo pipefail + VER="${{ steps.bump.outputs.workload_version }}" + BAND="${{ steps.bump.outputs.band }}" + for attempt in 1 2 3 4 5; do + OUT="$(python3 scripts/release-state.py --version "$VER" --band "$BAND")" + STATE="$(echo "$OUT" | sed -n 's/^state=//p')" + [ "$STATE" = "published" ] && { echo "All expected packages present for $VER."; exit 0; } + echo "Attempt $attempt: state=$STATE ($(echo "$OUT" | sed -n 's/^missing=//p')); feed may lag, retrying..." + sleep 30 + done + echo "::error::Publication incomplete for $VER; not tagging. Re-run this workflow to resume." + exit 1 + - name: Push Ref pack if: ${{ github.event.inputs.release_reference == 'true' }} run: | echo "Pushing Manifest packs for version ${{ github.event.inputs.net_sdk_version }}"... - dotnet nuget push ./workload/out/nuget-unsigned/Samsung.Tizen.Ref.*.nupkg \ + dotnet nuget push ${{ steps.stage.outputs.staging }}/Samsung.Tizen.Ref.*.nupkg \ -k ${{ secrets.NUGET_APIKEY }} \ -s https://api.nuget.org/v3/index.json \ -t 3000 \ --skip-duplicate + # Idempotent: a resumed run whose tag already exists must not fail the whole release. - name: Create GitHub Release if: ${{ github.event.inputs.release_manifest == 'true' }} env: @@ -106,14 +388,19 @@ jobs: run: | VER="${{ steps.bump.outputs.workload_version }}" SDK="${{ github.event.inputs.net_sdk_version }}" - BAND="${SDK%%-*}" + # Canonical feature band and manifest id, as resolved and VERIFIED against the + # staged artifacts. Do not recompute here: '${SDK%%-*}' yields '11.0.100' for + # 11.0.100-preview.7.* and '10.0.404' for a servicing band, linking to packages + # that were never published. + BAND="${{ steps.stage.outputs.band }}" + MANIFEST_ID="${{ steps.stage.outputs.manifest_id }}" MAJOR_MINOR="$(echo "$BAND" | cut -d. -f1-2)" { echo "### Target .NET SDK: .NET ${SDK}" echo "" echo "## NuGet Packages" echo "" - echo "- https://www.nuget.org/packages/Samsung.NET.Sdk.Tizen.Manifest-${BAND}/${VER}" + echo "- https://www.nuget.org/packages/${MANIFEST_ID}/${VER}" echo "- https://www.nuget.org/packages/Samsung.NETCore.App.Runtime.tizen/${VER}" echo "- https://www.nuget.org/packages/Samsung.Tizen.Sdk/${VER}" echo "- https://www.nuget.org/packages/Samsung.Tizen.Templates/${VER}" @@ -122,8 +409,28 @@ jobs: [ -n "$v" ] && echo "- https://www.nuget.org/packages/Samsung.Tizen.Ref.API${api}/${v}" done } > "$RUNNER_TEMP/release-notes.md" + SHA="${{ steps.bump.outputs.release_sha }}" + if gh release view "v${VER}" >/dev/null 2>&1; then + # An existing tag is only the intended artifact if it points at the reserved + # commit. Blindly treating "the tag exists" as done would bless a release whose + # tag targets a different tree than the packages that were published - exactly + # the mixed-provenance state the reservation exists to prevent. + git fetch origin "refs/tags/v${VER}:refs/tags/v${VER}" --force >/dev/null 2>&1 || true + TAG_SHA="$(git rev-parse "refs/tags/v${VER}^{commit}" 2>/dev/null || echo "")" + if [ -z "$TAG_SHA" ]; then + echo "::error::Release v${VER} exists but its tag could not be resolved to a commit." + exit 1 + fi + if [ "$TAG_SHA" != "$SHA" ]; then + echo "::error::Release v${VER} is tagged at $TAG_SHA but this version was reserved at $SHA." + echo "::error::Refusing to accept a release whose tag does not match the reserved release commit." + exit 1 + fi + echo "::notice ::Release v${VER} already exists at the reserved commit $SHA; nothing to do." + exit 0 + fi gh release create "v${VER}" \ - --target "${GITHUB_REF_NAME}" \ + --target "$SHA" \ --title "Tizen Workload ${VER} - .Net ${MAJOR_MINOR}" \ --notes-file "$RUNNER_TEMP/release-notes.md" \ --generate-notes diff --git a/.github/workflows/validate-version-map.yml b/.github/workflows/validate-version-map.yml index 2373ed29d..df47d9e58 100644 --- a/.github/workflows/validate-version-map.yml +++ b/.github/workflows/validate-version-map.yml @@ -10,7 +10,7 @@ name: Validate Version Map on: push: - branches: [ main, net7.0, net8.0, net9.0, net10.0 ] + branches: [ main, net7.0, net8.0, net9.0, net10.0, net11.0 ] paths: - 'workload/scripts/version-map.json' - 'workload/scripts/workload-install.sh' diff --git a/workload/Config.mk b/workload/Config.mk index bf0ba66d4..237e4bc8a 100644 --- a/workload/Config.mk +++ b/workload/Config.mk @@ -1,12 +1,13 @@ # DOTNET_VERSION --include $(TMPDIR)/dotnet-version.config -$(TMPDIR)/dotnet-version.config: $(TOP)/build/Versions.props -ifeq ($(DOTNET_VERSION), ) - @mkdir -p $(TMPDIR) - @grep "" build/Versions.props | sed -e 's/<\/*MicrosoftDotnetSdkInternalPackageVersion>//g' -e 's/[ \t]*/DOTNET_VERSION=/' > $@ -else - @mkdir -p $(TMPDIR) - @echo "DOTNET_VERSION=$(DOTNET_VERSION)" > $@ +# +# Resolved immediately from the caller or, when unset, from Versions.props. +# +# This used to be cached in $(TMPDIR)/dotnet-version.config with Versions.props as its only +# prerequisite. Because the cache file was then newer than Versions.props, make never +# regenerated it, so a DIFFERENT DOTNET_VERSION passed into an existing tree was ignored and +# the previous band's value was reused - silently building/testing the wrong band. +ifeq ($(strip $(DOTNET_VERSION)),) +DOTNET_VERSION := $(shell grep -oE '[^<]+' $(TOP)/build/Versions.props | sed 's/.*>//') endif # TizenFX API versions per API level — auto-extracted from Versions.props (SSOT) @@ -18,6 +19,13 @@ $(TMPDIR)/tizen-fx-api-versions.config: $(TOP)/build/Versions.props $(info DOTNET_VERSION is.. $(DOTNET_VERSION)) +# NOTE: do not add a parse-time guard for an empty DOTNET_VERSION here. The value arrives +# via `-include $(TMPDIR)/dotnet-version.config`, and on make's first parse pass (before it +# regenerates that file and restarts) DOTNET_VERSION is legitimately empty. +# Pass DOTNET_VERSION through the environment or omit it entirely; an explicit empty +# command-line override (make DOTNET_VERSION=) wins over the generated file and yields an +# empty band. + DOTNET_VERSION_BAND = $(firstword $(subst -, ,$(DOTNET_VERSION))) IS_PRERELEASE=$(findstring -,$(DOTNET_VERSION)) @@ -30,31 +38,36 @@ endif MAJOR = $(word 1,$(VERSIONS)) MINOR = $(word 2,$(VERSIONS)) MICRO = $(word 3,$(VERSIONS)) -BAND := $(shell echo "${MICRO}" | cut -c1)00 +# Feature band: the patch component rounded down to the nearest hundred (404 -> 400). +BAND = $(shell echo "$(MICRO)" | cut -c1)00 PRERELEASE = $(word 4,$(VERSIONS)) PRERELEASE_VERSION = $(word 5,$(VERSIONS)) # DOTNET_DESTDIR ifeq ($(DESTDIR),) - DOTNET_DESTDIR = $(OUTDIR)/dotnet + # Keyed by the FULL SDK version, not the feature band. Band-keying meant 10.0.100 and + # 10.0.101 (or two different previews of one band) shared a bootstrap directory, so the + # second request silently reused the first SDK and tested the wrong build. Manifest + # paths stay band-based below, which is what the SDK itself expects. + DOTNET_DESTDIR = $(OUTDIR)/dotnet-$(DOTNET_VERSION) else DOTNET_DESTDIR = $(abspath $(DESTDIR)) endif ifeq ($(MAJOR),6) - DOTNET6_MANIFESTS_DESTDIR := $(MAJOR).$(MINOR).$(BAND) - DOTNET_MANIFESTS_DESTDIR := $(DOTNET_DESTDIR)/sdk-manifests/$(DOTNET6_MANIFESTS_DESTDIR)/samsung.net.sdk.tizen DOTNET_VERSION_BAND := $(MAJOR).$(MINOR).$(BAND) + DOTNET6_MANIFESTS_DESTDIR := $(MAJOR).$(MINOR).$(BAND) + DOTNET_MANIFESTS_DESTDIR = $(DOTNET_DESTDIR)/sdk-manifests/$(DOTNET6_MANIFESTS_DESTDIR)/samsung.net.sdk.tizen else ifneq ($(IS_PRERELEASE),) ifneq ($(IS_RTM),) - DOTNET_VERSION_BAND := $(MAJOR).$(MINOR).$(MICRO)-$(PRERELEASE) + DOTNET_VERSION_BAND := $(MAJOR).$(MINOR).$(BAND)-$(PRERELEASE) else - DOTNET_VERSION_BAND := $(MAJOR).$(MINOR).$(MICRO)-$(PRERELEASE).$(PRERELEASE_VERSION) + DOTNET_VERSION_BAND := $(MAJOR).$(MINOR).$(BAND)-$(PRERELEASE).$(PRERELEASE_VERSION) endif else - DOTNET_VERSION_BAND := $(MAJOR).$(MINOR).$(MICRO) + DOTNET_VERSION_BAND := $(MAJOR).$(MINOR).$(BAND) endif DOTNET_MANIFESTS_DESTDIR = $(DOTNET_DESTDIR)/sdk-manifests/$(DOTNET_VERSION_BAND)/samsung.net.sdk.tizen endif diff --git a/workload/Makefile b/workload/Makefile index c3b5843e7..bb2b46a75 100644 --- a/workload/Makefile +++ b/workload/Makefile @@ -59,7 +59,9 @@ $(eval $(call CreateNuGetPkgs,Samsung.NETCore.App.Runtime,$(TIZEN_WORKLOAD_VERSI packs: $(NUPKG_TARGETS) # Install workload to the dotnet sdk -$(TMPDIR)/.stamp-install-workload: | $(DOTNET_MANIFESTS_DESTDIR) +INSTALL_STAMP = $(TMPDIR)/.stamp-install-workload-$(DOTNET_VERSION) + +$(INSTALL_STAMP): | $(DOTNET_MANIFESTS_DESTDIR) @cp -f \ $(TOP)/LICENSE \ $(TOP)/src/Samsung.NET.Sdk.Tizen/WorkloadManifest.targets \ @@ -70,14 +72,14 @@ $(TMPDIR)/.stamp-install-workload: | $(DOTNET_MANIFESTS_DESTDIR) @touch $@ .PHONY: install -install: packs $(TMPDIR)/.stamp-install-workload +install: packs $(INSTALL_STAMP) # Uninstall workload from the dotnet sdk .PHONY: uninstall uninstall: @$(DOTNET) workload uninstall tizen - @rm -f $(TMPDIR)/.stamp-install-workload + @rm -f $(INSTALL_STAMP) # Create MSI windows installer define CreateMsi @@ -99,9 +101,7 @@ $(TMPDIR)/msi: install @cp -fr $(DOTNET_MANIFESTS_DESTDIR) $@/sdk-manifests/$(DOTNET_VERSION_BAND) @mkdir -p $@/packs @cp -fr $(DOTNET_DESTDIR)/packs/Samsung.Tizen.Sdk $@/packs - @cp -fr $(DOTNET_DESTDIR)/packs/Samsung.Tizen.Ref.API11 $@/packs - @cp -fr $(DOTNET_DESTDIR)/packs/Samsung.Tizen.Ref.API12 $@/packs - @cp -fr $(DOTNET_DESTDIR)/packs/Samsung.Tizen.Ref.API13 $@/packs + @cp -fr $(DOTNET_DESTDIR)/packs/Samsung.Tizen.Ref.API* $@/packs @cp -fr $(DOTNET_DESTDIR)/packs/Samsung.NETCore.App.Runtime.* $@/packs @mkdir -p $@/template-packs @cp -f $(DOTNET_DESTDIR)/template-packs/samsung.tizen.templates.*.nupkg $@/template-packs @@ -141,6 +141,62 @@ test-matrix: install bash $(TOP)/scripts/test-matrix.sh +# Static checks that need no dotnet install: cross-file metadata consistency and +# the SDK feature-band detection shared by both install scripts. +.PHONY: validate-metadata +validate-metadata: + @python3 $(TOP)/scripts/validate-workload-metadata.py + +.PHONY: test-version-band +test-version-band: + @bash $(TOP)/scripts/test-version-band.sh + +.PHONY: test-matrix-self-test +test-matrix-self-test: + @bash $(TOP)/scripts/test-matrix.sh --self-test + +.PHONY: test-template-conditions +test-template-conditions: + @bash $(TOP)/scripts/test-template-conditions.sh + +.PHONY: test-package-fallback +test-package-fallback: + @bash $(TOP)/scripts/test-package-fallback.sh + +.PHONY: test-release-workflow +test-release-workflow: + @bash $(TOP)/scripts/test-release-workflow.sh + +.PHONY: test-install-failure +test-install-failure: + @bash $(TOP)/scripts/test-install-failure.sh + +.PHONY: check +check: validate-metadata test-matrix-self-test test-version-band test-template-conditions test-package-fallback test-release-workflow test-install-failure + @if command -v pwsh >/dev/null 2>&1; then \ + pwsh $(TOP)/scripts/Generate-InstallScripts.ps1 -Check; \ + else \ + echo "ERROR: pwsh not found. It is required to verify version-map drift and"; \ + echo " install-script integrity. Install PowerShell 7+ (https://aka.ms/powershell)"; \ + echo " or run: make check SKIP_PWSH_CHECKS=1 (leaves those checks unverified)."; \ + [ -n "$(SKIP_PWSH_CHECKS)" ]; \ + fi + +# Print the feature band Config.mk derives for DOTNET_VERSION. Used by +# scripts/test-version-band.sh to prove the producer agrees with the installers. +.PHONY: print-version-band +print-version-band: + @echo $(DOTNET_VERSION_BAND) + +.PHONY: print-dotnet-destdir +print-dotnet-destdir: + @echo $(DOTNET_DESTDIR) + +.PHONY: print-install-stamp +print-install-stamp: + @echo $(INSTALL_STAMP) + + # Remove artifacts and temporary files clean: @rm -fr $(OUTDIR) diff --git a/workload/NuGet.config b/workload/NuGet.config index efc78a639..f4dd8ea2e 100644 --- a/workload/NuGet.config +++ b/workload/NuGet.config @@ -10,6 +10,7 @@ + @@ -20,5 +21,10 @@ --> - + + + + diff --git a/workload/README.md b/workload/README.md index 850eec71d..e969c3bd4 100644 --- a/workload/README.md +++ b/workload/README.md @@ -1,6 +1,17 @@ # Workload for Tizen .NET -This is a build of Tizen workload for an early preview of Tizen in .NET 10. - +This is a build of Tizen workload for Tizen in .NET 10, and for the .NET 11 preview SDK band. + +See [docs/net11.md](docs/net11.md) for the .NET 11 target framework / API-level mapping, +how to build that band, and the external artifacts it is still blocked on. + +## Local checks + +```sh +make check # metadata consistency + version-band tests + install-script drift +make test # single-TFM smoke test (needs a bootstrapped SDK) +make test-matrix # full TFM matrix +``` + ## Using IDEs Refer [here](https://github.com/dotnet/net6-mobile-samples#using-ides) to see the supporting status of an each IDE and how to manually enable workload. diff --git a/workload/build/Versions.props b/workload/build/Versions.props index 79e761b67..c6d4f5cc3 100644 --- a/workload/build/Versions.props +++ b/workload/build/Versions.props @@ -54,4 +54,21 @@ 10.0.0-beta.25531.102 + + + + 11.0.100-preview.7.26381.103 + + + + 11.0.0-beta.26426.103 + diff --git a/workload/docs/net11.md b/workload/docs/net11.md new file mode 100644 index 000000000..80e199cbc --- /dev/null +++ b/workload/docs/net11.md @@ -0,0 +1,534 @@ +# .NET 11 support + +This branch builds the `tizen` workload for the .NET 11 SDK band in addition to .NET 10. + +At the time of writing .NET 11 is in **preview**: the newest SDK is +`11.0.100-preview.7.26381.103` (released 2026-08-11). Everything below therefore describes a +band that is real and buildable, but whose manifest package has **not been published to +nuget.org yet**. + +## TFM and API mapping + +The primary target framework is **`net11.0-tizen11.0`**. + +| Tizen platform version | TizenFX API level | Targeting (ref) pack | Pack version | +|---|---|---|---| +| `tizen8.0` | 11 | `Samsung.Tizen.Ref.API11` | `$(TizenFXAPI11Version)` | +| `tizen9.0` | 12 | `Samsung.Tizen.Ref.API12` | `$(TizenFXAPI12Version)` | +| `tizen10.0` | 13 | `Samsung.Tizen.Ref.API13` | `$(TizenFXAPI13Version)` | +| `tizen10.1` | 14 | `Samsung.Tizen.Ref.API14` | `$(TizenFXAPI14Version)` | +| **`tizen11.0`** | **15** | **`Samsung.Tizen.Ref.API15`** | `$(TizenFXAPI15Version)` | + +The .NET version axis and the Tizen platform axis are independent, so `net11.0` combines with +every platform version above — `net11.0-tizen8.0` … `net11.0-tizen11.0` are all valid. The +`tizen-manifest.xml` `api-version` attribute must match the platform version +(`tizen11.0` → `api-version="11"`, `tizen10.1` → `10.1`, `tizen10.0` → `10`). + +**No new reference pack is required for .NET 11.** Ref packs ship reference assemblies under +`ref/net8.0/` and are resolved by explicit `` entries in `data/FrameworkList.xml`, +so they are independent of the consuming project's .NET version. `Samsung.Tizen.Ref.API16` does +not exist and is not needed. + +Use versioned TFMs. The unversioned `net11.0-tizen` form is still accepted as project input but +resolves to `_DefaultTargetPlatformVersion` (`10.0`), which is rarely what a caller wants. + +## Building the .NET 11 band + +`DOTNET_VERSION` selects the SDK band; it defaults to +`MicrosoftDotnetSdkInternalPackageVersion` in `build/Versions.props` (currently the .NET 10 band). + +```sh +# Produce the packs, including Samsung.NET.Sdk.Tizen.Manifest-11.0.100-preview.7 +make packs DOTNET_VERSION=11.0.100-preview.7.26381.103 + +# Install into the locally bootstrapped SDK and run the full TFM matrix +make test-matrix DOTNET_VERSION=11.0.100-preview.7.26381.103 +``` + +The exact SDK version CI uses lives in `build/Versions.props` as ``. +That property is the single source of truth: the workflows grep it, so bumping the preview +there is enough. `validate-workload-metadata.py` check C7 fails if a workflow hardcodes a +different `11.0.1xx-*` version. + +The band string is derived generically from `DOTNET_VERSION`, so `11.0.100-rc.1.*` and the +eventual `11.0.100` GA need no further code change. This is asserted by +[`scripts/test-version-band.sh`](../scripts/test-version-band.sh). + +## CI behaviour + +| Branch / PR target | .NET 10 matrix leg | .NET 11 matrix leg | `Build Workload` SDK | +|---|---|---|---| +| `main`, `net10.0` | blocking | advisory (`continue-on-error`) | Versions.props default (.NET 10) | +| `net11.0` | blocking | **blocking** | **`$(DotNet11SdkVersion)`** | + +.NET 11 is advisory only while the branch ships a different band. On a `net11.0` branch +.NET 11 *is* the product, so both the matrix leg and the workload build switch to it. + +## Local checks + +`make check` runs everything that needs no dotnet install or Tizen workload: + +| Target | What it pins | +|---|---| +| `validate-metadata` | C1–C8 cross-file consistency | +| `test-matrix-self-test` | matrix row selection (an over-strict check silently builds nothing) | +| `test-version-band` | SDK version → feature band across **both installers and Config.mk**, fallback band family, band isolation | +| `test-template-conditions` | template platform detection across TFMs | +| `test-package-fallback` | fallback filtering, plus selection priority / atomicity in both directory-creation orders | +| `test-release-workflow` | release ordering, retryability, partial-publication resume, failure injection | +| `test-install-failure` | installer exit codes, fallback package **id**, SDK-pin verification, empty/transport responses | +| `Generate-InstallScripts.ps1 -Check` | version-map drift **and** install-script integrity | + +`make check` requires `pwsh` for the last row. If it is unavailable the target fails with an +actionable message; `make check SKIP_PWSH_CHECKS=1` proceeds with those checks unverified. + +## Band isolation and feature-band rounding + +`DOTNET_VERSION` is resolved immediately in `Config.mk`, and `DOTNET_DESTDIR` plus the install +stamp are scoped by band (`out/dotnet-`, `.stamp-install-workload-`). Two +consequences worth knowing: + +- Building a different band in an existing tree bootstraps a separate SDK instead of reusing + the previous one. Previously `DOTNET_VERSION` was cached in `.tmp/dotnet-version.config` + whose only prerequisite was `Versions.props`; because the cache was newer, make never + regenerated it and a newly-passed `DOTNET_VERSION` was **ignored**, silently building and + testing the previous band. +- Feature bands round the patch component down: `10.0.404` → `10.0.400`, `9.0.304` → `9.0.300`. + `Config.mk` previously did not round for stable non-6 versions, so it produced band + `10.0.404` while the installers looked for `10.0.400` — the manifest was installed where the + SDK would never look. `test-version-band.sh` now asserts producer/consumer agreement. + +Pass `DOTNET_VERSION` via the environment or omit it. An explicit empty command-line override +(`make DOTNET_VERSION=`) beats the resolved value and yields an empty band. + +## Manifest fallback safety + +When a band's manifest is not published, the installers fall back to the cached version map. +Two properties are load-bearing: + +- **The resolved package ID travels with the version.** `getLatestVersion` / + `Get-LatestVersion` return `"="`. Returning only a version made the + caller download that version under the *originally requested* — and unpublished — id: a + request for `...manifest-10.0.400` resolves to version `10.0.127`, which exists only under + `...manifest-10.0.300`, so the download 404'd. The manifest is still installed into the + **requested** band's directory; only the package fetched differs. +- **The fallback is constrained to the same .NET major.minor family**, and the resolved id is + function-local. The PowerShell installer previously took a fixed-length prefix + (`$ManifestBaseName.Length + 2`), so `...Manifest-11.0.100-preview.7` was truncated to + `...Manifest-1`, matched the 10.x entries and installed a **.NET 10 manifest into an 11.x + band**. It also cached the resolved id in a script-level `$global:FallbackId` that was never + cleared, so an `-UpdateAllWorkloads` run could carry one SDK's fallback package into the + next SDK's install. Both are fixed and pinned by `scripts/test-install-failure.sh`. + +An 11.x request with no 11.x map entry fails closed. + +A manifest version is required unconditionally, whichever branch produced it. An explicit +`-Version ""` (or whitespace) does not equal `""`, so it used to bypass resolution and +validation entirely, remove the installed manifest, and construct a **versionless** NuGet URL — +and the v2 package endpoint serves the **latest** package for such a URL. The gate now runs +before any removal, URL construction or download. + +The resolved version is validated before use. An empty or all-blank `versions[]` from the feed +is rejected rather than returned as a truthy `"="`: the NuGet v2 package endpoint serves the +**latest** version when given a versionless URL, so an empty version would silently install an +arbitrary package. Both installers fall through to the version map and then fail closed. + +The SDK pin is verified before anything is installed. `install_tizenworkload` is invoked under +`if !`, which disables `errexit` for everything it calls, so an unchecked +`dotnet new globaljson` previously let the install proceed against whatever SDK `PATH` resolved. +The pin now uses the dotnet under test, its exit status is checked, and the **effective** +`dotnet --version` and feature band are re-verified against the requested ones before any pack +is installed. + +Note the installer must run under **bash 3.2** (macOS ships it and is a supported target, see +`DOTNET_DEFAULT_PATH_MACOS`). The `${var,,}` lowercase expansion is bash 4+ and raised +`bad substitution` there, leaving the version empty and silently skipping the fallback +entirely; a portable `tr` is used instead, and an empty lookup response now takes the same +fallback path as an explicit `BlobNotFound`. + +## Package asset resolution + +`PackageTargetFallback` in `Samsung.Tizen.Sdk.NuGet.targets` lists the package `lib//` +folder names that `FixupNuGetReferences` prefers over a package's `netstandard2.x` assets. + +The task matches those directories **by name only** and performs no compatibility check of its +own, so the list must be filtered to what the project can actually consume. An unfiltered +cross-product lets a `net6.0-tizen8.0` build pick up `net6.0-tizen11.0` (newer platform) or +`net11.0-tizen8.0` (newer .NET) assets. A candidate is emitted only when its .NET version and +its Tizen platform version are both `<=` the project's, and candidates are appended +highest-first so the best compatible match wins the task's first-wins selection. + +The filter is built from conditional **properties**, not filtered items: MSBuild evaluates all +top-level properties before any items, so a property referencing `@(item)` at that level +silently expands to nothing. + +`PackageTargetFallback` is an **ordered preference list**, and `FixupNuGetReferences` honours +that order: it ranks candidates by their position in the list and selects exactly **one** +fallback TFM per package, taking every substituted assembly from that single directory. It +previously collected all matching directories into an unordered `HashSet` populated in +filesystem-enumeration order and then took assemblies first-wins across them, which could both +ignore the declared priority and mix assemblies from different TFMs within one package. +`scripts/test-package-fallback.sh` builds the candidate directories in **both** creation orders +so the assertion does not depend on how a particular filesystem enumerates. + +Check C8 verifies the candidate list covers the full (.NET major × platform) cross-product +*and* that every candidate carries a compatibility condition; +`scripts/test-package-fallback.sh` pins the filtering itself with negative +cross-platform/cross-version assertions. + +## What changed in this repository + +| File | Change | +|---|---| +| `build/Versions.props` | `MicrosoftDotNetBuildTasksFeedPackageVersion` = `11.0.0-beta.26426.103` when building an `11.0` band | +| `NuGet.config` | added the `dotnet11` package source | +| `src/Samsung.Tizen.Sdk/targets/Samsung.Tizen.Sdk.targets` | added the `net11.0` `KnownRuntimePack` | +| `src/Samsung.NETCore.App.Runtime/data/RuntimeList.xml` | added the `.NET Runtime 11` `FileList` row | +| `src/Samsung.Tizen.Templates/.../template.json` | added the `net11.0` framework choice (default stays `net10.0`) | +| `src/Samsung.Tizen.Templates/tizen/TizenApp1.csproj` | Material referenced conditionally; platform version parsed from the TFM, not `$(TargetPlatformVersion)` (see below) | +| `scripts/test-matrix.sh` | added `net11.0-*` rows; rows are skipped only when newer than the installed SDK; `--self-test` mode | +| `scripts/validate-workload-metadata.py` | new checks C5/C6/C7 | +| `scripts/test-version-band.sh` | new — asserts SDK-version → feature-band mapping for both installers | +| `scripts/test-template-conditions.sh` | new — pins template platform detection across TFMs | +| `scripts/test-install-failure.sh` | new — pins installer exit codes | +| `scripts/test-package-fallback.sh` | new — pins `PackageTargetFallback` compatibility filtering | +| `.github/workflows/build-matrix.yml` | .NET 11 leg, advisory off a `net11.0` branch and blocking on one | +| `.github/workflows/build-workload.yml` | builds against `$(DotNet11SdkVersion)` on a `net11.0` branch | +| `.github/workflows/release-workload.yml` | notes reuse the staging step's verified band / manifest id | + +### Template platform detection + +`PackageReference` items are evaluated with the project body, which runs **before** the .NET +SDK infers `$(TargetPlatformVersion)` from the TFM. Reading that property in the body yields an +empty string, so a naive implementation falls back to the default and pulls an incompatible +`Tizen.UI.Components.Material` into e.g. `net11.0-tizen9.0` while never raising `TIZENTMPL001`. +The template therefore parses the platform version out of `$(TargetFramework)` directly, and +re-checks the authoritative `$(TargetPlatformVersion)` inside a target where it is available. +`scripts/test-template-conditions.sh` extracts the shipped `PropertyGroup` and pins the +behaviour across 13 TFMs. + +`version-map.json` is deliberately **not** updated. That table is a fallback cache of *already +published* manifest versions, consulted only when the NuGet lookup fails. Adding an entry for a +band that has never been released would make `workload-install.sh` download a 404. The +`11.0.100-preview.7` entry should be added in a follow-up commit *after* the first release, the +same way `10.0.300` was. + +## External blockers + +These artifacts are owned by other repositories. Nothing in Samsung/Tizen.NET can fix them, and +they are not faked here. + +### 1. `Samsung.NET.Sdk.Tizen.Manifest-11.0.100-preview.7` is unpublished + +- **Owner:** Samsung/Tizen.NET maintainers (this repo's `Release Workload` workflow). +- **Action:** run `Release Workload` with `net_sdk_version = 11.0.100-preview.7.26381.103`. +- **Until then:** `workload-install.sh` on an 11.x SDK finds no manifest. Local development works + via `make install DOTNET_VERSION=11.0.100-preview.7.26381.103`, which installs into the + bootstrapped SDK under `workload/out/dotnet`. + +### 2. `Tizen.UIExtensions.NUI` has no modern assets + +- **Owner:** [Samsung/Tizen.UIExtensions](https://github.com/Samsung/Tizen.UIExtensions). +- **Published state:** `0.9.2` ships `lib/net6.0-tizen7.0/` and `lib/tizen10.0/`. Source on `main` + targets `tizen10.0;net6.0-tizen` against + `Tizen.NET 10.0.0.17508` (API level 10). +- **Note:** NuGet TFM compatibility is *not* the problem — `net6.0-tizen7.0` is consumable from + `net11.0-tizen11.0`. The blocker is the dependency group, which pins + `Microsoft.Maui.Graphics` / `Microsoft.Maui.Graphics.Skia` `6.0.300-rc.3.1336` and + `SkiaSharp.Views 2.88.6`, dragging .NET 6-era MAUI Graphics into any modern MAUI build. +- **Expected artifact:** a `Tizen.UIExtensions.NUI` release with a `lib/net11.0-tizen11.0/` folder + built against `Samsung.Tizen.Ref.API15` and a refreshed `Microsoft.Maui.Graphics*` dependency. +- **API risk, measured:** all types .NET MAUI's Tizen backend needs are present in API 15. Between + API 11 and API 15, `Tizen.NUI.ScrollView`, `Tizen.NUI.ItemView` (and the `Item*`/`Ruler*` + families), `Tizen.NUI.Components.Title`, `Tizen.NUI.Adaptor`, `Tizen.NUI.AutofillContainer`, + `Tizen.NUI.Accessibility.AccessibilityManager`, the `CubeTransition*` effects and the entire + `Tizen.NUI.Wearable` namespace were removed. Ports should use + `Tizen.NUI.Components.ScrollableBase`, which is retained. + +### 3. `Tizen.UI.Components.Material` is platform-gated + +- **Owner:** TizenAPI / Samsung (package `Tizen.UI.Components.Material`). +- **Published state:** `1.0.0-rc.8` ships **only** `lib/net8.0-tizen10.0/`; never went GA. +- **Effect:** consumable from `-tizen10.0`, `-tizen10.1` and `-tizen11.0`, but not from + `-tizen8.0` / `-tizen9.0`. The `dotnet new tizen` template now references it conditionally and + raises `TIZENTMPL001` with an actionable message instead of an opaque restore failure when the + target platform is below `tizen10.0`. +- **Expected artifact:** a release with `lib/` assets for the lower platform bands, or a separate + non-Material template family for them. + +## Notes for release notes +Observations from porting a real consumer (`Samsung/Tizen.UIExtensions`) onto this band: + +- **`net6.0-tizen7.0` is no longer reproducible.** `Samsung.Tizen.Sdk` dropped `7.0` from + `TizenSdkSupportedTargetPlatformVersion` (now `8.0`/`9.0`/`10.0`/`10.1`/`11.0`), so packages + that historically shipped a `net6.0-tizen7.0` asset cannot rebuild one. +- **Unversioned `net6.0-tizen` now resolves to platform 10.0** (`_DefaultTargetPlatformVersion`), + i.e. TizenFX API 13, where all of ElmSharp and `Tizen.NUI.Window.Instance` are `[Obsolete]`. + Consumers building that TFM with `TreatWarningsAsErrors` will break. Use a versioned TFM. +- **`tizen.myget.org` returns HTTP 401 to anonymous clients.** The feed is still referenced as a + push target in `build-workload.yml`'s deploy job. Any documentation or template still pointing + consumers at it for *restore* is dead; worth confirming whether the push target is still wanted. + +## `RuntimeList.xml` and self-contained publishing + +`src/Samsung.NETCore.App.Runtime/data/RuntimeList.xml` previously contained one +`` element per supported .NET version, i.e. **multiple root elements**, which +is not well-formed XML. + +**This file is parsed.** `Microsoft.NET.Build.Tasks` contains the literal `RuntimeList.xml` +alongside the runtime-pack manifest fields it reads (`Managed`, `Native`, `PgoData`, +`Resources`, `AssemblyVersion`, `FileVersion`, `PublicKeyToken`), i.e. +`ResolveRuntimePackAssets` reads it whenever runtime pack assets are resolved — notably for +`SelfContained=true`. An earlier note in this file claimed it was never parsed; that claim was +based on a framework-dependent RID build, which does not reach that task. **It was wrong and is +retracted.** + +Both remediations are applied: + +1. **The file is now well-formed** — a single `` root. The pack is a placeholder that + ships no runtime binaries (its only payload is `lib/net6.0-tizen/_._`), so the list is + legitimately empty. +2. **Explicitly requested self-contained Tizen publishing is rejected up front** with + `TIZENSDK001`, explaining that Tizen applications run against the platform-provided runtime. + Without it, the build failed with the opaque `NETSDK1083: The specified RuntimeIdentifier + 'tizen' is not recognized`. + +The guard (`_TizenErrorOnSelfContained` in `Samsung.Tizen.Sdk.targets`) gates on the SDK's +`_SelfContainedWasSpecified`, **not** on the final `$(SelfContained)` value, and this distinction +is load-bearing. The .NET SDK (`Microsoft.NET.RuntimeIdentifierInference.targets`) still *infers* +`SelfContained=true` from a present `RuntimeIdentifier` on pre-8.0 TFMs, and the Tizen SDK +supplies an implicit RID (`tizen-x86`) for MAUI apps. Gating on the inferred `$(SelfContained)` +therefore mis-classified an ordinary **framework-dependent** `net6.0-tizen` / `net7.0-tizen` build +— exactly what every existing .NET MAUI Tizen consumer does — as self-contained and broke it. +`_SelfContainedWasSpecified` is set by the SDK only when self-contained was *explicitly* requested +(`-p:SelfContained=true`, or `PublishSelfContained=true` during publish, which the SDK copies onto +`SelfContained` before computing `_SelfContainedWasSpecified`), so the guard now fires on explicit +requests only. + +Verified empirically on `11.0.100-preview.7.26381.103` (`dotnet msbuild -nodereuse:false`, +`OutputType=Exe`, implicit RID `tizen-x86`): + +| Build | inferred `SelfContained` | `_SelfContainedWasSpecified` | TIZENSDK001 | +| --- | --- | --- | --- | +| `net6.0-tizen`, framework-dependent | `true` | *(empty)* | **no** (was wrongly yes) | +| `net7.0-tizen`, framework-dependent | `true` | *(empty)* | **no** (was wrongly yes) | +| `net8.0`–`net11.0-tizen*`, framework-dependent | `false` | *(empty)* | no | +| any `-tizen*` with `-p:SelfContained=true` | `true` | `true` | **yes** | +| any `-tizen*` publish `-p:PublishSelfContained=true` | `true` | `true` | **yes** | + +`SkipTizenSelfContainedCheck=true` bypasses the guard for anyone supplying a runtime by other +means. These behaviours are locked down by `test-template-conditions.sh`, which extracts the real +guard `` from the shipped targets and asserts each row above without needing the Tizen +workload. + +**Scope note, stated precisely:** with the guard bypassed *and* the old malformed file restored, +this configuration failed at `NETSDK1083` (RID resolution) *before* reaching +`ResolveRuntimePackAssets`, so a raw `XmlException` could not be reproduced on this SDK. The +malformed file was nevertheless a latent hazard on any path that does reach that task, and both +fixes are correct regardless of which error surfaces first. + +Check C6 now parses the file with a real XML parser and asserts the `TIZENSDK001` guard exists; +`test-matrix.sh` additionally asserts the runtime disposition end to end. + +## Release retryability + +The release workflow **reserves first, then builds**: + +1. resolve the version (no mutation yet) and pin it as one immutable candidate, +2. create the release commit and **reserve** it globally, +3. check out the reserved SHA explicitly and build from *that* commit only, +4. verify every staged package carries the reserved SHA, +5. push packages from the verified staging directory, +6. create the tag, targeting the reserved SHA. + +> **This deliberately reverses the earlier "commit only after a verified build" ordering.** +> That ordering was adopted to keep a failed release retryable, but it cannot hold once a +> resume has to rebuild: the Makefile embeds the current Git SHA in every package, so an +> initial run and its retry produced packages built from *different* commits, and +> skip-duplicate happily preserved that mixed set under a tag pointing at only one of them. +> Retryability now comes from the **reservation**, not from deferring the commit — the +> reserved SHA is the single source of truth that both the first attempt and every retry +> build from, so provenance is identical by construction. + +`OLD == NEW` is not an error. `next-workload-version.py` derives the next version from what is +published on NuGet, so when the branch already carries the intended still-unpublished version +the run resumes it; there is simply nothing to commit, and the current HEAD *is* the release +commit, so it is reserved as-is. Re-creating an existing tag is a no-op. + +### What a reservation records + +A reservation used to be nothing but a ref pointing at a commit. That answers *"is this +version taken"* but not *"is this run allowed to continue it"*, and both gaps were real: + +* **Re-runs could never resume their own release.** `Re-run jobs` checks out the SHA of the + *original* event — the commit *before* the version bump — so the version read from + `Versions.props` was the previous, already-released one. The re-run concluded there was + nothing to resume, recomputed the same candidate, found its own reservation ref and + aborted as though a stranger held it. An interrupted release was permanently unfinishable. +* **Nothing tied a reservation to the inputs it was made for**, so re-dispatching with a + different `net_sdk_version` would adopt a reservation created for another band. + +`refs/tizen-release/v` is therefore an *annotated tag object* whose message carries the +complete release input set — version, reserved SHA, SDK, band, manifest id, package set and +branch (`scripts/release-reservation.py`). Resume is decided by **enumerating** those refs +and matching them against the current run's inputs; the checked-out SHA is no longer used +for that decision at all. A reservation that does not match every input is skipped, and one +that predates the payload format cannot be validated and so is refused rather than trusted. +Two unfinished reservations matching the same inputs is an error, not a guess. + +An existing tag is only accepted as "already done" when it resolves to the reserved commit. +Treating mere existence as done would bless a release whose tag targets a different tree +than the packages that were published. + +### Reservation and concurrency + +Two branches or bands releasing at once could previously select the same next version, both +push, and let skip-duplicate silently accept one owner's packs while the other's were dropped. +Ownership is now explicit: + +* the job takes a repository-wide `concurrency` group (`cancel-in-progress: false`), +* it claims `refs/tizen-release/v` with an atomic create — losing the race aborts, +* a retry must **prove** it owns the reservation (the ref must point at its release SHA); + a foreign reservation aborts rather than publishing over someone else's version. + +Because `workflow_dispatch` re-runs check out the *original* event SHA rather than the pushed +bump commit, every downstream step checks out the reserved SHA explicitly and rejects source +drift instead of silently building the wrong tree. + +A reference-only run (`release_manifest=false`) publishes no manifest and is completely +non-mutating: no bump, no commit, no tag. + +### Resuming a partially published release + +Publication is not atomic: the companion packs, the manifest, the tag and the GitHub release +are separate steps. If one fails partway, version V is *partially* published — and because +`next-workload-version.py` derives the next version from what exists on NuGet, it then answers +V+1. Advancing at that point strands V permanently with no tag, no release and a missing pack +set. + +`scripts/release-state.py` makes that state explicit (`unpublished` / `partial` / `published`), +and the workflow resumes V unless it is provably complete: + +| State of the branch's version | Action | +|---|---| +| `partial` | **always resume V** — never advance | +| `published` but no tag/release | resume V to finish the tag/release | +| `unpublished` and branch already carries it | resume V | +| `published` **and** released | safe to compute V+1 | + +Two supporting properties: + +* **The manifest is published last.** It is what `next-workload-version.py` keys off, so + publishing it only after its companion packs means an interruption cannot leave a version + that looks advanceable while its packs are missing. +* **Every push is idempotent** (`--skip-duplicate`). NuGet packages are immutable, so an + id+version that already exists *is* the intended artifact and is skipped; only missing + artifacts are published. Completeness is re-verified (with retries for feed lag) before the + tag is created, and re-creating an existing tag is a no-op. + +A feed transport failure exits non-zero rather than being read as `unpublished` — otherwise a +network blip would advance the version and strand the release it was meant to protect. + +`scripts/test-release-workflow.sh` pins all of the above, including failure injection at each +publication step. + + +## Installer band selection + +`workload-install.sh` and `workload-install.ps1` must behave identically; `test-version-band.sh` +extracts the real logic from both (never a hand-copy) and compares them case by case. + +**Closest band at or below the request.** When a manifest for the active band is unavailable the +installer falls back — but it must fall back *downwards*. Selecting `10.0.300` for a `10.0.200` +request skips the published `10.0.200` manifest entirely and installs a newer feature band's +metadata. Both installers now order candidates with a numeric band sort key and choose the +closest compatible band `<= ` the request, and the fallback returns the resolved **package ID +and version together** — resolving only a version left the download using the original, +unavailable manifest ID. + +**Explicit vs. derived target band.** `-t` / `-Tizen` is documented as supporting cross-band +installation, so the "target band must equal the active SDK band" rule applies **only** to an +auto-derived band. An explicitly requested band is honoured, and all inputs are validated +*before* anything is written. + +**SDK pinning is validated before any mutation.** `install_tizenworkload` is invoked under `if !`, +which suppresses `errexit` for everything it calls, so an unchecked `dotnet new globaljson` could +fail and let the install proceed against the wrong SDK. Every pin command is now checked +explicitly and the effective `dotnet --version` is re-read and compared before download. + +**Atomic manifest replacement.** The payload is staged and verified in a temporary directory +alongside the destination, then swapped in atomically, with rollback of the previous manifest on +any failure. Previously a partial copy could destroy a working manifest, and the leftover +directory made a subsequent existence check pass. + +**SDK bootstrap is keyed by full SDK version.** `DOTNET_DESTDIR` and the install stamp include +the exact SDK version (`10.0.100` vs `10.0.101` vs a preview build), so `make install` no longer +reuses a stale SDK that merely shares a feature band. Manifest paths remain band-based. + +Shell tests run under stock macOS Bash 3.2 (no `${var,,}`), and cover spaced paths, empty and +transport-failed version queries, and mixed-band `UpdateAllWorkloads`. + + +## Deriving the next version from the feed + +`next-workload-version.py` picks the next global build counter from what is published, so +every question it asks the feed has to fail closed: + +* **The search index is paginated.** The endpoint caps results per request, so a single + query saw only the first page. If the band holding the highest counter fell on a later + page it was invisible and the derived "next" version collided with one already published. + Pagination now continues until the number of entries seen matches `totalHits`, and a + response that delivers fewer than it promised, omits `totalHits`, or returns a + non-integer `totalHits` or non-list `data` aborts the run. +* **A 200 that cannot be parsed is not an empty package.** `release-state.py` read + `payload.get("versions", [])`, so `{}` — a truncated or error-shaped body — made a + published package look missing. That downgrades `published` to `partial`, or makes a + partial release look unpublished and advances past it. Only a genuine 404 (or a missing + file on the `file://` feed used by the tests) means "never published". + +`--set-version` equal to the value already on disk is an **idempotent no-op, not a failure**. +It is called outside a condition under `set -e`, so returning non-zero killed the step and +made the documented "reserve the current commit" recovery below it unreachable. Writing a +*lower* version is still refused. + +## Installer band selection, continued + +**The manifest package always follows the target band.** The manifest is named after the +band it is *for*, but the id was derived from the running SDK and only corrected on the +auto-derived path. `-t 10.0.200` on a 10.0.100 SDK therefore downloaded +`samsung.net.sdk.tizen.manifest-10.0.100` and installed it into `sdk-manifests/10.0.200` — +the wrong band's manifest in the right band's directory, reported as success. + +**Pre-release bands are ordered by SemVer precedence.** The sort key appended the +pre-release verbatim and the comparison is a string compare, so `preview.10` sorted *below* +`preview.9` (`'1' < '9'`) and a request against a `preview.10` band silently fell back to +`preview.9`. Numeric identifiers are now zero-padded and tagged, alphanumeric ones tagged +separately (numeric sorts below alphanumeric, as SemVer requires). The shell and PowerShell +keys are asserted byte-identical. + +**Installing is one transaction.** A partial install is worse than none: the shell installer +left `global.json` pinned when a download failed — so the next iteration of an +`--update-all-workloads` run overwrote the user's real `global.json.bak` and destroyed it — +and a failed pack install left a new manifest advertising packs that were not on disk, so +every later build against that band failed with an unrelated-looking error. The PowerShell +installer was worse still: it *removed* the old manifest and packs before fetching the new +ones, so a mid-way failure left no workload at all. Both now roll the SDK back to exactly +its previous state on any failure, including restoring `global.json` verbatim. + +## Testing the shipped code + +The fallback tests used to re-implement the family-prefix rule with their own `sed`, so they +agreed with themselves no matter what the installer did and could never detect drift. The +resolver is now delimited by `# BEGIN/END FALLBACK RESOLVER` markers, extracted, and invoked +directly — as `compute_target_version_band` and `band_sort_key` already were. Regressing +`band_sort_key` to its previous form makes the suite fail, which is the property that +matters. + +The package set lives in three places — the workflow's push loop, `release-state.py`, and +the reservation payload — and is now checked in **both** directions. The old check only +verified that everything pushed was tracked; a package tracked but never pushed could never +become present, so the completeness gate would retry until it gave up and the version could +never be tagged. + +SDK bootstrap directories and install stamps are keyed by the **full** SDK version, so +`10.0.100` and `10.0.101` do not share a tree, while the manifest path stays band-based. diff --git a/workload/scripts/Generate-InstallScripts.ps1 b/workload/scripts/Generate-InstallScripts.ps1 index 390d37c7a..ca35c2330 100644 --- a/workload/scripts/Generate-InstallScripts.ps1 +++ b/workload/scripts/Generate-InstallScripts.ps1 @@ -137,6 +137,30 @@ Please add these markers around the existing LatestVersionMap block, then rerun. return $replaced } +function Test-ScriptIntegrity { + <# + Guards against the file-truncation / NUL-padding corruption that silently landed + in workload-install.sh (see git history around the version-map SSOT change). + The version-map drift check alone cannot catch it: it only compares the + auto-generated block, so a script whose *tail* is missing still reports "OK". + #> + param([string]$Path, [string]$ExpectedTail) + + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes -contains 0) { + Write-Host " CORRUPT: $Path contains NUL bytes." -ForegroundColor Red + return $false + } + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text.TrimEnd() -notmatch ([regex]::Escape($ExpectedTail) + '\s*$')) { + Write-Host " TRUNCATED: $Path does not end with '$ExpectedTail'." -ForegroundColor Red + Write-Host (" actual tail: " + ($text.TrimEnd() -split "`r?`n" | Select-Object -Last 1)) + return $false + } + Write-Host " OK: $Path integrity (no NULs, expected tail)." -ForegroundColor Green + return $true +} + function Detect-LineEnding { param([string]$Path) $bytes = [System.IO.File]::ReadAllBytes($Path) @@ -197,6 +221,16 @@ $ps1New = Replace-Block -FilePath $Ps1Path -NewBlock $ps1Block -LineEnding $p $okSh = Write-Or-Check -Path $ShPath -NewContent $shNew -CheckOnly:$Check $okPs1 = Write-Or-Check -Path $Ps1Path -NewContent $ps1New -CheckOnly:$Check +$intactSh = Test-ScriptIntegrity -Path $ShPath -ExpectedTail 'echo "DONE"' +$intactPs1 = Test-ScriptIntegrity -Path $Ps1Path -ExpectedTail 'Write-Host "`nDone"' + +if (-not $intactSh -or -not $intactPs1) { + Write-Host "" + Write-Host "An install script is corrupt (truncated or NUL-padded)." -ForegroundColor Red + Write-Host "Restore it from git history before regenerating the version map." + exit 1 +} + if ($Check -and (-not $okSh -or -not $okPs1)) { Write-Host "" Write-Host "version-map.json and the install scripts are out of sync." -ForegroundColor Red diff --git a/workload/scripts/README.md b/workload/scripts/README.md index 207f4c852..f81f00451 100644 --- a/workload/scripts/README.md +++ b/workload/scripts/README.md @@ -51,6 +51,46 @@ To add or update an entry: The CI workflow `validate-version-map.yml` runs `Generate-InstallScripts.ps1 -Check` on every PR and fails if the two scripts have drifted from `version-map.json`. +### When *not* to add an entry + +`LatestVersionMap` is a **fallback cache of already-published manifest versions**. It is only +consulted when the live NuGet lookup fails. Adding an entry for an SDK band whose +`Samsung.NET.Sdk.Tizen.Manifest-` package has never been released makes the installer +download a 404. Add the entry *after* the release, not before — see commit +`chore: add 10.0.300 -> 10.0.127 to version map`. + +## test-version-band + +`Generate-InstallScripts.ps1 -Check` only compares the generated version-map block, so it cannot +see problems elsewhere in the installers. Several extra guards cover that gap: + +* [`test-version-band.sh`](./test-version-band.sh) asserts the SDK-version → feature-band mapping + (for example `11.0.100-preview.7.26381.103` → `11.0.100-preview.7`). It extracts the bash + implementation from `workload-install.sh` and the PowerShell one from `workload-install.ps1`, + in both cases between the `BEGIN/END VERSION BAND DETECTION` markers, and additionally compares + both against `Config.mk`'s `DOTNET_VERSION_BAND`. Extracting the real code — rather than + reimplementing it in the test — is what lets the test detect the two installers drifting apart. + It also pins the manifest fallback band family and `DOTNET_DESTDIR` band isolation. + **Keep those markers intact.** +* [`test-template-conditions.sh`](./test-template-conditions.sh) extracts the template's platform + detection `PropertyGroup` (between the `BEGIN/END TIZEN UI PLATFORM DETECTION` markers) and + pins it across 13 TFMs. +* [`test-install-failure.sh`](./test-install-failure.sh) pins installer exit codes: a failed + install must exit non-zero rather than printing `DONE` and exiting 0. +* `test-matrix.sh --self-test` pins matrix row selection without needing a dotnet install. +* `Generate-InstallScripts.ps1` additionally verifies both installers contain no NUL bytes and + end with their expected final statement. Both scripts had previously been committed truncated + mid-statement and NUL-padded, which the version-map drift check reported as "OK". + +Run everything at once with: + +``` +make -C workload check +``` + +`pwsh` is required for the drift/integrity checks. Without it `make check` fails with an +actionable message; use `make check SKIP_PWSH_CHECKS=1` to proceed with those unverified. + ### Why Previously, the same ~36 entries were maintained by hand in two different languages diff --git a/workload/scripts/next-workload-version.py b/workload/scripts/next-workload-version.py index bfc3f8a1f..0667a4e44 100644 --- a/workload/scripts/next-workload-version.py +++ b/workload/scripts/next-workload-version.py @@ -9,7 +9,7 @@ and returning it incremented by 1. Rationale -========= +--------- TizenWorkloadVersion uses the format `..` where `buildSeq` increments by 1 per release REGARDLESS of which .NET SDK band the release targets. Every active branch (main, net7.0, net8.0, net9.0, net10.0) draws from the @@ -17,7 +17,7 @@ than the largest `buildSeq` ever published, irrespective of sdkBand or branch. Algorithm -========= +--------- 1. Query NuGet's search index for all packages whose id starts with `samsung.net.sdk.tizen.manifest-`. This returns one entry per sdkBand (one package per band, e.g. ...-7.0.400, ...-8.0.100-rtm, etc.). @@ -28,7 +28,7 @@ 5. Print `..` on stdout (single line, no extras). Usage -===== +----- # Print only: python3 workload/scripts/next-workload-version.py @@ -39,7 +39,7 @@ python3 workload/scripts/next-workload-version.py --verbose Notes -===== +----- - Reads no local files; relies only on what is published to nuget.org. This is intentional: published artifacts are the authoritative shared sequence; uncommitted Versions.props bumps cannot influence the answer. @@ -50,6 +50,7 @@ from __future__ import annotations import argparse import json +import os import re import sys import urllib.error @@ -57,17 +58,24 @@ import urllib.request from pathlib import Path +# Overridable so the tests can point the queries at a local stub feed. Defaults are the +# production endpoints; nothing in CI or a real release sets these. +SEARCH_BASE = os.environ.get("TIZEN_NUGET_SEARCH_BASE", "https://azuresearch-usnc.nuget.org/query") +FLATCONTAINER_BASE = os.environ.get( + "TIZEN_NUGET_FEED_BASE", "https://api.nuget.org/v3-flatcontainer" +) + SEARCH_URL = ( - "https://azuresearch-usnc.nuget.org/query" - "?q=packageid:samsung.net.sdk.tizen.manifest" - "&prerelease=true&semVerLevel=2.0.0&take=200" + SEARCH_BASE + "?q=packageid:samsung.net.sdk.tizen.manifest" + "&prerelease=true&semVerLevel=2.0.0" ) SEARCH_FALLBACK_URL = ( - "https://azuresearch-usnc.nuget.org/query" - "?q=samsung.net.sdk.tizen.manifest" - "&prerelease=true&semVerLevel=2.0.0&take=200" + SEARCH_BASE + "?q=samsung.net.sdk.tizen.manifest" + "&prerelease=true&semVerLevel=2.0.0" ) -FLATCONTAINER_FMT = "https://api.nuget.org/v3-flatcontainer/{id}/index.json" +PAGE_SIZE = 200 + +FLATCONTAINER_FMT = FLATCONTAINER_BASE.rstrip("/") + "/{id}/index.json" VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$") @@ -80,36 +88,130 @@ def fetch_json(url: str, timeout: int = 15) -> dict: raise SystemExit(f"network error fetching {url}: {e}") -def find_manifest_package_ids(verbose: bool = False) -> list[str]: - """Return lowercase ids of every published samsung.net.sdk.tizen.manifest-* package.""" +def search_page(url_base: str, skip: int, take: int) -> tuple[int, list]: + """One page of a NuGet search query, with the response strictly validated. + + Returns (totalHits, entries). Every structural surprise raises: a search response we + cannot fully understand must never be reduced to "fewer packages exist". + """ + url = "{0}&skip={1}&take={2}".format(url_base, skip, take) + data = fetch_json(url) + if not isinstance(data, dict): + raise SystemExit(f"unexpected search response for {url}: not a JSON object") + if "totalHits" not in data: + raise SystemExit(f"search response for {url} has no 'totalHits'; refusing to guess") + total = data["totalHits"] + if not isinstance(total, int) or isinstance(total, bool) or total < 0: + raise SystemExit(f"search response for {url} has a bad 'totalHits': {total!r}") + entries = data.get("data") + if not isinstance(entries, list): + raise SystemExit( + f"search response for {url} has a non-list 'data': {type(entries).__name__}" + ) + return total, entries + + +def collect_ids(url_base: str, verbose: bool = False) -> set[str]: + """Every matching package id from url_base, paginating until totalHits is satisfied. + + The search endpoint caps `take`, so a single request silently returned only the first + page. Reading one page and treating it as the whole world meant a band whose entry fell + on page 2 was invisible - and if that band held the highest build counter, the derived + "next" version collided with one already published. Pagination continues until the + number of entries seen matches totalHits; anything less is a hard error. + """ out: set[str] = set() - for url in (SEARCH_URL, SEARCH_FALLBACK_URL): - try: - data = fetch_json(url) - except SystemExit: - continue - for entry in data.get("data", []): - pid = entry.get("id", "").lower() + skip = 0 + seen = 0 + total: int | None = None + while True: + page_total, entries = search_page(url_base, skip, PAGE_SIZE) + if total is None: + total = page_total + elif page_total != total: + raise SystemExit( + f"totalHits changed mid-pagination ({total} -> {page_total}); the feed is " + f"not stable enough to derive a version from." + ) + for entry in entries: + if not isinstance(entry, dict): + raise SystemExit(f"unexpected search entry: {type(entry).__name__}") + pid = entry.get("id", "") + if not isinstance(pid, str): + raise SystemExit(f"unexpected package id: {pid!r}") + pid = pid.lower() # Accept both exact 'samsung.net.sdk.tizen.manifest' (no suffix, unusual) # and the typical '-' suffix forms. if pid.startswith("samsung.net.sdk.tizen.manifest"): out.add(pid) - if out: + seen += len(entries) + if seen >= total: break + if not entries: + # More were promised than delivered: stop rather than spin, and fail closed. + raise SystemExit( + f"search returned {seen} of {total} promised entries for {url_base}; " + f"refusing to derive a version from an incomplete package list." + ) + skip += len(entries) if verbose: - print(f"[verbose] discovered {len(out)} manifest package ids", file=sys.stderr) - return sorted(out) + print( + f"[verbose] {url_base}: {seen}/{total} entries, {len(out)} manifest ids", + file=sys.stderr, + ) + return out + + +def find_manifest_package_ids(verbose: bool = False) -> list[str]: + """Return lowercase ids of every published samsung.net.sdk.tizen.manifest-* package.""" + problems: list[str] = [] + for url in (SEARCH_URL, SEARCH_FALLBACK_URL): + try: + out = collect_ids(url, verbose=verbose) + except SystemExit as exc: + # Remember it: if every query form fails we must abort rather than proceed + # with no data, which would look exactly like "nothing is published yet". + problems.append(f"{url}: {exc}") + continue + if out: + if verbose: + print(f"[verbose] discovered {len(out)} manifest package ids", file=sys.stderr) + return sorted(out) + if problems: + raise SystemExit( + "could not enumerate the manifest packages: " + + "; ".join(problems) + + ". Refusing to derive a version from incomplete feed data." + ) + return [] def fetch_versions(pid: str, verbose: bool = False) -> list[str]: + """Versions of an authoritative package. + + Any transport, status or parse failure ABORTS. Returning an empty list on failure + silently lowered the global maximum: if the query that failed happened to be the + package holding the highest build counter, the next version would collide with one + that is already published. Being unable to see the whole picture is never the same + as seeing an empty one. + """ url = FLATCONTAINER_FMT.format(id=pid) try: data = fetch_json(url) - except SystemExit: - if verbose: - print(f"[verbose] failed to fetch versions for {pid}", file=sys.stderr) - return [] - return data.get("versions", []) + except SystemExit as exc: + raise SystemExit( + f"failed to fetch versions for {pid}: {exc}. Refusing to derive a version " + f"from incomplete feed data." + ) + if not isinstance(data, dict) or "versions" not in data: + raise SystemExit( + f"unexpected response for {pid} (no 'versions' array). Refusing to derive a " + f"version from unparseable feed data." + ) + versions = data.get("versions") + if not isinstance(versions, list): + raise SystemExit(f"unexpected 'versions' payload for {pid}: {type(versions).__name__}") + return versions def parse_version(v: str) -> tuple[int, int, int] | None: @@ -140,7 +242,9 @@ def find_max_triple(verbose: bool = False) -> tuple[int, int, int]: return best -def update_versions_props(versions_props: Path, new_value: str, verbose: bool = False) -> int: +def update_versions_props( + versions_props: Path, new_value: str, verbose: bool = False, allow_noop: bool = False +) -> int: text = versions_props.read_text(encoding="utf-8") m = re.search(r"([^<]+)", text) if not m: @@ -156,6 +260,13 @@ def to_tuple(s: str) -> tuple[int, int, int] | None: if new_t is None: raise SystemExit(f"unparseable new version: {new_value!r}") + if allow_noop and new_t == cur_t: + print( + f"TizenWorkloadVersion is already {new_value}; nothing to write.", + file=sys.stderr, + ) + return 0 + if new_t <= cur_t: print( f"refusing to write: current TizenWorkloadVersion ({current}) is >= candidate ({new_value}). " @@ -183,10 +294,21 @@ def main() -> int: p.add_argument("--versions-props", default=None, help="Path to Versions.props (default: workload/build/Versions.props relative to this script).") p.add_argument("--verbose", action="store_true", help="Print diagnostics on stderr.") + p.add_argument("--set-version", default=None, + help="Write this exact version instead of re-deriving it. Use with --apply " + "so a caller that already computed a candidate cannot have a " + "concurrent release change the value between compute and apply.") args = p.parse_args() - major, minor, build = find_max_triple(verbose=args.verbose) - next_value = f"{major}.{minor}.{build + 1}" + if args.set_version: + if not args.apply: + raise SystemExit("--set-version requires --apply") + next_value = args.set_version.strip() + if not VERSION_RE.match(next_value): + raise SystemExit(f"--set-version {next_value!r} is not a valid workload version") + else: + major, minor, build = find_max_triple(verbose=args.verbose) + next_value = f"{major}.{minor}.{build + 1}" if args.apply: if args.versions_props: @@ -194,7 +316,12 @@ def main() -> int: else: # script is in workload/scripts/, so Versions.props is sibling-of-parent/build/ vp = Path(__file__).resolve().parents[1] / "build" / "Versions.props" - rc = update_versions_props(vp, next_value, verbose=args.verbose) + # An explicit --set-version that equals what is already on disk is the resume + # case: a previous run bumped the file but never got as far as reserving it. + # That is a no-op, not a failure - the caller reserves the current commit. + rc = update_versions_props( + vp, next_value, verbose=args.verbose, allow_noop=bool(args.set_version) + ) # Always echo the computed value so CI/scripts can capture it. print(next_value) return rc diff --git a/workload/scripts/release-reservation.py b/workload/scripts/release-reservation.py new file mode 100644 index 000000000..6708163d3 --- /dev/null +++ b/workload/scripts/release-reservation.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# +# Copyright (c) Samsung Electronics. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# +""" +Encode and verify the payload of a release reservation. + +Why a payload at all +-------------------- +A reservation used to be nothing but a ref pointing at a commit. That is enough to answer +"is this version taken", but not "is this run allowed to continue it". Two failures follow +from a bare ref: + + * A re-run of a dispatch checks out the SHA of the ORIGINAL event, so `Versions.props` on + disk still holds the pre-bump version. Deciding what to resume from that value makes a + re-run look at a version it already finished, conclude there is nothing to resume, and + then abort against its own reservation. + * Nothing tied the reservation to the inputs it was made for. Re-dispatching with a + different `net_sdk_version` would happily resume a reservation created for another SDK + band and publish a manifest built for the wrong one. + +So the reservation records the complete set of release inputs, and a run that wants to +continue it must match them exactly. + +Format +------ +The reservation ref (`refs/tizen-release/v`) points at an annotated tag object +whose message contains one line of JSON: + + {"version", "sha", "sdk", "band", "manifest_id", "packages", "branch"} + +`sha` is the reserved release commit: the single commit every artifact for this version +must be built from, on the first attempt and on every retry. + +Commands +-------- + encode build the payload from explicit inputs (prints one line of JSON) + verify check a payload against the inputs of the run that wants to use it + +Exit codes +---------- + 0 ok + 2 usage/parse error + 3 the reservation does not match the run's inputs (do NOT resume it) +""" +from __future__ import annotations + +import argparse +import json +import sys + +FIELDS = ("version", "sha", "sdk", "band", "manifest_id", "packages", "branch") + + +def encode(args: argparse.Namespace) -> int: + packages = [p.strip() for p in args.packages.split(",") if p.strip()] + if not packages: + print("ERROR: --packages must list at least one package id", file=sys.stderr) + return 2 + if len(args.sha) != 40 or not all(c in "0123456789abcdef" for c in args.sha.lower()): + print(f"ERROR: --sha must be a full 40-character commit id, got {args.sha!r}", + file=sys.stderr) + return 2 + payload = { + "version": args.version.strip(), + "sha": args.sha.strip().lower(), + "sdk": args.sdk.strip(), + "band": args.band.strip(), + "manifest_id": args.manifest_id.strip(), + "packages": sorted(packages), + "branch": args.branch.strip(), + } + for key in FIELDS: + if not payload[key]: + print(f"ERROR: {key} must not be empty", file=sys.stderr) + return 2 + # separators= keeps it on a single line so `git cat-file -p` output can be grepped. + print(json.dumps(payload, sort_keys=True, separators=(",", ":"))) + return 0 + + +def load_payload(path: str) -> dict: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + # Tolerate the surrounding tag-object headers: take the first line that looks like the + # payload object. + for line in text.splitlines(): + line = line.strip() + if line.startswith("{"): + return json.loads(line) + raise ValueError("no JSON payload found") + + +def verify(args: argparse.Namespace) -> int: + try: + payload = load_payload(args.payload_file) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"ERROR: unreadable reservation payload: {exc}", file=sys.stderr) + return 2 + + if not isinstance(payload, dict): + print("ERROR: reservation payload is not an object", file=sys.stderr) + return 2 + missing = [f for f in FIELDS if f not in payload] + if missing: + # An older, bare reservation cannot be validated, so it cannot be resumed. + print( + "ERROR: reservation is missing required field(s): " + + ", ".join(missing) + + ". Refusing to resume a reservation whose release inputs are unknown.", + file=sys.stderr, + ) + return 3 + + problems = [] + checks = [ + ("version", args.expect_version), + ("sdk", args.expect_sdk), + ("band", args.expect_band), + ("branch", args.expect_branch), + ("manifest_id", args.expect_manifest_id), + ("sha", args.expect_sha), + ] + for key, expected in checks: + if expected is None: + continue + actual = payload.get(key) + if isinstance(actual, str) and isinstance(expected, str): + same = actual.strip() == expected.strip() + else: + same = actual == expected + if not same: + problems.append(f"{key}: reserved {actual!r} but this run has {expected!r}") + + if args.expect_packages is not None: + expected_pkgs = sorted(p.strip() for p in args.expect_packages.split(",") if p.strip()) + actual_pkgs = payload.get("packages") + if not isinstance(actual_pkgs, list): + problems.append("packages: reservation payload has a non-list 'packages'") + elif sorted(actual_pkgs) != expected_pkgs: + problems.append( + f"packages: reserved {sorted(actual_pkgs)} but this run expects {expected_pkgs}" + ) + + if problems: + print( + "ERROR: reservation v{0} does not match this run:".format(payload.get("version")), + file=sys.stderr, + ) + for problem in problems: + print(" - " + problem, file=sys.stderr) + print( + "Refusing to resume it. A reservation may only be continued by a run with the " + "same release inputs.", + file=sys.stderr, + ) + return 3 + + if args.print_field: + value = payload.get(args.print_field) + print(",".join(value) if isinstance(value, list) else value) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + enc = sub.add_parser("encode", help="build a reservation payload") + for flag in ("version", "sha", "sdk", "band", "manifest-id", "packages", "branch"): + enc.add_argument("--" + flag, required=True) + enc.set_defaults(func=encode) + + ver = sub.add_parser("verify", help="check a reservation against this run's inputs") + ver.add_argument("--payload-file", required=True) + for flag in ("version", "sha", "sdk", "band", "branch", "manifest-id", "packages"): + ver.add_argument("--expect-" + flag, default=None) + ver.add_argument("--print-field", default=None, + help="on success, print this field from the payload") + ver.set_defaults(func=verify) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/workload/scripts/release-state.py b/workload/scripts/release-state.py new file mode 100644 index 000000000..826814fc8 --- /dev/null +++ b/workload/scripts/release-state.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# +# Copyright (c) Samsung Electronics. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# +""" +Report the publication state of a workload version so a release can be resumed. + +Why +=== +The release publishes several packages and then creates a tag + GitHub release. Those +steps are not atomic. If one fails after the manifest has been pushed, the version V is +*partially* published: + + * `next-workload-version.py` derives the next version from what exists on NuGet, so it + now answers V+1; + * the previous logic saw OLD(V) != NEW(V+1), bumped, and moved on; + * V therefore never receives its remaining packs, its tag, or its release - permanently. + +This script makes that state explicit so the workflow can resume V instead of advancing. +NuGet packages are immutable, so "already present" is always safe to skip. + +States +------ + unpublished none of the expected packages exist for V + partial some (but not all) expected packages exist for V + published every expected package exists for V + (the caller still has to check the tag/release before treating V as done) + +Outputs +------- +Emits `key=value` lines suitable for appending to $GITHUB_OUTPUT: + + state=unpublished|partial|published + present= + missing= + +Exit codes +---------- + 0 state determined + 2 the feed could not be reached (caller must NOT advance the version on this) + +Testing +------- +`--feed-base` (or TIZEN_NUGET_FEED_BASE) overrides the flatcontainer base URL, so tests can +point at a local directory of `/index.json` files via a file:// URL. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + +DEFAULT_FEED_BASE = "https://api.nuget.org/v3-flatcontainer" + +# Packages that carry the workload version. Ref packs are versioned by their TizenFX +# version instead and are governed by the separate release_reference input. +VERSIONED_PACKAGE_SUFFIXES = [ + "Samsung.Tizen.Sdk", + "Samsung.NETCore.App.Runtime.tizen", + "Samsung.Tizen.Templates", +] + + +def feed_versions(feed_base: str, package_id: str) -> list: + """Return the published versions of package_id, or [] when it has never been published.""" + url = "{0}/{1}/index.json".format(feed_base.rstrip("/"), package_id.lower()) + try: + with urllib.request.urlopen(url, timeout=30) as response: + payload = json.load(response) + except urllib.error.HTTPError as exc: + if exc.code == 404: + # Genuinely never published. + return [] + raise + except urllib.error.URLError as exc: + # A missing file on a file:// feed (used by the tests) is the local equivalent of a + # 404. Every OTHER URLError is a transport failure and must stay fatal - treating it + # as "unpublished" would advance the version and strand a partial release. + if url.startswith("file:") and isinstance(exc.reason, FileNotFoundError): + return [] + raise + + # Only a 404 means "never published". A 200 carrying something we cannot parse is a + # malformed response, and must NOT be read as absence: `{}` previously yielded [] via + # .get("versions", []), so a truncated or error-shaped body made a published package + # look missing - which downgrades `published` to `partial` and re-publishes, or worse + # makes a partial release look unpublished and advances past it. + if not isinstance(payload, dict): + raise ValueError( + "malformed index for {0}: expected a JSON object, got {1}".format( + package_id, type(payload).__name__ + ) + ) + if "versions" not in payload: + raise ValueError( + "malformed index for {0}: no 'versions' key. A 200 response that cannot be " + "parsed is not the same as a package that was never published.".format(package_id) + ) + versions = payload["versions"] + if not isinstance(versions, list): + raise ValueError( + "malformed index for {0}: 'versions' is {1}, expected a list".format( + package_id, type(versions).__name__ + ) + ) + return [v for v in versions if v] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True, help="workload version to inspect") + parser.add_argument("--band", required=True, help="SDK feature band, e.g. 11.0.100-preview.7") + parser.add_argument( + "--feed-base", + default=os.environ.get("TIZEN_NUGET_FEED_BASE", DEFAULT_FEED_BASE), + help="flatcontainer base URL (tests may point this at a local directory)", + ) + args = parser.parse_args() + + version = args.version.strip() + if not version: + print("ERROR: --version must not be empty", file=sys.stderr) + return 2 + + expected = ["Samsung.NET.Sdk.Tizen.Manifest-" + args.band] + VERSIONED_PACKAGE_SUFFIXES + + present, missing = [], [] + for package_id in expected: + try: + versions = feed_versions(args.feed_base, package_id) + except Exception as exc: # noqa: BLE001 - any transport failure is fatal here + print("ERROR: could not query {0}: {1}".format(package_id, exc), file=sys.stderr) + # Never let a transport failure look like "unpublished": that would advance the + # version and strand a partially published release. + return 2 + (present if version in versions else missing).append(package_id) + + if not present: + state = "unpublished" + elif missing: + state = "partial" + else: + state = "published" + + print("state=" + state) + print("present=" + ",".join(present)) + print("missing=" + ",".join(missing)) + + print( + "Version {0} (band {1}): {2} - {3}/{4} expected package(s) published".format( + version, args.band, state, len(present), len(expected) + ), + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/workload/scripts/stub-nuget-search.py b/workload/scripts/stub-nuget-search.py new file mode 100755 index 000000000..df192eec5 --- /dev/null +++ b/workload/scripts/stub-nuget-search.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# +# Copyright (c) Samsung Electronics. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# +"""Serve a stub NuGet search + flatcontainer feed and run next-workload-version.py against it. + +Used by test-release-workflow.sh to exercise the search pagination path offline. The stub +deliberately puts the HIGHEST build counter on the LAST page: a reader that takes only the +first page returns a version that is already published. + +Usage: stub-nuget-search.py +Prints "rc= out=" for the caller to assert on. +""" +import http.server, json, threading, subprocess, os, sys, urllib.parse +# 3 pages of 2 entries; the HIGHEST build counter lives on page 3 (previously invisible). +IDS = ["samsung.net.sdk.tizen.manifest-%d" % i for i in range(6)] +VERS = {i: ["10.0.%d" % (100+i)] for i in range(6)} +MODE = sys.argv[1] +class H(http.server.BaseHTTPRequestHandler): + def log_message(self,*a): pass + def do_GET(self): + u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query) + if u.path == "/query": + skip=int(q.get("skip",[0])[0]); take=2 + page = IDS[skip:skip+take] + body = {"totalHits": len(IDS), "data": [{"id": i} for i in page]} + if MODE == "truncated" and skip == 0: + body = {"totalHits": len(IDS), "data": [{"id": i} for i in IDS[:2]]} + # then serve nothing further + if MODE == "truncated" and skip > 0: + body = {"totalHits": len(IDS), "data": []} + if MODE == "nototal": body.pop("totalHits") + if MODE == "badtotal": body["totalHits"] = "six" + else: + pid = u.path.strip("/").split("/")[0] + idx = IDS.index(pid) if pid in IDS else None + body = {"versions": VERS[idx]} if idx is not None else {"versions": []} + b = json.dumps(body).encode() + self.send_response(200); self.send_header("Content-Type","application/json") + self.send_header("Content-Length",str(len(b))); self.end_headers(); self.wfile.write(b) +srv = http.server.HTTPServer(("127.0.0.1",0), H) +threading.Thread(target=srv.serve_forever, daemon=True).start() +port = srv.server_address[1] +env = dict(os.environ, TIZEN_NUGET_SEARCH_BASE="http://127.0.0.1:%d/query"%port, + TIZEN_NUGET_FEED_BASE="http://127.0.0.1:%d"%port) +here = os.path.dirname(os.path.abspath(__file__)) +r = subprocess.run([sys.executable, os.path.join(here, "next-workload-version.py")],env=env, + capture_output=True,text=True) +print("rc=%d out=%s" % (r.returncode, r.stdout.strip())) diff --git a/workload/scripts/test-install-failure.sh b/workload/scripts/test-install-failure.sh new file mode 100755 index 000000000..f634ef9c7 --- /dev/null +++ b/workload/scripts/test-install-failure.sh @@ -0,0 +1,468 @@ +#!/bin/bash +# +# Copyright (c) Samsung Electronics. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# +# Exit-code regression test for the install scripts. +# +# Both installers used to swallow every per-SDK failure: install_tizenworkload returned +# early on error, the caller ignored the result, and the script printed "DONE" and exited 0. +# A CI job that pipes the script to bash therefore reported success even when nothing was +# installed. These tests pin the corrected behaviour. +# +# Usage: +# bash workload/scripts/test-install-failure.sh +# make -C workload test-install-failure +# + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SH_SCRIPT="$SCRIPT_DIR/workload-install.sh" +PS1_SCRIPT="$SCRIPT_DIR/workload-install.ps1" +TMPROOT="$(mktemp -d)" +trap 'rm -rf "$TMPROOT"' EXIT + +c_reset=$'\033[0m'; c_red=$'\033[31m'; c_green=$'\033[32m'; c_yellow=$'\033[33m' +[[ -t 1 ]] || { c_reset=""; c_red=""; c_green=""; c_yellow=""; } + +pass=0; fail=0 + +check() { + local name="$1" expected="$2" actual="$3" output="$4" + if [[ "$actual" == "$expected" ]]; then + printf " %sPASS%s %-52s exit=%s\n" "$c_green" "$c_reset" "$name" "$actual" + pass=$((pass + 1)) + else + printf " %sFAIL%s %-52s exit=%s (expected %s)\n" "$c_red" "$c_reset" "$name" "$actual" "$expected" + echo "$output" | tail -6 | sed 's/^/ | /' + fail=$((fail + 1)) + fi +} + +# --- 1. missing dotnet install dir must fail ------------------------------------ + +out="$(bash "$SH_SCRIPT" -d "$TMPROOT/does-not-exist" 2>&1)"; rc=$? +check "sh: nonexistent --dotnet-install-dir" 1 "$rc" "$out" + +# --- 2. dotnet present but no manifest for the band must fail ------------------- +# +# A stub dotnet reports an implausible SDK version. The band lookup finds nothing on +# NuGet and nothing in the fallback version map, so install_tizenworkload must fail and +# the script must exit non-zero instead of printing DONE. + +FAKE="$TMPROOT/fakedotnet" +mkdir -p "$FAKE" +cat > "$FAKE/dotnet" <<'STUB' +#!/bin/bash +case "$1" in + --version) echo "99.0.100" ;; + --list-sdks) echo "99.0.100 [$(dirname "$0")/sdk]" ;; + *) exit 0 ;; +esac +STUB +chmod +x "$FAKE/dotnet" + +if curl -sSf -m 20 -o /dev/null https://api.nuget.org/v3/index.json 2>/dev/null; then + out="$(cd "$TMPROOT" && bash "$SH_SCRIPT" -d "$FAKE" 2>&1)"; rc=$? + check "sh: unknown SDK band fails instead of printing DONE" 1 "$rc" "$out" + + if grep -q "^DONE$" <<< "$out"; then + printf " %sFAIL%s %-52s\n" "$c_red" "$c_reset" "sh: must not print DONE on failure" + fail=$((fail + 1)) + else + printf " %sPASS%s %-52s\n" "$c_green" "$c_reset" "sh: must not print DONE on failure" + pass=$((pass + 1)) + fi +else + printf " %sSKIP%s %-52s (no network)\n" "$c_yellow" "$c_reset" "sh: unknown SDK band" +fi + +# --- 3. PowerShell parity ------------------------------------------------------ + +if command -v pwsh >/dev/null 2>&1 && [[ -f "$PS1_SCRIPT" ]]; then + out="$(pwsh -NoProfile -File "$PS1_SCRIPT" -d "$TMPROOT/does-not-exist" 2>&1)"; rc=$? + check "ps1: nonexistent -DotnetInstallDir" 1 "$rc" "$out" +else + printf " %sSKIP%s %-52s (pwsh unavailable)\n" "$c_yellow" "$c_reset" "ps1 parity" +fi + +# --- 4. fallback must resolve the package ID, not just the version ------------- +# +# getLatestVersion previously returned only a version. The caller then downloaded that +# version under the ORIGINAL, unpublished manifest id - e.g. a request for +# '...manifest-10.0.400' resolved to version 10.0.127 (which belongs to +# '...manifest-10.0.300') and then 404'd trying to fetch 10.0.400/10.0.127. +# The function must return "=". + +echo "" +echo "-- fallback resolves package id --" + +# Load the shipped map + function without executing the installer body. +fallback_probe() { + bash -c ' + eval "$(sed -n "/^MANIFEST_BASE_NAME=/p" '"$SH_SCRIPT"')" + eval "$(sed -n "/# BEGIN AUTO-GENERATED VERSION MAP/,/# END AUTO-GENERATED VERSION MAP/p" '"$SH_SCRIPT"' | grep -v "^#")" + eval "$(sed -n "/^function getLatestVersion/,/^}/p" '"$SH_SCRIPT"')" + getLatestVersion "$1" + ' _ "$1" +} + +# "||" ('' = must resolve to nothing) +FALLBACK_CASES=( + "10.0.400|10.0.300|10.0.127" + "10.0.300|10.0.300|10.0.127" + "9.0.400|9.0.300|10.0.121" + "11.0.100-preview.7||" + "12.0.100||" +) + +for case in "${FALLBACK_CASES[@]}"; do + IFS='|' read -r req want_band want_ver <<< "$case" + base="samsung.net.sdk.tizen.manifest" + got="$(fallback_probe "$base-$req")" + if [[ -z "$want_band" ]]; then + if [[ -z "$got" ]]; then + printf " %sPASS%s %-24s -> resolves to nothing (fails closed)\n" "$c_green" "$c_reset" "$req" + pass=$((pass + 1)) + else + printf " %sFAIL%s %-24s -> %s (expected nothing)\n" "$c_red" "$c_reset" "$req" "$got" + fail=$((fail + 1)) + fi + continue + fi + want="$base-$want_band=$want_ver" + if [[ "$got" == "$want" ]]; then + printf " %sPASS%s %-24s -> %s\n" "$c_green" "$c_reset" "$req" "${got#$base-}" + pass=$((pass + 1)) + else + printf " %sFAIL%s %-24s -> %s (expected %s)\n" "$c_red" "$c_reset" "$req" "${got:-}" "$want" + fail=$((fail + 1)) + fi +done + +# --- 5. PowerShell parity, incl. no cross-SDK fallback leakage ----------------- +# +# The PS installer kept the resolved fallback id in a script-level $global:FallbackId that +# was never cleared, so an -UpdateAllWorkloads run could carry one SDK's fallback package +# into the NEXT SDK's install. The resolved id must be per-call. + +if command -v pwsh >/dev/null 2>&1 && [[ -f "$PS1_SCRIPT" ]]; then + echo "" + echo "-- PowerShell fallback parity / no global leakage --" + + if grep -q 'global:FallbackId' "$PS1_SCRIPT"; then + printf " %sFAIL%s workload-install.ps1 still uses \$global:FallbackId\n" "$c_red" "$c_reset" + fail=$((fail + 1)) + else + printf " %sPASS%s workload-install.ps1 has no \$global:FallbackId\n" "$c_green" "$c_reset" + pass=$((pass + 1)) + fi + + cat > "$TMPROOT/ps-probe.ps1" <<'PSEOF' +param([string]$ScriptPath) +$src = Get-Content -Raw $ScriptPath +$ManifestBaseName = 'Samsung.NET.Sdk.Tizen.Manifest' +Invoke-Expression ([regex]::Match($src,'(?s)# BEGIN AUTO-GENERATED VERSION MAP.*?# END AUTO-GENERATED VERSION MAP').Value -replace '(?m)^#.*$','') +Invoke-Expression ([regex]::Match($src,'(?s)# BEGIN VERSION BAND DETECTION.*?# END VERSION BAND DETECTION').Value) +function Resolve-Offline([string]$Id) { + if ($LatestVersionMap.Contains($Id)) { return "$Id=$($LatestVersionMap.$Id)" } + $p = Get-BandFamilyPrefix -ManifestId $Id + if ($p) { + $ids = @(); $vs = @() + foreach ($k in $LatestVersionMap.Keys) { + if ($k -like "$p*") { $ids += $k; $vs += $LatestVersionMap[$k] } + } + if ($vs) { return "$($ids[-1])=$($vs[-1])" } + } + return '' +} +# Mixed-band sequence: a 10.x fallback must not bleed into the 11.x iteration. +foreach ($b in @('10.0.400','11.0.100-preview.7','9.0.400')) { + Write-Output "$b=>$(Resolve-Offline "$ManifestBaseName-$b")" +} +PSEOF + ps_out="$(pwsh -NoProfile -File "$TMPROOT/ps-probe.ps1" -ScriptPath "$PS1_SCRIPT" 2>/dev/null | tr -d '\r')" + + check_ps() { + local label="$1" expect="$2" + if grep -Fqx "$expect" <<< "$ps_out"; then + printf " %sPASS%s %-24s -> %s\n" "$c_green" "$c_reset" "$label" "${expect#*=>}" + pass=$((pass + 1)) + else + printf " %sFAIL%s %-24s (got: %s)\n" "$c_red" "$c_reset" "$label" "$(grep -F "$label=>" <<< "$ps_out")" + fail=$((fail + 1)) + fi + } + B=Samsung.NET.Sdk.Tizen.Manifest + check_ps "10.0.400" "10.0.400=>$B-10.0.300=10.0.127" + check_ps "11.0.100-preview.7" "11.0.100-preview.7=>" + check_ps "9.0.400" "9.0.400=>$B-9.0.300=10.0.121" +else + echo "" + echo " (pwsh unavailable - skipping PowerShell fallback parity)" +fi + +# --- 6. bash 3.2 compatibility ------------------------------------------------- +# +# macOS ships bash 3.2 and is a supported target (DOTNET_DEFAULT_PATH_MACOS). The +# ${var,,} lowercase expansion is bash 4+ and raises "bad substitution" there, which left +# the version empty and silently skipped the fallback path entirely. + +echo "" +echo "-- bash 3.2 compatibility --" + +if grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*(,,|\^\^)\}' "$SH_SCRIPT" | grep -qv '^\s*[0-9]*:\s*#'; then + printf " %sFAIL%s workload-install.sh uses a bash 4+ case-conversion expansion\n" "$c_red" "$c_reset" + grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*(,,|\^\^)\}' "$SH_SCRIPT" | sed 's/^/ /' + fail=$((fail + 1)) +else + printf " %sPASS%s no bash 4+ case-conversion expansions\n" "$c_green" "$c_reset" + pass=$((pass + 1)) +fi + +for bad in 'declare -A' 'readarray' 'mapfile'; do + if grep -q -- "$bad" "$SH_SCRIPT"; then + printf " %sFAIL%s workload-install.sh uses bash 4+ feature: %s\n" "$c_red" "$c_reset" "$bad" + fail=$((fail + 1)) + else + printf " %sPASS%s no bash 4+ feature: %-12s\n" "$c_green" "$c_reset" "$bad" + pass=$((pass + 1)) + fi +done + +printf " %sINFO%s running under bash %s\n" "$c_yellow" "$c_reset" "${BASH_VERSION}" + +# --- 7. install path containing spaces ------------------------------------------ +# +# Unquoted $DOTNET_INSTALL_DIR / $TMPDIR expansions word-split on a path with spaces. + +echo "" +echo "-- space-containing install path --" + +SPACEDIR="$TMPROOT/dir with spaces/dotnet sdk" +mkdir -p "$SPACEDIR" +cat > "$SPACEDIR/dotnet" <<'STUB' +#!/bin/bash +case "$1" in + --version) echo "10.0.100" ;; + --list-sdks) echo "10.0.100 [$(dirname "$0")/sdk]" ;; + workload) exit 0 ;; + new) exit 0 ;; + *) exit 0 ;; +esac +STUB +chmod +x "$SPACEDIR/dotnet" + +if curl -sSf -m 20 -o /dev/null https://api.nuget.org/v3/index.json 2>/dev/null; then + space_out="$(cd "$TMPROOT" && bash "$SH_SCRIPT" -d "$SPACEDIR" 2>&1)"; space_rc=$? + if [[ $space_rc -eq 0 ]] && [[ -f "$SPACEDIR/sdk-manifests/10.0.100/samsung.net.sdk.tizen/WorkloadManifest.json" ]]; then + printf " %sPASS%s installs into a path containing spaces\n" "$c_green" "$c_reset" + pass=$((pass + 1)) + else + printf " %sFAIL%s install into space-containing path failed (exit %s)\n" "$c_red" "$c_reset" "$space_rc" + echo "$space_out" | tail -6 | sed 's/^/ | /' + fail=$((fail + 1)) + fi +else + printf " %sSKIP%s space-path install (no network)\n" "$c_yellow" "$c_reset" +fi + +# --- 8. transport failure must fail closed -------------------------------------- +# +# A failed/empty version query must take the fallback path and, when that yields +# nothing, fail - never proceed with an empty version. + +echo "" +echo "-- transport failure fails closed --" + +FAKEHOME="$TMPROOT/nonet" +mkdir -p "$FAKEHOME" +cat > "$FAKEHOME/dotnet" <<'STUB' +#!/bin/bash +case "$1" in + --version) echo "99.0.100" ;; + --list-sdks) echo "99.0.100 [$(dirname "$0")/sdk]" ;; + *) exit 0 ;; +esac +STUB +chmod +x "$FAKEHOME/dotnet" +# Force every curl to fail by pointing at an unroutable proxy. +nonet_out="$(cd "$TMPROOT" && ALL_PROXY="http://127.0.0.1:9" HTTPS_PROXY="http://127.0.0.1:9" \ + bash "$SH_SCRIPT" -d "$FAKEHOME" 2>&1)"; nonet_rc=$? +if [[ $nonet_rc -ne 0 ]] && ! grep -q "^DONE$" <<< "$nonet_out"; then + printf " %sPASS%s unreachable feed -> non-zero exit, no DONE\n" "$c_green" "$c_reset" + pass=$((pass + 1)) +else + printf " %sFAIL%s unreachable feed -> exit %s (must fail closed)\n" "$c_red" "$c_reset" "$nonet_rc" + echo "$nonet_out" | tail -6 | sed 's/^/ | /' + fail=$((fail + 1)) +fi + +# --- 9. SDK pin must be verified before installing ----------------------------- +# +# install_tizenworkload is invoked under `if !`, which disables errexit for everything it +# calls. An unchecked `dotnet new globaljson` therefore let the install proceed against +# whatever SDK the PATH happened to resolve. The pin is now checked, and the EFFECTIVE +# version/band re-verified, before any pack is installed. + +echo "" +echo "-- SDK pin verified before install --" + +PINDIR="$TMPROOT/pinbad" +mkdir -p "$PINDIR" +cat > "$PINDIR/dotnet" <<'STUB' +#!/bin/bash +if [ "$1" = "--version" ]; then + # Model a pin that silently does not take effect. + if [ -f "$PWD/global.json" ]; then echo "9.0.100"; else echo "10.0.100"; fi + exit 0 +fi +if [ "$1" = "new" ] && [ "$2" = "globaljson" ]; then + printf '{"sdk":{"version":"x"}}' > "$PWD/global.json"; exit 0 +fi +case "$1" in + --list-sdks) echo "10.0.100 [$(dirname "$0")/sdk]" ;; + workload) echo "REACHED_INSTALL"; exit 0 ;; + *) exit 0 ;; +esac +STUB +chmod +x "$PINDIR/dotnet" + +if curl -sSf -m 20 -o /dev/null https://api.nuget.org/v3/index.json 2>/dev/null; then + pin_out="$(cd "$TMPROOT" && bash "$SH_SCRIPT" -d "$PINDIR" 2>&1)"; pin_rc=$? + if [[ $pin_rc -ne 0 ]] && grep -q "pin did not take effect" <<< "$pin_out" && ! grep -q "REACHED_INSTALL" <<< "$pin_out"; then + printf " %sPASS%s ineffective SDK pin aborts before install\n" "$c_green" "$c_reset" + pass=$((pass + 1)) + else + printf " %sFAIL%s ineffective SDK pin did not abort (exit %s)\n" "$c_red" "$c_reset" "$pin_rc" + echo "$pin_out" | tail -5 | sed 's/^/ | /' + fail=$((fail + 1)) + fi + + # A pin that DOES take effect must install normally. + PINOK="$TMPROOT/pinok" + mkdir -p "$PINOK" + cat > "$PINOK/dotnet" <<'STUB' +#!/bin/bash +case "$1" in + --version) echo "10.0.100" ;; + --list-sdks) echo "10.0.100 [$(dirname "$0")/sdk]" ;; + new) exit 0 ;; + workload) exit 0 ;; + *) exit 0 ;; +esac +STUB + chmod +x "$PINOK/dotnet" + ok_out="$(cd "$TMPROOT" && bash "$SH_SCRIPT" -d "$PINOK" 2>&1)"; ok_rc=$? + if [[ $ok_rc -eq 0 ]] && grep -q "^DONE$" <<< "$ok_out"; then + printf " %sPASS%s effective SDK pin installs normally\n" "$c_green" "$c_reset" + pass=$((pass + 1)) + else + printf " %sFAIL%s effective SDK pin failed (exit %s)\n" "$c_red" "$c_reset" "$ok_rc" + echo "$ok_out" | tail -5 | sed 's/^/ | /' + fail=$((fail + 1)) + fi +else + printf " %sSKIP%s SDK pin verification (no network)\n" "$c_yellow" "$c_reset" +fi + +# --- 10. empty feed response must not install "latest" ------------------------- +# +# The NuGet v2 package endpoint serves the LATEST version when the URL carries no version +# segment, so an empty resolved version must never reach the download step. + +echo "" +echo "-- empty version never reaches the download URL --" + +if grep -q 'Refusing to install: resolved an empty manifest id/version' "$SH_SCRIPT"; then + printf " %sPASS%s workload-install.sh guards against an empty resolved version\n" "$c_green" "$c_reset" + pass=$((pass + 1)) +else + printf " %sFAIL%s workload-install.sh has no empty-version guard\n" "$c_red" "$c_reset" + fail=$((fail + 1)) +fi + +if command -v pwsh >/dev/null 2>&1; then + # An empty versions[] must NOT yield a truthy "=" result. + cat > "$TMPROOT/empty-versions.ps1" <<'PSEOF' +param([string]$ScriptPath) +$src = Get-Content -Raw $ScriptPath +if ($src -match 'Where-Object \{ \$_ -and \$_\.Trim\(\) \} \| Select-Object -Last 1') { + Write-Output 'GUARDED' +} else { + Write-Output 'UNGUARDED' +} +if ($src -match 'IsNullOrWhiteSpace\(\$ResolvedVersion\)') { + Write-Output 'CALLER_GUARDED' +} else { + Write-Output 'CALLER_UNGUARDED' +} +PSEOF + ev="$(pwsh -NoProfile -File "$TMPROOT/empty-versions.ps1" -ScriptPath "$PS1_SCRIPT" 2>/dev/null | tr -d '\r')" + for want in GUARDED CALLER_GUARDED; do + if grep -Fqx "$want" <<< "$ev"; then + printf " %sPASS%s ps1 %s\n" "$c_green" "$c_reset" "$want" + pass=$((pass + 1)) + else + printf " %sFAIL%s ps1 missing %s\n" "$c_red" "$c_reset" "$want" + fail=$((fail + 1)) + fi + done +fi + +# --- 11. explicit -Version "" must have no side effects ------------------------ +# +# An explicit empty/whitespace -Version does NOT equal "", so it bypassed the +# resolution-and-validation block entirely, reached the manifest REMOVAL, and produced a +# versionless NuGet URL - and the v2 package endpoint serves the LATEST package for such a +# URL. The gate is now unconditional, before any removal, URL construction or download. + +if command -v pwsh >/dev/null 2>&1 && [[ -f "$PS1_SCRIPT" ]]; then + echo "" + echo "-- explicit empty -Version has no side effects --" + + EVDIR="$TMPROOT/emptyver" + mkdir -p "$EVDIR" + cat > "$EVDIR/dotnet" <<'STUB' +#!/bin/bash +case "$1" in + --version) echo "10.0.100" ;; + --list-sdks) echo "10.0.100 [$(dirname "$0")/sdk]" ;; + *) echo "SIDE_EFFECT: dotnet $*" ;; +esac +STUB + chmod +x "$EVDIR/dotnet" + + for arg in "" " "; do + # Pre-seed an installed manifest so its removal would be detectable. + seeded="$EVDIR/sdk-manifests/10.0.100/samsung.net.sdk.tizen" + rm -rf "$EVDIR/sdk-manifests"; mkdir -p "$seeded" + echo '{"version":"SENTINEL","packs":{}}' > "$seeded/WorkloadManifest.json" + + ev_out="$(pwsh -NoProfile -File "$PS1_SCRIPT" -d "$EVDIR" -Version "$arg" 2>&1)"; ev_rc=$? + label="$([[ -z "$arg" ]] && echo 'empty' || echo 'whitespace')" + + if [[ $ev_rc -ne 0 ]] \ + && grep -q "manifest version is required" <<< "$ev_out" \ + && ! grep -q "SIDE_EFFECT" <<< "$ev_out" \ + && grep -q "SENTINEL" "$seeded/WorkloadManifest.json"; then + printf " %sPASS%s -Version %-12s rejected; no download, no removal\n" "$c_green" "$c_reset" "'$arg'" + pass=$((pass + 1)) + else + printf " %sFAIL%s -Version %-12s exit=%s (expected rejection with no side effects)\n" "$c_red" "$c_reset" "'$arg'" "$ev_rc" + grep -q "SENTINEL" "$seeded/WorkloadManifest.json" 2>/dev/null || echo " | installed manifest was REMOVED" + grep -q "SIDE_EFFECT" <<< "$ev_out" && echo " | dotnet was invoked" + echo "$ev_out" | tail -3 | sed 's/^/ | /' + fail=$((fail + 1)) + fi + done +fi + +echo "" +echo "============= install-failure summary =============" +echo " passed: $pass" +echo " failed: $fail" + +[[ $fail -eq 0 ]] || exit 1 +exit 0 diff --git a/workload/scripts/test-matrix.sh b/workload/scripts/test-matrix.sh index e27836766..6e41ae753 100644 --- a/workload/scripts/test-matrix.sh +++ b/workload/scripts/test-matrix.sh @@ -24,6 +24,14 @@ WORKLOAD_DIR="$(cd "$(dirname "$0")/.." && pwd)" DOTNET="${DOTNET:-dotnet}" TMPDIR="${TEST_MATRIX_TMP:-$WORKLOAD_DIR/.tmp/matrix}" ONLY="${TEST_MATRIX_ONLY:-}" +SELF_TEST="" + +for arg in "$@"; do + case "$arg" in + --self-test) SELF_TEST="1" ;; + *) echo "Unknown argument '$arg'"; exit 2 ;; + esac +done # Matrix rows: "|" # @@ -37,11 +45,27 @@ ONLY="${TEST_MATRIX_ONLY:-}" # A single shared fixture can't build both eras, so they are excluded here. # Add coverage in a follow-up by introducing a separate legacy fixture # (e.g. workload/scripts/fixtures/legacy/) keyed off the row's TFM. +# +# NOTE on net11.0-*: +# .NET 11 is a preview SDK band. A row is SKIPPED (not failed) when the dotnet +# under test cannot build that .NET major, so the default `make test-matrix` +# run against the .NET 10 band stays green. To exercise these rows: +# make test-matrix DOTNET_VERSION=11.0.100-preview.7.26381.103 MATRIX=( + # net8.0: oldest .NET major still exercised, across every current platform band. "net8.0-tizen10.0|10" "net8.0-tizen10.1|10.1" "net8.0-tizen11.0|11" + # net9.0 "net9.0-tizen10.0|10" + # net10.0: the current shipping band. Covered explicitly rather than implied by the + # SDK version used to run the matrix. + "net10.0-tizen10.0|10" + "net10.0-tizen10.1|10.1" + "net10.0-tizen11.0|11" + # net11.0: the band this branch adds. + "net11.0-tizen10.0|10" + "net11.0-tizen11.0|11" ) # --- helpers --------------------------------------------------------------- @@ -53,9 +77,63 @@ log() { printf "%s\n" "$*"; } pass() { printf " %sPASS%s %s\n" "$c_green" "$c_reset" "$*"; } fail() { printf " %sFAIL%s %s\n" "$c_red" "$c_reset" "$*"; } warn() { printf " %sWARN%s %s\n" "$c_yellow" "$c_reset" "$*"; } +skip() { printf " %sSKIP%s %s\n" "$c_yellow" "$c_reset" "$*"; } + +# Newest .NET SDK major visible to $DOTNET, e.g. "11". +# Populated once, after the $DOTNET prerequisite check below. +LATEST_SDK_MAJOR="" + +# sdk_can_target e.g. sdk_can_target net11.0 +# An SDK can build any TFM up to and including its own major (the .NET 10 SDK builds +# net8.0/net9.0/net10.0 fine), so a row is only unbuildable when its .NET major is +# NEWER than the newest installed SDK. Returns non-zero in that case so the row is +# skipped rather than reported as a failure. +sdk_can_target() { + local netver="$1" + local want="${netver#net}"; want="${want%%.*}" + [[ -n "$LATEST_SDK_MAJOR" ]] || return 0 + [[ "$want" -le "$LATEST_SDK_MAJOR" ]] +} # --- prerequisites --------------------------------------------------------- +# --self-test exercises the row-selection logic without any dotnet install, so CI can +# pin it in the cheap metadata job. A regression here is expensive but silent: an +# over-strict check makes every row "skip", and the matrix reports success having +# built nothing. +if [[ -n "$SELF_TEST" ]]; then + st_pass=0; st_fail=0 + # "||" + # + # An SDK builds its own major and every earlier one, so only rows NEWER than the + # installed SDK may be skipped. + for c in \ + "10|net8.0-tizen10.0|run" "10|net9.0-tizen10.0|run" "10|net10.0-tizen11.0|run" \ + "10|net11.0-tizen11.0|skip" "10|net12.0-tizen11.0|skip" \ + "11|net8.0-tizen10.0|run" "11|net9.0-tizen10.0|run" "11|net11.0-tizen11.0|run" \ + "11|net12.0-tizen11.0|skip" \ + "9|net8.0-tizen10.0|run" "9|net10.0-tizen10.0|skip" + do + IFS='|' read -r major tfm want <<< "$c" + LATEST_SDK_MAJOR="$major" + netver="${tfm%-tizen*}" + if sdk_can_target "$netver"; then got="run"; else got="skip"; fi + if [[ "$got" == "$want" ]]; then + printf " %sPASS%s sdk=%-3s %-22s -> %s\n" "$c_green" "$c_reset" "$major.x" "$tfm" "$got" + st_pass=$((st_pass + 1)) + else + printf " %sFAIL%s sdk=%-3s %-22s -> %s (expected %s)\n" "$c_red" "$c_reset" "$major.x" "$tfm" "$got" "$want" + st_fail=$((st_fail + 1)) + fi + done + echo "" + echo "============ test-matrix self-test summary ============" + echo " passed: $st_pass" + echo " failed: $st_fail" + [[ $st_fail -eq 0 ]] || exit 1 + exit 0 +fi + if ! command -v "$DOTNET" >/dev/null 2>&1; then log "ERROR: '$DOTNET' command not found." log " Run 'make install' first to bootstrap dotnet under workload/out/dotnet," @@ -71,10 +149,15 @@ fi mkdir -p "$TMPDIR" +# Discover the newest .NET major this dotnet can build for. +LATEST_SDK_MAJOR="$("$DOTNET" --list-sdks 2>/dev/null | sed -E 's/^([0-9]+)\..*/\1/' | sort -un | tail -1)" +log "Newest .NET SDK major: ${LATEST_SDK_MAJOR:-}" + # --- matrix loop ----------------------------------------------------------- -declare -i pass_count=0 fail_count=0 +declare -i pass_count=0 fail_count=0 skip_count=0 declare -a failed_rows=() +declare -a skipped_rows=() for entry in "${MATRIX[@]}"; do tfm="${entry%%|*}" @@ -84,13 +167,20 @@ for entry in "${MATRIX[@]}"; do continue fi - netver="${tfm%-tizen*}" # net6.0 / net8.0 / net9.0 - platver="${tfm##*-tizen}" # 8.0 / 9.0 / 10.0 / 11.0 + netver="${tfm%-tizen*}" # net6.0 / net8.0 / net9.0 / net11.0 + platver="${tfm##*-tizen}" # 8.0 / 9.0 / 10.0 / 10.1 / 11.0 rowdir="$TMPDIR/$tfm" log "" log "==> [$tfm] api-version=$apiver" + if ! sdk_can_target "$netver"; then + skip "$tfm (needs a ${netver#net}+ SDK; newest installed is ${LATEST_SDK_MAJOR}.x)" + skip_count+=1 + skipped_rows+=("$tfm") + continue + fi + rm -rf "$rowdir" mkdir -p "$rowdir" @@ -151,12 +241,65 @@ for entry in "${MATRIX[@]}"; do fi done +# --- self-contained disposition -------------------------------------------- +# +# Samsung.NETCore.App.Runtime.tizen is a placeholder pack with no runtime binaries, so a +# self-contained Tizen publish cannot work. It must fail with the actionable TIZENSDK001 +# rather than a raw XmlException from ResolveRuntimePackAssets parsing RuntimeList.xml, +# or an opaque NETSDK1083. +if [[ -z "$ONLY" && $pass_count -gt 0 ]]; then + sc_dir="$TMPDIR/selfcontained" + log "" + log "==> [self-contained disposition]" + rm -rf "$sc_dir" && mkdir -p "$sc_dir" + # Reuse whichever row built successfully; any Tizen project will do. + src_row="$(find "$TMPDIR" -maxdepth 1 -name 'net*-tizen*' -type d | head -1)" + if [[ -z "$src_row" ]]; then + fail "self-contained disposition could not run (no built row to reuse)" + fail_count+=1 + failed_rows+=("selfcontained:no-fixture") + else + cp "$src_row/TizenApp1.csproj" "$src_row/tizen-manifest.xml" "$sc_dir/" 2>/dev/null + cp -r "$src_row"/*.cs "$sc_dir/" 2>/dev/null + sc_log="$sc_dir/selfcontained.log" + if "$DOTNET" build "$sc_dir" --nologo -p:SelfContained=true > "$sc_log" 2>&1; then + fail "self-contained build unexpectedly SUCCEEDED (no runtime is shipped)" + fail_count+=1 + failed_rows+=("selfcontained:unexpected-success") + elif grep -q "TIZENSDK001" "$sc_log"; then + pass "self-contained rejected with TIZENSDK001" + pass_count+=1 + elif grep -qiE "XmlException|multiple root" "$sc_log"; then + fail "self-contained produced a raw XML parse error - RuntimeList.xml is malformed" + grep -iE "XmlException|multiple root" "$sc_log" | head -2 | sed 's/^/ | /' + fail_count+=1 + failed_rows+=("selfcontained:xmlexception") + else + # Any other diagnostic is a FAILURE, not a warning. Self-contained has exactly one + # supported outcome; NETSDK1083 or anything else means the guard did not fire and + # the user gets an unactionable error. + fail "self-contained produced an unexpected diagnostic (expected TIZENSDK001)" + grep -m3 "error" "$sc_log" | sed 's/^/ | /' + fail_count+=1 + failed_rows+=("selfcontained:unexpected-diagnostic") + fi + fi +fi + # --- summary --------------------------------------------------------------- log "" log "================ test-matrix summary ================" log " passed: $pass_count" log " failed: $fail_count" +log " skipped: $skip_count" + +if [[ $skip_count -gt 0 ]]; then + log " skipped rows:" + for r in "${skipped_rows[@]}"; do + log " - $r" + done +fi if [[ $fail_count -gt 0 ]]; then log " failed rows:" diff --git a/workload/scripts/test-package-fallback.sh b/workload/scripts/test-package-fallback.sh new file mode 100755 index 000000000..45f9d9d38 --- /dev/null +++ b/workload/scripts/test-package-fallback.sh @@ -0,0 +1,285 @@ +#!/bin/bash +# +# Copyright (c) Samsung Electronics. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# +# Evaluation test for PackageTargetFallback compatibility filtering. +# +# FixupNuGetReferences matches a package's lib// sibling directories against +# PackageTargetFallback by NAME ONLY - it performs no compatibility check of its own. +# An unfiltered cross-product therefore lets a net6.0-tizen8.0 build silently pick up +# net6.0-tizen11.0 (newer platform) or net11.0-tizen8.0 (newer .NET) assets. +# +# This test extracts the real filtering block from the shipped +# Samsung.Tizen.Sdk.NuGet.targets (between the BEGIN/END TIZEN PACKAGE FALLBACK markers) +# and evaluates it, asserting both that compatible entries are present and - the point of +# the exercise - that incompatible ones are ABSENT. +# +# Usage: +# bash workload/scripts/test-package-fallback.sh +# make -C workload test-package-fallback +# + +set -uo pipefail + +WORKLOAD_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TARGETS="$WORKLOAD_DIR/src/Samsung.Tizen.Sdk/targets/Samsung.Tizen.Sdk.NuGet.targets" +DOTNET="${DOTNET:-dotnet}" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +c_reset=$'\033[0m'; c_red=$'\033[31m'; c_green=$'\033[32m'; c_yellow=$'\033[33m' +[[ -t 1 ]] || { c_reset=""; c_red=""; c_green=""; c_yellow=""; } + +if ! command -v "$DOTNET" >/dev/null 2>&1; then + echo " ${c_yellow}SKIP${c_reset} '$DOTNET' not found; cannot evaluate MSBuild expressions." + exit 0 +fi + +BLOCK="$(sed -n '/BEGIN TIZEN PACKAGE FALLBACK/,/END TIZEN PACKAGE FALLBACK/p' "$TARGETS" \ + | sed -e '1d' -e '$d')" +if [[ -z "$BLOCK" ]]; then + echo "ERROR: TIZEN PACKAGE FALLBACK markers not found in $TARGETS." + echo " Keep the markers intact so this test exercises shipped code." + exit 2 +fi + +{ + echo '' + echo "$BLOCK" + echo ' ' + echo ' ' + echo ' ' + echo '' +} > "$TMPDIR/probe.proj" + +pass=0; fail=0 + +# "|||" +CASES=( + # Building for the lowest supported platform: nothing newer may leak in. + "v6.0|8.0|net6.0-tizen8.0,tizen80|net6.0-tizen9.0,net6.0-tizen10.0,net6.0-tizen11.0,net8.0-tizen8.0,net11.0-tizen11.0,tizen90,tizen10.0" + # Newer .NET, old platform: platform siblings above 8.0 must stay out. + "v11.0|8.0|net11.0-tizen8.0,net6.0-tizen8.0,tizen80|net11.0-tizen9.0,net6.0-tizen11.0,net11.0-tizen11.0,tizen90" + # Old .NET, newest platform: .NET majors above 6.0 must stay out. + "v6.0|11.0|net6.0-tizen11.0,net6.0-tizen8.0,tizen10.0|net8.0-tizen11.0,net11.0-tizen11.0,net9.0-tizen10.0" + # The primary target: everything at or below is fair game. + "v11.0|11.0|net11.0-tizen11.0,net6.0-tizen8.0,net8.0-tizen10.0,tizen40|" + # Mid-range combination. + "v9.0|10.0|net9.0-tizen10.0,net8.0-tizen9.0,tizen10.0|net10.0-tizen10.0,net11.0-tizen11.0,net9.0-tizen10.1,net9.0-tizen11.0" + # 10.1 must not admit 11.0, and 10.1 itself is available at 10.1. + "v10.0|10.1|net10.0-tizen10.1,net10.0-tizen10.0|net10.0-tizen11.0,net11.0-tizen10.1" +) + +for case in "${CASES[@]}"; do + IFS='|' read -r tfv tpv want_present want_absent <<< "$case" + + out="$("$DOTNET" msbuild "$TMPDIR/probe.proj" -t:Probe -nologo -v:m -nodereuse:false \ + -p:TargetFrameworkVersion="$tfv" -p:TargetPlatformVersion="$tpv" 2>&1 \ + | grep -o 'RESULT|.*' | head -1)" + list=";${out#RESULT|};" + list="${list// /}" + + label="net${tfv#v}-tizen${tpv}" + row_ok=1 + detail="" + + presents=(); absents=() + [[ -n "$want_present" ]] && IFS=',' read -ra presents <<< "$want_present" + [[ -n "$want_absent" ]] && IFS=',' read -ra absents <<< "$want_absent" + + for e in "${presents[@]+"${presents[@]}"}"; do + [[ -z "$e" || "$e" == *_SKIP ]] && continue + if [[ "$list" != *";$e;"* ]]; then row_ok=0; detail="$detail missing:$e"; fi + done + + for e in "${absents[@]+"${absents[@]}"}"; do + [[ -z "$e" ]] && continue + if [[ "$list" == *";$e;"* ]]; then row_ok=0; detail="$detail LEAKED:$e"; fi + done + + if [[ $row_ok -eq 1 ]]; then + printf " %sPASS%s %-22s\n" "$c_green" "$c_reset" "$label" + pass=$((pass + 1)) + else + printf " %sFAIL%s %-22s%s\n" "$c_red" "$c_reset" "$label" "$detail" + printf " list: %s\n" "${out#RESULT|}" + fail=$((fail + 1)) + fi +done + +# --- selection priority + atomicity ------------------------------------------ +# +# PackageTargetFallback is an ORDERED preference list, but FixupNuGetReferences used to add +# every matching directory to an unordered HashSet (populated in FILESYSTEM enumeration +# order) and then take assemblies first-wins across all of them. Two consequences: +# * the declared priority was ignored whenever it disagreed with directory order, and +# * assemblies could be MIXED across TFMs within one package. +# +# The cases below are chosen so alphabetical directory order DISAGREES with the declared +# priority - otherwise the old implementation passes by luck. + +echo "" +echo "-- selection priority / atomicity --" + +TASK_PROJ="$WORKLOAD_DIR/src/Samsung.Tizen.Build.Tasks/Samsung.Tizen.Build.Tasks.csproj" +TASK_DLL="$WORKLOAD_DIR/src/Samsung.Tizen.Build.Tasks/bin/Release/netstandard2.0/Samsung.Tizen.Build.Tasks.dll" + +# Always rebuild: a stale DLL would let the suite validate code that is no longer the +# source of truth. MSBuild node reuse is disabled everywhere below for the same reason - +# a persistent node caches the loaded task assembly across invocations. +"$DOTNET" build "$TASK_PROJ" -c Release --nologo -v:q -nodereuse:false >/dev/null 2>&1 || true + +# "