azure-devops-extensionThis repository contains the Locally extension for Azure DevOps, which adds several Azure Pipelines tasks for Locally Build:
These are the Azure Pipelines equivalent of the Azure DevOps Services (dev.azure.com) is supported, on Microsoft-hosted and self-hosted Linux, macOS and Windows agents (x64 and arm64). Azure DevOps Server can't issue OIDC tokens, so it can only be used with a CI Key. Installing the extensionInstall the Locally extension from the Visual Studio Marketplace into your Azure DevOps Organisation. Once it's installed, every pipeline in that Organisation can use the tasks above. OIDC Configuration for a Team/User (obtaining
|
| Claim | New service connections (Entra issuer) | Older service connections |
|---|---|---|
Issuer (iss) |
https://login.microsoftonline.com/<tenant ID>/v2.0 |
https://vstoken.dev.azure.com/<organisation ID> |
Subject (sub) |
/eid1/c/pub/t/<...>/sc/<organisation ID>/<service connection ID> |
sc://<organisation>/<project>/<service connection name> |
Audience (aud) |
fb60f99c-7a34-4190-8149-302f77469936 |
api://AzureADTokenExchange |
If the trust doesn't match, locally ci fails and prints the issuer and subject it was given, so you can compare them with the trust.
Example: Using SetupLocally@1
To use Locally against your Team, you can use the following Azure Pipelines configuration:
steps:
- task: SetupLocally@1
inputs:
serviceConnection: my-service-connection # see above
teamUUID: 'abc123'
# plugins: recommended # default; or `none`, `all`, `custom`
To use Locally in CI against your User, you can use the following Azure Pipelines configuration:
steps:
- task: SetupLocally@1
inputs:
serviceConnection: my-service-connection # see above
userUUID: 'bcd234' # see above
# plugins: recommended # default; or `none`, `all`, `custom`
By default, both of these will install the Recommended set of Plugins, but you can specify a custom list to install for example:
steps:
- task: SetupLocally@1
inputs:
serviceConnection: my-service-connection
teamUUID: $(LOCALLY_TEAM_UUID)
plugins: custom
pluginsList: |
Microsoft.ServiceBus
Microsoft.Storage
pluginsList accepts one plugin per line (as above) or a comma-separated list (Microsoft.ServiceBus,Microsoft.Storage). The newline form is easier to read and gives cleaner diffs as the list grows.
The full list of arguments:
| Name | Required | Default | Description |
|---|---|---|---|
authentication |
no | serviceConnection |
How locally ci signs in: serviceConnection (OIDC) or ciKey |
serviceConnection |
when authentication=serviceConnection |
'' |
An Azure Resource Manager service connection that uses Workload Identity Federation |
ciKey |
when authentication=ciKey |
'' |
A CI Key, passed in from a secret variable |
teamUUID |
one of¹ | '' |
Locally team UUID (from account.locally.build) |
userUUID |
one of¹ | '' |
Locally user UUID (from account.locally.build) |
version |
no | latest |
CLI version (latest or e.g. v2026.11) |
plugins |
no | recommended |
One of none, recommended, all, custom |
pluginsList |
when plugins=custom |
'' |
Comma- or newline-separated plugin list |
start |
no | true |
Generate TLS, install plugins, start locally ci. Set to false to only install the CLI |
configureDNS |
no | true |
Route *.locally to Locally's DNS once started, and check it resolves. Needs passwordless sudo on Linux and macOS agents, which Microsoft-hosted agents have |
acceptSqlServerLicence |
no | false |
Accept the Microsoft SQL Server licence by setting LOCALLY_ACCEPT_SQL_SERVER_LICENCE - must be explicitly set to true |
containerRuntime |
no | auto |
Container runtime for locally ci (auto, docker or podman), set as LOCALLY_CONTAINER_RUNTIME. auto uses docker on Microsoft-hosted Linux agents and Locally's own detection elsewhere. Needs Locally 2026.10.01 or later |
expectedChecksum |
no | '' |
SHA-256 to verify CLI tarball against |
¹ With a service connection, exactly one of teamUUID or userUUID must be set, depending on whether the OIDC trust is configured at team or user scope. Setting both fails; setting neither fails. With a CI Key neither is needed, since the key says which team or user it belongs to.
See tasks/SetupLocallyV1/task.json for the full input reference.
Example: Using SetupLocally@1 with a CI Key
Where a service connection isn't an option (Azure DevOps Server, for example), you can create a CI Key in your Locally Account, store it as a secret variable in the pipeline, and pass it in:
steps:
- task: SetupLocally@1
inputs:
authentication: ciKey
ciKey: $(LOCALLY_CI_KEY)
A CI Key is a long-lived secret that you have to store and rotate, so we'd recommend using OIDC where possible.
Using Locally in later steps
Once SetupLocally@1 has run, locally is on the PATH and later steps in the job find the running locally ci without any further configuration. Use locally run to run your tools against it, which hands them Locally's CA, credentials and endpoints:
steps:
- task: SetupLocally@1
inputs:
serviceConnection: my-service-connection
teamUUID: $(LOCALLY_TEAM_UUID)
- script: locally run terraform apply -auto-approve
displayName: Terraform Apply against Locally
- task: TeardownLocally@1
condition: always()
Anything run outside of locally run (a bare curl, say) needs to trust Locally's CA, which is at $(LOCALLY_TLS_CA_FILE_PATH).
SetupLocally@1 sets the following variables for later steps:
| Variable | Description |
|---|---|
LOCALLY_CLI_PATH |
The absolute path to the Locally CLI |
LOCALLY_WORKING_DIRECTORY |
Locally's working directory, which contains ci.log |
LOCALLY_PLUGIN_DIRECTORY |
Where plugins are installed |
LOCALLY_TLS_CA_FILE_PATH |
The CA certificate that Locally's endpoints are signed by |
LOCALLY_TLS_CERT_FILE_PATH / LOCALLY_TLS_KEY_FILE_PATH |
The certificate and key Locally serves |
LOCALLY_TEAM_UUID / LOCALLY_USER_UUID |
Whichever of teamUUID or userUUID was set |
LOCALLY_ACCEPT_SQL_SERVER_LICENCE |
true, only when acceptSqlServerLicence was set |
LOCALLY_CONTAINER_RUNTIME |
The container runtime, when one was chosen |
The OIDC token and the CI Key are never set as variables: they're only passed to the locally processes that sign in.
Caching plugins
The GitHub Action caches the plugin directory itself. An Azure Pipelines task can't do that, so to avoid downloading plugins on every run, add the Cache@2 task before SetupLocally@1:
steps:
- task: Cache@2
inputs:
key: 'locally-plugins | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | v2026.11 | recommended'
path: $(Agent.TempDirectory)/locally-plugins
- task: SetupLocally@1
inputs:
serviceConnection: my-service-connection
teamUUID: $(LOCALLY_TEAM_UUID)
version: v2026.11
Include the Locally version and the plugin set in the key, so that changing either starts a fresh cache.
Example: Using InstallLocallyPlugins@1
Use this task to install Locally plugins later in a job, for example after running SetupLocally@1 with plugins: none. It requires SetupLocally@1 to have run earlier in the job, and reuses its sign-in.
steps:
- task: InstallLocallyPlugins@1
inputs:
plugins: custom
pluginsList: |
Microsoft.ServiceBus
Microsoft.Storage
As above, pluginsList takes one plugin per line or a comma-separated list.
The full list of arguments:
| Name | Required | Default | Description |
|---|---|---|---|
plugins |
no | none |
One of none, recommended, all, custom |
pluginsList |
when plugins=custom |
'' |
Comma- or newline-separated plugin list |
acceptSqlServerLicence |
no | false |
Accept the Microsoft SQL Server licence by setting LOCALLY_ACCEPT_SQL_SERVER_LICENCE - must be explicitly set to true (otherwise the variable set by SetupLocally@1 is inherited) |
authentication |
no | inherit |
inherit reuses the sign-in from SetupLocally@1. Set serviceConnection or ciKey only when SetupLocally@1 ran with start: false, and so never signed in |
serviceConnection |
when authentication=serviceConnection |
'' |
An Azure Resource Manager service connection that uses Workload Identity Federation |
ciKey |
when authentication=ciKey |
'' |
A CI Key, passed in from a secret variable |
teamUUID |
no¹ | '' |
Locally team UUID (falls back to the LOCALLY_TEAM_UUID variable set by SetupLocally@1) |
userUUID |
no¹ | '' |
Locally user UUID (falls back to the LOCALLY_USER_UUID variable set by SetupLocally@1) |
¹ With authentication: serviceConnection, exactly one of teamUUID or userUUID must be resolvable (via input or inherited variable) for any value of plugins other than none. Setting both fails.
See tasks/InstallLocallyPluginsV1/task.json for the full input reference.
Example: Using TeardownLocally@1
Use this task as the final step of any job that called SetupLocally@1. It stops locally ci, removes the *.locally DNS configuration added by configureDNS, and, on job failure, uploads ci.log as a pipeline artifact for debugging.
On self-hosted agents this matters: without it, the agent is left routing *.locally to Locally's DNS port after Locally has stopped.
steps:
- task: TeardownLocally@1
condition: always()
# inputs:
# uploadLogsOnFailure: true # default
The full list of arguments:
| Name | Required | Default | Description |
|---|---|---|---|
uploadLogsOnFailure |
no | true |
Whether to upload ci.log as a pipeline artifact when the job has failed |
The artifact is named locally-ci-log-<job name>-<job attempt>, since artifact names have to be unique within a run.
See tasks/TeardownLocallyV1/task.json for the full input reference.
Example: Using SaveLocallySnapshot@1
Use this task to take a snapshot of all of the resources and data in Locally, to load again later or to hand to someone else. It requires SetupLocally@1 to have run earlier in the job, and reuses its sign-in. Snapshots need the Standard plan or above, and sharing them with a team needs the Team plan or above.
[!WARNING] Snapshots contain the credentials of your resources (Key Vault values, keys, passwords). Anyone who can download a pipeline artifact can read an unencrypted snapshot, so set
passphrase(from a secret variable) before uploading or sharing one.
steps:
- task: SaveLocallySnapshot@1
inputs:
name: deployed
upload: artifact
passphrase: $(LOCALLY_SNAPSHOT_PASSPHRASE)
upload takes a comma-separated list or one entry per line, so upload: artifact, team does both.
The full list of arguments:
| Name | Required | Default | Description |
|---|---|---|---|
name |
yes | The name to save the snapshot under: letters, digits, ., _ and -, starting with a letter or digit (at most 100 characters) |
|
description |
no | '' |
What this snapshot holds and why it was taken (one line, at most 500 characters) |
passphrase |
no | '' |
Encrypt the snapshot with this passphrase, passed in from a secret variable |
upload |
no | none |
Where to upload the snapshot once it's saved: any of none (keep it on the agent only), artifact (a pipeline artifact) and team (share it with your Locally Team) |
artifactName |
no | locally-snapshot-<name> |
The artifact's name, when upload includes artifact |
team |
no | '' |
The Locally Team's name or ID, when upload includes team (only needed when you're in more than one team) |
timeout |
no | '' |
Give up if running operations haven't finished within this time (a Go duration, e.g. 5m). Defaults to waiting until they finish |
Artifact names have to be unique within a run, so when more than one job uploads a snapshot, give each a different name (or artifactName).
Give the step a name to read its output variables from later steps, for example $(save.snapshotPath):
| Name | Description |
|---|---|
snapshotName |
The name the snapshot was saved under |
snapshotPath |
The path of the snapshot file on the agent |
artifactName |
The name of the artifact, when one was uploaded |
See tasks/SaveLocallySnapshotV1/task.json for the full input reference.
Example: Using LoadLocallySnapshot@1
Use this task to replace everything in Locally with a snapshot - to start a job from an environment that's already been deployed, say, or to put Locally back as it was between two sets of tests. It requires SetupLocally@1 to have run earlier in the job, and reuses its sign-in.
Everything in Locally is replaced, and only if the whole snapshot can be loaded: otherwise it's left as it was. Plugins that the snapshot needs and that aren't installed are installed first.
To put Locally back as it was earlier in the job:
steps:
- task: SaveLocallySnapshot@1
inputs:
name: before
- script: locally run terraform apply -auto-approve
- task: LoadLocallySnapshot@1
inputs:
snapshot: before
To load a snapshot that another pipeline (or an earlier run) uploaded as an artifact, download it with DownloadPipelineArtifact@2 and give snapshot the folder it was downloaded to. The task loads the one .locallysnap file in that folder:
steps:
- task: DownloadPipelineArtifact@2
inputs:
buildType: specific
project: $(System.TeamProject)
definition: <pipeline ID>
buildVersionToDownload: latestFromBranch
branchName: refs/heads/main
artifactName: locally-snapshot-deployed
targetPath: $(Pipeline.Workspace)/locally-snapshot
- task: LoadLocallySnapshot@1
inputs:
snapshot: $(Pipeline.Workspace)/locally-snapshot
passphrase: $(LOCALLY_SNAPSHOT_PASSPHRASE)
To load one shared with your Locally Team, which needs the Team plan or above:
steps:
- task: LoadLocallySnapshot@1
inputs:
snapshot: shared-baseline
source: team
passphrase: $(LOCALLY_SNAPSHOT_PASSPHRASE)
A team's snapshot is downloaded under a name of its own, loaded, then removed from the agent, so it can't replace a snapshot that's already there.
The full list of arguments:
| Name | Required | Default | Description |
|---|---|---|---|
snapshot |
yes | With source: local, a snapshot's name, the path of a .locallysnap file, or the path of a folder holding exactly one. With source: team, the name of a snapshot shared with your Locally Team |
|
source |
no | local |
Where the snapshot comes from: local (on the agent) or team |
team |
no | '' |
The Locally Team's name or ID, with source: team (only needed when you're in more than one team) |
passphrase |
when the snapshot is encrypted | '' |
The snapshot's passphrase, passed in from a secret variable |
timeout |
no | '' |
Give up if running operations haven't finished within this time (a Go duration, e.g. 5m). Defaults to waiting until they finish |
See tasks/LoadLocallySnapshotV1/task.json for the full input reference.
Development
npm install
npm test # unit tests
npm run build # type-check, and bundle each task into tasks/<task>/index.js
npm run package # out/LocallyBuild.locally-<version>.vsix
Each task is a small entry point (src/setup.ts, src/install-plugins.ts, src/teardown.ts, src/snapshot-save.ts, src/snapshot-load.ts) around a flow (src/*-flow.ts) that's written against an interface rather than the agent, so that the whole flow can be unit tested.
Trying the tasks without publishing the extension
Azure Pipelines only runs tasks that an Organisation has installed, normally from a Marketplace extension. To try a change without publishing, npm run upload installs the tasks straight into one Organisation: it builds them, then uploads each task's folder (its task.json, the bundled index.js and the azure-pipelines-task-lib it needs at runtime) with tfx build tasks upload. Every pipeline in that Organisation can then use them.
It needs a personal access token (User settings -> Personal access tokens) with the Agent Pools (Read & manage) scope:
npm ci
LOCALLY_ADO_ORG_URL=https://dev.azure.com/<organisation> LOCALLY_ADO_PAT=<token> npm run upload
Run it again after changing a task, having bumped Patch in that task's task.json: an Organisation won't take a second upload of the same version, so a task whose version is already there is skipped.
This repository's azure-pipelines.yml tries all five tasks with one job per platform: by default linux_amd64, windows_amd64 and darwin_amd64 (Azure Pipelines' Microsoft-hosted macOS agents are Intel), which you can change when queueing a run. By default it signs in with a CI Key, which needs one secret variable, LOCALLY_CI_KEY. To try OIDC instead, choose serviceConnection when queueing a run and name a Workload Identity Federation service connection; that needs a trust for the service connection in your Locally Account, and the variable LOCALLY_TEAM_UUID. The snapshot tasks need the Locally Account to be on the Standard plan or above.
To remove the tasks again, run npx tfx build tasks delete --task-id <id> --service-url https://dev.azure.com/<organisation> --token <token> for each. The IDs are in each task.json.
Publishing the extension
The publish workflow packages the extension and publishes it to the Visual Studio Marketplace, as the version in vss-extension.json. To release:
- Bump
versioninvss-extension.json, andPatch(orMinor) in thetask.jsonof each task that's changed, and updateCHANGELOG.md. - Once that's on
main, push a tag for the version, for examplev1.0.1for1.0.1. The workflow fails if the tag and the version don't match.
There's no token to store or rotate: it uses the Marketplace's Trusted Publishing, which exchanges GitHub's OIDC token for a Marketplace credential that lasts 15 minutes. That needs a policy on the LocallyBuild publisher's Trusted Publishing page, in the Marketplace's publisher management, with locallybuild as the repository owner, azure-devops-extension as the repository and publish.yml as the workflow.
License
This extension is licensed under Apache-2.0. The Locally CLI it installs is governed by separate terms, see NOTICE.