From a711b5804254b86da1ca075185ec5789e7c02c5a Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Mon, 14 Sep 2026 14:50:50 -0700 Subject: [PATCH 1/3] Add root-level Function ZIP packaging scripts for each language --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++ docs/ONBOARDING.md | 4 ++++ package-dotnet.ps1 | 34 +++++++++++++++++++++++++++++++ package-javascript.ps1 | 43 ++++++++++++++++++++++++++++++++++++++++ package-python.ps1 | 35 ++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 package-dotnet.ps1 create mode 100644 package-javascript.ps1 create mode 100644 package-python.ps1 diff --git a/README.md b/README.md index 3a5cfbc..3cae35b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,51 @@ by default. Deploy each language separately, not all three to the same Function New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing, and deploying, step by step. +## Package a Function + +Run the script for your chosen language from the repository root. These standalone scripts create +ZIPs locally; they do not sign in to Azure, upload code, or change app settings. + +| Language | Root-level script | Prerequisites | ZIP in `artifacts/` | +|---|---|---|---| +| JavaScript | [package-javascript.ps1](package-javascript.ps1) | PowerShell 7+, Node.js 20 or 22 with npm, npm registry access | `epp-javascript.zip` | +| .NET | [package-dotnet.ps1](package-dotnet.ps1) | PowerShell 7+, .NET 8 SDK, NuGet feed access | `epp-dotnet.zip` | +| Python | [package-python.ps1](package-python.ps1) | PowerShell 7+; Azure remote build required when deploying | `epp-python-source.zip` | + +```powershell +pwsh -File ./package-javascript.ps1 +pwsh -File ./package-dotnet.ps1 +pwsh -File ./package-python.ps1 +``` + +Choose one command; each packages only its language. The scripts locate source relative to their +own location, so invoking an absolute script path also works from another directory. Each ZIP has +`host.json` at its root, with no enclosing language folder. Local settings, credential files, and +first-party tests are excluded. Generated ZIPs are ignored by Git. + +JavaScript installs production dependencies from the lockfile in a temporary folder; your working +`node_modules` is not copied or modified. Dependency lifecycle scripts are disabled for this sample's +JavaScript dependencies. If you add native dependencies or packages requiring install scripts, +review packaging and build them for the target Azure OS. .NET packages fresh Release publish output, +including `.azurefunctions`, rather than an old `bin/` directory. + +**Python is a source ZIP, not a ready-to-run package.** Deploy to a Linux Function App with remote +build enabled in the deployment tool for your hosting plan, so Azure installs `requirements.txt`. +Do not use this source ZIP directly with run-from-package or copy Windows-installed Python dependencies +to Azure. The script deliberately does not invoke pip or include a local virtual environment. + +Existing archives are never overwritten. For another build, specify a new path: + +```powershell +pwsh -File ./package-javascript.ps1 -OutputPath ./artifacts/epp-javascript-v2.zip +``` + +The same `-OutputPath` option works for all three scripts. Configure the destination app's runtime, +app settings, Key Vault access, and Easy Auth separately before deployment. See +[deployment and validation](docs/ONBOARDING.md#4-package-deploy-and-verify). Packaging success does +not verify cloud configuration or provider delivery. File selection is tailored to this sample; +extend it deliberately if you add runtime assets, and never put secrets in application source. + ## The design in one line SAS → Easy Auth → anonymous HTTP handler (`POST /api/SendOtp`, validate envelope + decrypt JWE) → diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index bbb14e1..8c07fbf 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -149,6 +149,10 @@ digit spacing, without guessing a passcode. A timed-out send may already be acce ## 4. Package, deploy and verify +Run the [root-level packaging script](../README.md#package-a-function) for your chosen language: +`package-javascript.ps1`, `package-dotnet.ps1`, or `package-python.ps1`. Each writes a separate ZIP +under `artifacts/`; none deploys it. The Python source ZIP requires Azure remote build on Linux. + Build and publish only the chosen language folder, retaining runtime dependencies or using a supported remote build. Configure and verify Easy Auth before publishing; keep public ingress disabled until the required platform gate is in place. Verify managed identity access, encryption and platform diff --git a/package-dotnet.ps1 b/package-dotnet.ps1 new file mode 100644 index 0000000..56bc1fe --- /dev/null +++ b/package-dotnet.ps1 @@ -0,0 +1,34 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [string]$OutputPath = (Join-Path $PSScriptRoot 'artifacts/epp-dotnet.zip') +) + +$ErrorActionPreference = 'Stop' +$project = Join-Path $PSScriptRoot 'dotnet/dotnet.csproj' +$archive = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +if (Test-Path -LiteralPath $archive) { throw "Output already exists: $archive. Choose another -OutputPath." } +$dotnet = (Get-Command dotnet -ErrorAction Stop).Source +$temporary = Join-Path ([IO.Path]::GetTempPath()) ('epp-dotnet-' + [guid]::NewGuid().ToString('N')) +$stage = Join-Path $temporary 'app' + +try { + & $dotnet publish $project --configuration Release --output $stage --verbosity minimal + if ($LASTEXITCODE -ne 0) { throw '.NET publish failed; no ZIP created.' } + foreach ($name in @('host.json', 'functions.metadata', 'worker.config.json', 'dotnet.dll', '.azurefunctions')) { + if (-not (Test-Path -LiteralPath (Join-Path $stage $name))) { throw "Missing published runtime file: $name" } + } + $unsafe = @(Get-ChildItem -LiteralPath $stage -Recurse -Force -File | Where-Object { + $_.Name -like 'local.settings*' -or $_.Name -like '.env*' -or + $_.Extension -in @('.pem', '.pfx', '.p12', '.key', '.publishsettings', '.pubxml') -or + [IO.Path]::GetRelativePath($stage, $_.FullName) -match '(^|[\\/])(tests?|scripts)([\\/]|$)' + }) + if ($unsafe.Count) { throw 'Local settings, credentials, or test files found in publish output; no ZIP created.' } + $zip = Join-Path $temporary 'app.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($stage, $zip) + New-Item -ItemType Directory -Path (Split-Path $archive) -Force | Out-Null + [IO.File]::Move($zip, $archive) + Get-Item -LiteralPath $archive | Select-Object FullName, Length +} finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } +} \ No newline at end of file diff --git a/package-javascript.ps1 b/package-javascript.ps1 new file mode 100644 index 0000000..d25de3c --- /dev/null +++ b/package-javascript.ps1 @@ -0,0 +1,43 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [string]$OutputPath = (Join-Path $PSScriptRoot 'artifacts/epp-javascript.zip') +) + +$ErrorActionPreference = 'Stop' +$source = Join-Path $PSScriptRoot 'javascript' +$archive = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +if (Test-Path -LiteralPath $archive) { throw "Output already exists: $archive. Choose another -OutputPath." } +$npm = (Get-Command $(if ($IsWindows) { 'npm.cmd' } else { 'npm' }) -ErrorAction Stop).Source +$temporary = Join-Path ([IO.Path]::GetTempPath()) ('epp-javascript-' + [guid]::NewGuid().ToString('N')) +$stage = Join-Path $temporary 'app' + +try { + New-Item -ItemType Directory -Path $stage -Force | Out-Null + foreach ($name in @('host.json', 'package.json', 'package-lock.json')) { + Copy-Item -LiteralPath (Join-Path $source $name) -Destination $stage + } + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $source 'src/functions') -Recurse -File -Filter '*.js') { + $relative = [IO.Path]::GetRelativePath($source, $file.FullName) + if ($relative -match '(^|[\\/])(tests?|node_modules)([\\/]|$)' -or $file.Name -match '\.(test|spec)\.js$') { continue } + $destination = Join-Path $stage $relative + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $destination + } + if (-not (Test-Path -LiteralPath (Join-Path $stage 'src/functions/SendOtp.js'))) { throw 'Missing JavaScript function entry point.' } + Push-Location $stage + try { + & $npm ci --omit=dev --ignore-scripts --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { throw 'Production dependency installation failed; no ZIP created.' } + } finally { Pop-Location } + if (-not (Test-Path -LiteralPath (Join-Path $stage 'node_modules/@azure/functions/package.json'))) { + throw 'Missing Azure Functions runtime dependency.' + } + $zip = Join-Path $temporary 'app.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($stage, $zip) + New-Item -ItemType Directory -Path (Split-Path $archive) -Force | Out-Null + [IO.File]::Move($zip, $archive) + Get-Item -LiteralPath $archive | Select-Object FullName, Length +} finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } +} \ No newline at end of file diff --git a/package-python.ps1 b/package-python.ps1 new file mode 100644 index 0000000..d36582d --- /dev/null +++ b/package-python.ps1 @@ -0,0 +1,35 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [string]$OutputPath = (Join-Path $PSScriptRoot 'artifacts/epp-python-source.zip') +) + +$ErrorActionPreference = 'Stop' +$source = Join-Path $PSScriptRoot 'python' +$archive = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +if (Test-Path -LiteralPath $archive) { throw "Output already exists: $archive. Choose another -OutputPath." } +$temporary = Join-Path ([IO.Path]::GetTempPath()) ('epp-python-' + [guid]::NewGuid().ToString('N')) +$stage = Join-Path $temporary 'app' + +try { + New-Item -ItemType Directory -Path $stage -Force | Out-Null + foreach ($name in @('host.json', 'function_app.py', 'requirements.txt')) { + Copy-Item -LiteralPath (Join-Path $source $name) -Destination $stage + } + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $source 'src') -Recurse -File -Filter '*.py') { + $relative = [IO.Path]::GetRelativePath($source, $file.FullName) + if ($relative -match '(^|[\\/])(tests?|__pycache__|\.venv|venv)([\\/]|$)') { continue } + $destination = Join-Path $stage $relative + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $destination + } + if (-not (Test-Path -LiteralPath (Join-Path $stage 'src/dispatch.py'))) { throw 'Missing Python application source.' } + $zip = Join-Path $temporary 'app.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($stage, $zip) + New-Item -ItemType Directory -Path (Split-Path $archive) -Force | Out-Null + [IO.File]::Move($zip, $archive) + Write-Host 'Python source ZIP created. Deploy with Azure remote build to install Linux dependencies; not ready for direct run-from-package.' + Get-Item -LiteralPath $archive | Select-Object FullName, Length +} finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } +} \ No newline at end of file From 4f32003e1e4fec82ce1d768053dc060f12a7275e Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Mon, 14 Sep 2026 15:02:45 -0700 Subject: [PATCH 2/3] Publish downloadable Function ZIPs through GitHub Releases --- .github/workflows/packages.yml | 105 +++++++++++++++++++++++++++++++++ README.md | 30 +++++++++- docs/ONBOARDING.md | 7 ++- 3 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/packages.yml diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000..d8f44df --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,105 @@ +name: Function ZIPs + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: function-zips-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22.x' + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '8.0.x' + - name: Test JavaScript + working-directory: javascript + run: | + npm ci --ignore-scripts --no-audit --no-fund + npm test + - name: Test Python + working-directory: python + run: | + python -m pip install -r requirements.txt pytest + python -m pytest tests + - name: Test .NET + run: dotnet test dotnet/tests/Epp.Otp.Tests.csproj + - name: Build ZIPs + shell: pwsh + run: | + ./package-javascript.ps1 + ./package-dotnet.ps1 + ./package-python.ps1 + - name: Verify ZIPs and write checksums + shell: pwsh + run: | + $required = @{ + 'epp-javascript.zip' = @('host.json', 'src/functions/SendOtp.js', 'node_modules/@azure/functions/package.json') + 'epp-dotnet.zip' = @('host.json', 'dotnet.dll', 'functions.metadata', 'worker.config.json') + 'epp-python-source.zip' = @('host.json', 'function_app.py', 'requirements.txt', 'src/dispatch.py') + } + $checksums = foreach ($name in ($required.Keys | Sort-Object)) { + $path = Join-Path 'artifacts' $name + $zip = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $path)) + try { + $entries = @($zip.Entries.FullName) + foreach ($file in $required[$name]) { + if ($file -notin $entries) { throw "Missing $file in $name" } + } + if ($entries -match '(^|/)(local\.settings[^/]*|\.env[^/]*|\.git|\.venv|__pycache__)(/|$)|\.(pem|pfx|p12|key|publishsettings|pubxml)$') { + throw "Private files in $name" + } + if ($name -eq 'epp-dotnet.zip' -and -not ($entries -like '.azurefunctions/*')) { + throw 'Missing .NET extension output' + } + } finally { $zip.Dispose() } + '{0} {1}' -f (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant(), $name + } + $checksums | Set-Content artifacts/SHA256SUMS.txt -Encoding utf8 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: function-zips + path: | + artifacts/*.zip + artifacts/SHA256SUMS.txt + if-no-files-found: error + retention-days: 14 + + publish: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: function-zips + path: artifacts + - name: Publish versioned ZIP downloads + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + RUN_NUMBER: ${{ github.run_number }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + tag="epp-packages-${RUN_NUMBER}-${RUN_ATTEMPT}" + gh release create "$tag" artifacts/*.zip artifacts/SHA256SUMS.txt \ + --target "$COMMIT_SHA" --title "EPP Function ZIPs ${RUN_NUMBER}.${RUN_ATTEMPT}" \ + --notes "Built and tested from commit ${COMMIT_SHA}. Download one language ZIP and verify it against SHA256SUMS.txt. JavaScript includes production dependencies; .NET includes Release publish output. Python is a source ZIP requiring Azure remote build on Linux, not a direct run-from-package artifact. Configure runtime, app settings, Key Vault access, and Easy Auth separately. No cloud deployment or live provider verification is performed." \ No newline at end of file diff --git a/README.md b/README.md index 3cae35b..e8fa5b0 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,35 @@ by default. Deploy each language separately, not all three to the same Function New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing, and deploying, step by step. -## Package a Function +## Download a Function ZIP -Run the script for your chosen language from the repository root. These standalone scripts create +Download the ZIP for your chosen language from the +[preview release](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/tag/epp-packages-preview-20260914): + +| Language | Download | Contents | +|---|---|---| +| JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-javascript.zip) | Application and production dependencies | +| .NET | [epp-dotnet.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-dotnet.zip) | Release publish output | +| Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-python-source.zip) | Source for Azure remote build on Linux | + +Customers do not need PowerShell or a local build toolchain to download these files. Verify downloads +against the release's `SHA256SUMS.txt`. Configure the target Function App's runtime, app settings, +Key Vault access, and Easy Auth before deploying. Python requires remote build to install dependencies; +its source ZIP cannot run directly as a run-from-package artifact. GitHub's **Code > Download ZIP** +is the whole source repository, not a Function deployment package. + +After the packaging workflow is merged, each successful `main` build tests all three implementations, +builds and inspects the ZIPs, and publishes a new versioned release. Get those builds from +[Latest release](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest). +Older releases remain available; existing assets are not overwritten. Pull requests build downloadable +workflow artifacts only and cannot publish releases. GitHub sign-in may be required for workflow +artifacts, but public release downloads do not require a local build. Packaging does not deploy or +verify live provider delivery. The current preview is built from the packaging branch, not a merged +release of the separate provider feature branches. + +## Build ZIPs Locally + +For custom builds, run the script for your chosen language from the repository root. These standalone scripts create ZIPs locally; they do not sign in to Azure, upload code, or change app settings. | Language | Root-level script | Prerequisites | ZIP in `artifacts/` | diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 8c07fbf..91eb70e 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -149,9 +149,10 @@ digit spacing, without guessing a passcode. A timed-out send may already be acce ## 4. Package, deploy and verify -Run the [root-level packaging script](../README.md#package-a-function) for your chosen language: -`package-javascript.ps1`, `package-dotnet.ps1`, or `package-python.ps1`. Each writes a separate ZIP -under `artifacts/`; none deploys it. The Python source ZIP requires Azure remote build on Linux. +Download your language's [Function ZIP](../README.md#download-a-function-zip) from GitHub Releases. +No local packaging tools are required. For custom builds, use the +[root-level packaging scripts](../README.md#build-zips-locally). The Python source ZIP requires Azure +remote build on Linux. Downloading or building a ZIP does not deploy it. Build and publish only the chosen language folder, retaining runtime dependencies or using a supported remote build. Configure and verify Easy Auth before publishing; keep public ingress disabled until From 753d06fbad81d9b9b52ea26915964e8835f5c24d Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Tue, 15 Sep 2026 10:03:46 -0700 Subject: [PATCH 3/3] Package .NET Function source and verify ZIP safeguards in CI --- .github/workflows/packages.yml | 53 ++++++++++++++++++++++++++++++---- README.md | 25 ++++++++++------ docs/ONBOARDING.md | 6 ++-- package-dotnet.ps1 | 29 ++++++++++--------- 4 files changed, 84 insertions(+), 29 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index d8f44df..6309f0c 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -45,12 +45,36 @@ jobs: ./package-javascript.ps1 ./package-dotnet.ps1 ./package-python.ps1 + - name: Verify existing ZIPs are never overwritten + shell: pwsh + run: | + $packages = @{ + 'package-javascript.ps1' = 'artifacts/epp-javascript.zip' + 'package-dotnet.ps1' = 'artifacts/epp-dotnet-source.zip' + 'package-python.ps1' = 'artifacts/epp-python-source.zip' + } + foreach ($script in ($packages.Keys | Sort-Object)) { + $archive = (Resolve-Path -LiteralPath $packages[$script]).Path + $before = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash + $rejected = $false + try { + & (Join-Path $PWD $script) -OutputPath $archive + } catch { + if (-not $_.Exception.Message.StartsWith('Output already exists:')) { throw } + $rejected = $true + } + if (-not $rejected) { throw "$script did not reject an existing archive" } + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $before) { + throw "$script changed the existing archive" + } + Write-Host "$script rejected the existing output; SHA256 unchanged." + } - name: Verify ZIPs and write checksums shell: pwsh run: | $required = @{ 'epp-javascript.zip' = @('host.json', 'src/functions/SendOtp.js', 'node_modules/@azure/functions/package.json') - 'epp-dotnet.zip' = @('host.json', 'dotnet.dll', 'functions.metadata', 'worker.config.json') + 'epp-dotnet-source.zip' = @('host.json', 'dotnet.csproj', 'Program.cs', 'Functions/SendOtp.cs', 'Src/DispatchEngine.cs') 'epp-python-source.zip' = @('host.json', 'function_app.py', 'requirements.txt', 'src/dispatch.py') } $checksums = foreach ($name in ($required.Keys | Sort-Object)) { @@ -64,18 +88,37 @@ jobs: if ($entries -match '(^|/)(local\.settings[^/]*|\.env[^/]*|\.git|\.venv|__pycache__)(/|$)|\.(pem|pfx|p12|key|publishsettings|pubxml)$') { throw "Private files in $name" } - if ($name -eq 'epp-dotnet.zip' -and -not ($entries -like '.azurefunctions/*')) { - throw 'Missing .NET extension output' + if ($name -eq 'epp-dotnet-source.zip' -and + ($entries -match '(^|/)(bin|obj|tests?|\.azurefunctions)(/|$)|\.(dll|exe|pdb|deps\.json|runtimeconfig\.json)$|(^|/)(functions\.metadata|worker\.config\.json)$')) { + throw 'Build output or tests found in .NET source ZIP' } } finally { $zip.Dispose() } '{0} {1}' -f (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant(), $name } $checksums | Set-Content artifacts/SHA256SUMS.txt -Encoding utf8 + - name: Verify .NET source ZIP can be published by customers + shell: pwsh + run: | + $temporary = Join-Path ([IO.Path]::GetTempPath()) ('epp-source-check-' + [guid]::NewGuid().ToString('N')) + try { + $source = Join-Path $temporary 'source' + $publish = Join-Path $temporary 'publish' + [IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path 'artifacts/epp-dotnet-source.zip'), $source) + dotnet publish (Join-Path $source 'dotnet.csproj') --configuration Release --output $publish --verbosity minimal + if ($LASTEXITCODE -ne 0) { throw 'Extracted .NET source could not be published' } + foreach ($file in @('host.json', 'dotnet.dll', 'functions.metadata', 'worker.config.json', '.azurefunctions')) { + if (-not (Test-Path -LiteralPath (Join-Path $publish $file))) { throw "Missing customer publish output: $file" } + } + } finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } + } - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: function-zips path: | - artifacts/*.zip + artifacts/epp-javascript.zip + artifacts/epp-dotnet-source.zip + artifacts/epp-python-source.zip artifacts/SHA256SUMS.txt if-no-files-found: error retention-days: 14 @@ -102,4 +145,4 @@ jobs: tag="epp-packages-${RUN_NUMBER}-${RUN_ATTEMPT}" gh release create "$tag" artifacts/*.zip artifacts/SHA256SUMS.txt \ --target "$COMMIT_SHA" --title "EPP Function ZIPs ${RUN_NUMBER}.${RUN_ATTEMPT}" \ - --notes "Built and tested from commit ${COMMIT_SHA}. Download one language ZIP and verify it against SHA256SUMS.txt. JavaScript includes production dependencies; .NET includes Release publish output. Python is a source ZIP requiring Azure remote build on Linux, not a direct run-from-package artifact. Configure runtime, app settings, Key Vault access, and Easy Auth separately. No cloud deployment or live provider verification is performed." \ No newline at end of file + --notes "Packaged and tested from commit ${COMMIT_SHA}. Download one language ZIP and verify it against SHA256SUMS.txt. JavaScript includes production dependencies. .NET contains project and C# source only: extract and build/publish the project before deployment. Python is a source ZIP requiring Azure remote build on Linux. Neither source ZIP is a direct run-from-package artifact. Configure runtime, app settings, Key Vault access, and Easy Auth separately. No cloud deployment or live provider verification is performed." \ No newline at end of file diff --git a/README.md b/README.md index e8fa5b0..9b04ac9 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,19 @@ and deploying, step by step. ## Download a Function ZIP -Download the ZIP for your chosen language from the -[preview release](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/tag/epp-packages-preview-20260914): +Download the preview ZIP for your chosen language: | Language | Download | Contents | |---|---|---| | JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-javascript.zip) | Application and production dependencies | -| .NET | [epp-dotnet.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-dotnet.zip) | Release publish output | +| .NET | [epp-dotnet-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-dotnet-source-preview-20260915/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | | Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-python-source.zip) | Source for Azure remote build on Linux | Customers do not need PowerShell or a local build toolchain to download these files. Verify downloads -against the release's `SHA256SUMS.txt`. Configure the target Function App's runtime, app settings, -Key Vault access, and Easy Auth before deploying. Python requires remote build to install dependencies; -its source ZIP cannot run directly as a run-from-package artifact. GitHub's **Code > Download ZIP** +against the corresponding release's `SHA256SUMS.txt`. Configure the target Function App's runtime, app settings, +Key Vault access, and Easy Auth before deploying. .NET requires building/publishing the extracted +project; Python requires remote build to install dependencies. Neither source ZIP can run directly +as a run-from-package artifact. GitHub's **Code > Download ZIP** is the whole source repository, not a Function deployment package. After the packaging workflow is merged, each successful `main` build tests all three implementations, @@ -59,7 +59,7 @@ ZIPs locally; they do not sign in to Azure, upload code, or change app settings. | Language | Root-level script | Prerequisites | ZIP in `artifacts/` | |---|---|---|---| | JavaScript | [package-javascript.ps1](package-javascript.ps1) | PowerShell 7+, Node.js 20 or 22 with npm, npm registry access | `epp-javascript.zip` | -| .NET | [package-dotnet.ps1](package-dotnet.ps1) | PowerShell 7+, .NET 8 SDK, NuGet feed access | `epp-dotnet.zip` | +| .NET | [package-dotnet.ps1](package-dotnet.ps1) | PowerShell 7+ to package; .NET 8 SDK and NuGet feed access when customers build | `epp-dotnet-source.zip` | | Python | [package-python.ps1](package-python.ps1) | PowerShell 7+; Azure remote build required when deploying | `epp-python-source.zip` | ```powershell @@ -76,8 +76,15 @@ first-party tests are excluded. Generated ZIPs are ignored by Git. JavaScript installs production dependencies from the lockfile in a temporary folder; your working `node_modules` is not copied or modified. Dependency lifecycle scripts are disabled for this sample's JavaScript dependencies. If you add native dependencies or packages requiring install scripts, -review packaging and build them for the target Azure OS. .NET packages fresh Release publish output, -including `.azurefunctions`, rather than an old `bin/` directory. +review packaging and build them for the target Azure OS. + +**.NET is a source ZIP, not compiled output.** It contains `dotnet.csproj`, `host.json`, `Program.cs`, +and the C# files under `Functions/` and `Src/`. Packaging does not run restore, build, or publish, +and needs no .NET SDK. It excludes `bin/`, `obj/`, tests, local settings, and compiled dependencies. +Customers extract it and run `dotnet publish dotnet.csproj --configuration Release --output ../publish` +with the .NET 8 SDK, or use a deployment pipeline that builds the project. Deploy the resulting +publish output with `host.json` at its root, not the source ZIP directly. CI tests this customer +build from an extracted copy; that temporary publish output is not included in the download. **Python is a source ZIP, not a ready-to-run package.** Deploy to a Linux Function App with remote build enabled in the deployment tool for your hosting plan, so Azure installs `requirements.txt`. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 91eb70e..a10d438 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -151,8 +151,10 @@ digit spacing, without guessing a passcode. A timed-out send may already be acce Download your language's [Function ZIP](../README.md#download-a-function-zip) from GitHub Releases. No local packaging tools are required. For custom builds, use the -[root-level packaging scripts](../README.md#build-zips-locally). The Python source ZIP requires Azure -remote build on Linux. Downloading or building a ZIP does not deploy it. +[root-level packaging scripts](../README.md#build-zips-locally). The .NET source ZIP must be extracted +and built/published with the .NET 8 SDK or a build-enabled deployment pipeline. The Python source ZIP +requires Azure remote build on Linux. Neither source ZIP is ready for direct run-from-package. +Downloading or building a ZIP does not deploy it. Build and publish only the chosen language folder, retaining runtime dependencies or using a supported remote build. Configure and verify Easy Auth before publishing; keep public ingress disabled until diff --git a/package-dotnet.ps1 b/package-dotnet.ps1 index 56bc1fe..bfcbaf2 100644 --- a/package-dotnet.ps1 +++ b/package-dotnet.ps1 @@ -1,33 +1,36 @@ #Requires -Version 7.0 [CmdletBinding()] param( - [string]$OutputPath = (Join-Path $PSScriptRoot 'artifacts/epp-dotnet.zip') + [string]$OutputPath = (Join-Path $PSScriptRoot 'artifacts/epp-dotnet-source.zip') ) $ErrorActionPreference = 'Stop' -$project = Join-Path $PSScriptRoot 'dotnet/dotnet.csproj' +$source = Join-Path $PSScriptRoot 'dotnet' $archive = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) if (Test-Path -LiteralPath $archive) { throw "Output already exists: $archive. Choose another -OutputPath." } -$dotnet = (Get-Command dotnet -ErrorAction Stop).Source $temporary = Join-Path ([IO.Path]::GetTempPath()) ('epp-dotnet-' + [guid]::NewGuid().ToString('N')) $stage = Join-Path $temporary 'app' try { - & $dotnet publish $project --configuration Release --output $stage --verbosity minimal - if ($LASTEXITCODE -ne 0) { throw '.NET publish failed; no ZIP created.' } - foreach ($name in @('host.json', 'functions.metadata', 'worker.config.json', 'dotnet.dll', '.azurefunctions')) { - if (-not (Test-Path -LiteralPath (Join-Path $stage $name))) { throw "Missing published runtime file: $name" } + New-Item -ItemType Directory -Path $stage -Force | Out-Null + foreach ($name in @('host.json', 'dotnet.csproj', 'Program.cs')) { + Copy-Item -LiteralPath (Join-Path $source $name) -Destination $stage } - $unsafe = @(Get-ChildItem -LiteralPath $stage -Recurse -Force -File | Where-Object { - $_.Name -like 'local.settings*' -or $_.Name -like '.env*' -or - $_.Extension -in @('.pem', '.pfx', '.p12', '.key', '.publishsettings', '.pubxml') -or - [IO.Path]::GetRelativePath($stage, $_.FullName) -match '(^|[\\/])(tests?|scripts)([\\/]|$)' - }) - if ($unsafe.Count) { throw 'Local settings, credentials, or test files found in publish output; no ZIP created.' } + foreach ($folder in @('Functions', 'Src')) { + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $source $folder) -Recurse -File -Filter '*.cs') { + $relative = [IO.Path]::GetRelativePath($source, $file.FullName) + if ($relative -match '(^|[\\/])(tests?|bin|obj)([\\/]|$)') { continue } + $destination = Join-Path $stage $relative + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $destination + } + } + if (-not (Test-Path -LiteralPath (Join-Path $stage 'Functions/SendOtp.cs'))) { throw 'Missing .NET function source.' } $zip = Join-Path $temporary 'app.zip' [IO.Compression.ZipFile]::CreateFromDirectory($stage, $zip) New-Item -ItemType Directory -Path (Split-Path $archive) -Force | Out-Null [IO.File]::Move($zip, $archive) + Write-Host '.NET source ZIP created. Build/publish the extracted project before deployment; not ready for direct run-from-package.' Get-Item -LiteralPath $archive | Select-Object FullName, Length } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force }