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..1dbab66fb 100644 --- a/.github/workflows/release-workload.yml +++ b/.github/workflows/release-workload.yml @@ -37,53 +37,144 @@ jobs: --store-password-in-clear-text \ --configfile workload/NuGet.config - - name: Bump TizenWorkloadVersion (global sequential, NuGet-derived) + # Resolve the version to release. + # + # Two properties matter here: + # * A reference-only run (release_manifest=false) publishes no manifest, so it must be + # completely NON-MUTATING - no bump, no commit, no tag. + # * OLD == NEW is NOT necessarily an error. next-workload-version.py derives the next + # version from what is published on NuGet, so if a previous run already bumped and + # pushed but failed before publishing, the branch legitimately carries the intended + # (still unpublished) version. That case must RESUME, not abort - otherwise a failed + # release can never be retried. + - name: Resolve release version id: bump working-directory: ./workload run: | - set -e + set -euo pipefail OLD=$(grep -oP '(?<=)[^<]+(?=)' build/Versions.props) NEW=$(python3 scripts/next-workload-version.py) echo "Current: $OLD" echo "Next: $NEW" + + 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 "mutated=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$OLD" = "$NEW" ]; then - echo "ERROR: computed next == current. NuGet already has this version published." - exit 1 + # The branch already holds the version NuGet does not have yet: resume it. + echo "::notice ::Resuming release of $OLD (already set on this branch, not yet published)." + echo "workload_version=$OLD" >> "$GITHUB_OUTPUT" + echo "mutated=false" >> "$GITHUB_OUTPUT" + exit 0 fi + python3 scripts/next-workload-version.py --apply --verbose echo "workload_version=$NEW" >> "$GITHUB_OUTPUT" + echo "mutated=true" >> "$GITHUB_OUTPUT" echo "::notice ::Bumped TizenWorkloadVersion: $OLD -> $NEW" - 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 + + # 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/" + SDK="${{ github.event.inputs.net_sdk_version }}" + # Reuse the band function from workload-install.sh so the release cannot + # disagree with what the installer will look for. + eval "$(sed -n '/# BEGIN VERSION BAND DETECTION/,/# END VERSION BAND DETECTION/p' workload/scripts/workload-install.sh)" + BAND="$(compute_target_version_band "$SDK")" + 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" + echo "Staged packages:"; ls -1 "$STAGING" + + # Persist the bump only AFTER the build succeeded and the expected artifacts were + # verified on disk. Committing earlier meant a later build failure left the branch + # already bumped, so the retry computed OLD == NEW and aborted - an unretryable release. + # + # Skipped when the resolve step did not mutate anything (reference-only run, or a resume + # of a version this branch already carries). + - name: Commit and push the version bump + id: commit + run: | + set -euo pipefail + BRANCH="${GITHUB_REF_NAME}" + VER="${{ steps.bump.outputs.workload_version }}" + if [ "${{ steps.bump.outputs.mutated }}" != "true" ]; then + echo "::notice ::No version mutation to persist; tagging existing HEAD." + echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git diff --quiet -- workload/build/Versions.props; then + echo "::error::Versions.props was not modified by the resolve step." + exit 1 + fi + 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 + git commit -m "chore: bump TizenWorkloadVersion to ${VER}" + git push origin "HEAD:${BRANCH}" + echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Push Manifest/SDK/Runtime 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 \ + 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 - dotnet nuget push ./workload/out/nuget-unsigned/Samsung.Tizen.Sdk.*.nupkg \ + dotnet nuget push ${{ steps.stage.outputs.staging }}/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 \ + dotnet nuget push ${{ steps.stage.outputs.staging }}/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 \ + dotnet nuget push ${{ steps.stage.outputs.staging }}/Samsung.Tizen.Templates.*.nupkg \ -k ${{ secrets.NUGET_APIKEY }} \ -s https://api.nuget.org/v3/index.json \ -t 3000 \ @@ -93,12 +184,13 @@ jobs: 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 +198,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 +219,12 @@ jobs: [ -n "$v" ] && echo "- https://www.nuget.org/packages/Samsung.Tizen.Ref.API${api}/${v}" done } > "$RUNNER_TEMP/release-notes.md" + if gh release view "v${VER}" >/dev/null 2>&1; then + echo "::notice ::Release v${VER} already exists; skipping creation (resumed run)." + exit 0 + fi gh release create "v${VER}" \ - --target "${GITHUB_REF_NAME}" \ + --target "${{ steps.commit.outputs.release_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..d2592266b 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,35 @@ 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 + # Band-scoped: `make install DOTNET_VERSION=11...` in a tree that already built the + # .NET 10 band must bootstrap a separate SDK instead of reusing (and silently + # testing) the old one. + DOTNET_DESTDIR = $(OUTDIR)/dotnet-$(DOTNET_VERSION_BAND) 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..baf8cf7d4 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_BAND) + +$(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..468a60a8e --- /dev/null +++ b/workload/docs/net11.md @@ -0,0 +1,322 @@ +# .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, non-mutating reference-only runs | +| `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. + +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. **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`. + +Verified on `11.0.100-preview.7.26381.103`: `dotnet build -p:SelfContained=true` on a +`net11.0-tizen11.0` project now fails with `TIZENSDK001` and the actionable message. +`SkipTizenSelfContainedCheck=true` bypasses the guard for anyone supplying a runtime by other +means. + +**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 is ordered so a failure is always retryable: + +1. resolve the version (no mutation yet), +2. clean, build, and **verify** the expected artifacts on disk, +3. only then commit and push the version bump, +4. push packages from the verified staging directory, +5. create the tag, targeting the commit that carries the released `Versions.props`. + +Committing the bump *before* the build meant a later failure left the branch already bumped, so +the retry computed `OLD == NEW` and aborted — an unretryable release. `OLD == NEW` is therefore +no longer 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. Re-creating an existing tag is a no-op. + +A reference-only run (`release_manifest=false`) publishes no manifest and is completely +non-mutating: no bump, no commit, no tag. + +`scripts/test-release-workflow.sh` pins all of the above. 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/test-install-failure.sh b/workload/scripts/test-install-failure.sh new file mode 100755 index 000000000..d122fb198 --- /dev/null +++ b/workload/scripts/test-install-failure.sh @@ -0,0 +1,420 @@ +#!/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 + +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..e1d3814e2 --- /dev/null +++ b/workload/scripts/test-package-fallback.sh @@ -0,0 +1,221 @@ +#!/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 \ + -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" + +if [[ ! -f "$TASK_DLL" ]]; then + "$DOTNET" build "$TASK_PROJ" -c Release --nologo -v:q >/dev/null 2>&1 || true +fi + +# "