diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000..6309f0c --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,148 @@ +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 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-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)) { + $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-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/epp-javascript.zip + artifacts/epp-dotnet-source.zip + artifacts/epp-python-source.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 "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 3a5cfbc..9b04ac9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,84 @@ 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. +## Download a Function ZIP + +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-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 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, +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/` | +|---|---|---|---| +| 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+ 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 +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 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`. +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..a10d438 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -149,6 +149,13 @@ digit spacing, without guessing a passcode. A timed-out send may already be acce ## 4. Package, deploy and verify +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 .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 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..bfcbaf2 --- /dev/null +++ b/package-dotnet.ps1 @@ -0,0 +1,37 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [string]$OutputPath = (Join-Path $PSScriptRoot 'artifacts/epp-dotnet-source.zip') +) + +$ErrorActionPreference = 'Stop' +$source = Join-Path $PSScriptRoot 'dotnet' +$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-dotnet-' + [guid]::NewGuid().ToString('N')) +$stage = Join-Path $temporary 'app' + +try { + 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 + } + 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 } +} \ 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