Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
@@ -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."
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) →
Expand Down
7 changes: 7 additions & 0 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions package-dotnet.ps1
Original file line number Diff line number Diff line change
@@ -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 }
}
43 changes: 43 additions & 0 deletions package-javascript.ps1
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading