Skip to content
Draft
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
59 changes: 59 additions & 0 deletions Build/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Pipeline artifact identities

Product artifacts use `<logical-name>_attempt$(System.JobAttempt)` as their
published Azure Pipelines names. The suffix belongs to the **producing job**;
retrying a consumer does not change which producer output it needs.

| Logical name | Producer | In-tree reader |
| --- | --- | --- |
| `cpptools.vsix` | `cg/cg.yml` | None (diagnostic output) |
| `vsix` | `package/jobs_package_vsix.yml` | `publish/jobs_publish_vsix.yml` |
| `unsigned_lldb-mi_<arch>` | `lldb-mi/lldb-mi.template.yml` | `lldb-mi/lldb-mi-sign.template.yml` |
| `lldb-mi_<arch>_zip` | `lldb-mi/lldb-mi-sign.template.yml` | None |

Architecture suffixes are part of the logical name. Append the attempt suffix
once, after the complete logical name. Only the remote artifact name changes;
VSIX/ZIP filenames, local directories, signing inputs, and package contents do
not include the attempt suffix.

## Resolve before downloading

[`templates/resolve_artifact.yml`](templates/resolve_artifact.yml) is a step
template with inline PowerShell, so it also works in jobs with `checkout: none`.
It reads Azure Pipelines artifact and timeline metadata using the job access
token; it does not download artifact contents.

- Supply `artifactName`, an `artifactNames` list, or `artifactNamesJson` for a
list computed at runtime, using logical names without attempt suffixes.
- Supply an exact `buildId` and `project`, or a `definition` with an exact
`branch` and optional `tags`. The defaults select the current build/project.
A pipeline resource reader should pass that resource's `runID` and `projectID`.
- `allowPartiallySucceededBuilds` and `allowFailedBuilds` opt into those results
when selecting the latest build. `allowFailedBuilds` also permits artifacts
from failed producing jobs. `allowMissing` omits unavailable artifacts instead
of failing; it never permits fallback to an earlier attempt.
- The default variables are `$(ResolvedArtifactName)`,
`$(ResolvedArtifactBuildId)`, and `$(ResolvedArtifactPatterns)`. Use
`variablePrefix` to give another prefix. `Name` is populated for a single
selected artifact; `Patterns` contains one `<resolved-name>/**` line per
artifact. Keep downloads pinned to the resolved build ID.
- Set `stepName` to expose `Name`, `BuildId`, and `Patterns` as job outputs.
Release jobs resolve metadata in a preceding normal job, then map the output
through `dependencies` to a variable in their existing typed artifact input.
This preserves the release template's artifact and SBOM validation.

The resolver matches an artifact's `source` to the current producing timeline
job ID. `previousAttempts` connects older outputs to that job, so a retry that
publishes nothing cannot silently reuse a stale output. Historical fixed-name
artifacts are accepted only when they belong to the current producing job.

Names owned by external services or toolchains require their owning contract to
be verified before renaming; an absence of in-tree readers does not establish
that an artifact has no external consumers.

The localization pipeline's `drop` is deliberately retained pending confirmation
of its external service contract. Its local translation inputs and output
directories do not establish whether external handoff readers accept a renamed
pipeline artifact.

See [`tests`](tests/README.md) for offline resolver and artifact-contract checks.
2 changes: 1 addition & 1 deletion Build/cg/cg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ extends:
displayName: 'cpptools.vsix'
condition: succeeded()
targetPath: $(Build.ArtifactStagingDirectory)\Extension
artifactName: cpptools.vsix
artifactName: cpptools.vsix_attempt$(System.JobAttempt)

steps:
- checkout: self
Expand Down
8 changes: 6 additions & 2 deletions Build/lldb-mi/lldb-mi-sign.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ jobs:
- checkout: none

- ${{ each arch in parameters.archs }}:
- template: ../templates/resolve_artifact.yml
parameters:
artifactName: 'unsigned_lldb-mi_${{ arch }}'

- task: DownloadPipelineArtifact@1
displayName: 'Downloading unsigned_lldb-mi_${{ arch }}'
inputs:
targetPath: '$(Pipeline.Workspace)/Artifacts'
artifactName: 'unsigned_lldb-mi_${{ arch }}'
artifactName: $(ResolvedArtifactName)

- task: MicroBuildSignMacFiles@1
displayName: 'ESRP Sign'
Expand All @@ -26,5 +30,5 @@ jobs:
displayName: 'Publish signed LLDB-MI'
inputs:
targetPath: '$(Pipeline.Workspace)\Artifacts\lldb-mi_${{ arch }}.zip'
artifactName: 'lldb-mi_${{ arch }}_zip'
artifactName: 'lldb-mi_${{ arch }}_zip_attempt$(System.JobAttempt)'
...
2 changes: 1 addition & 1 deletion Build/lldb-mi/lldb-mi.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ jobs:
displayName: 'Publish LLDB-MI'
inputs:
targetPath: '$(Build.StagingDirectory)/lldb-mi_${{ parameters.llvm_arch }}.zip'
artifactName: 'unsigned_lldb-mi_${{ parameters.llvm_arch }}'
artifactName: 'unsigned_lldb-mi_${{ parameters.llvm_arch }}_attempt$(System.JobAttempt)'

- task: CmdLine@2
displayName: 'Clean up'
Expand Down
2 changes: 1 addition & 1 deletion Build/package/jobs_package_vsix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- output: pipelineArtifact
displayName: '${{ parameters.vsixName }}.vsix'
targetPath: $(Build.ArtifactStagingDirectory)\vsix
artifactName: vsix
artifactName: vsix_attempt$(System.JobAttempt)

steps:
- checkout: self
Expand Down
16 changes: 15 additions & 1 deletion Build/publish/jobs_publish_vsix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,29 @@ parameters:
default: ''

jobs:
- job: resolve_vsix_artifact
displayName: Resolve VSIX artifact
steps:
- checkout: none
- template: ../templates/resolve_artifact.yml
parameters:
artifactName: vsix
buildId: $(resources.pipeline.vsixBuild.runID)
project: $(resources.pipeline.vsixBuild.projectID)
stepName: resolve

- job: Publish
displayName: Publish to Marketplace
dependsOn: resolve_vsix_artifact
variables:
ResolvedVsixArtifactName: $[ dependencies.resolve_vsix_artifact.outputs['resolve.Name'] ]
templateContext:
type: releaseJob
isProduction: true
inputs:
- input: pipelineArtifact
pipeline: vsixBuild
artifactName: vsix
artifactName: $(ResolvedVsixArtifactName)
targetPath: $(Build.StagingDirectory)\vsix

steps:
Expand Down
242 changes: 242 additions & 0 deletions Build/templates/resolve_artifact.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
# Resolve artifact identities without downloading their contents. Release jobs
# consume the result through their normal, validated templateContext.inputs.
parameters:
- name: artifactName
type: string
default: ''
- name: artifactNames
type: object
default: []
- name: artifactNamesJson
type: string
default: ''
- name: buildId
type: string
default: '$(Build.BuildId)'
- name: project
type: string
default: '$(System.TeamProjectId)'
- name: definition
type: string
default: ''
- name: branch
type: string
default: ''
- name: tags
type: string
default: ''
- name: allowPartiallySucceededBuilds
type: boolean
default: false
- name: allowFailedBuilds
type: boolean
default: false
- name: allowMissing
type: boolean
default: false
- name: variablePrefix
type: string
default: ResolvedArtifact
- name: stepName
type: string
default: ''

steps:
- powershell: |
$ErrorActionPreference = 'Stop'

function Get-BuildMetadata([string] $path) {
# Never send the job token to an artifact download URL or a redirect.
try {
Invoke-RestMethod -Uri "$api/$path" -Headers $headers -Method Get -MaximumRedirection 0 -TimeoutSec 60
} catch {
throw 'Unable to read Azure Pipelines build metadata; no artifact was selected.'
}
}

function Set-ArtifactVariable([string] $name, [string] $value) {
$escaped = $value.Replace('%', '%AZP25').Replace("`r", '%0D').Replace("`n", '%0A')
Write-Host "##vso[task.setvariable variable=$($env:ARTIFACT_VARIABLE_PREFIX)$name]$escaped"
if ($env:ARTIFACT_STEP_NAME) {
Write-Host "##vso[task.setvariable variable=$name;isOutput=true]$escaped"
}
}

function Select-Artifact([string] $baseName) {
$pattern = '^' + [regex]::Escape($baseName) + '_attempt([1-9][0-9]*)\z'
$candidates = @($artifacts | Where-Object { $_.name -ceq $baseName -or $_.name -cmatch $pattern })
if ($candidates.Count -eq 0) {
return $null
}

# A retry has a new job ID. previousAttempts connects artifacts left by
# earlier attempts to the current producer, even if it published nothing.
$owners = @($jobs | Where-Object {
$ids = @($_.id) + @($_.previousAttempts | ForEach-Object { $_.recordId })
@($candidates | Where-Object { $_.source -and $_.source -in $ids }).Count -gt 0
})
if ($owners.Count -ne 1) {
throw "Cannot identify exactly one producing job for '$baseName'."
}
$producer = $owners[0]
$attempt = 0
if (-not [int]::TryParse([string]$producer.attempt, [ref]$attempt) -or $attempt -lt 1) {
throw "Invalid producing job attempt for '$baseName'."
}
$historyIds = @($producer.id)
$historyAttempts = @($attempt)
foreach ($previous in $producer.previousAttempts) {
$previousAttempt = 0
$recordId = [guid]::Empty
$timelineId = [guid]::Empty
if (-not [int]::TryParse([string]$previous.attempt, [ref]$previousAttempt) -or
$previousAttempt -lt 1 -or $previousAttempt -ge $attempt -or
-not [guid]::TryParse([string]$previous.recordId, [ref]$recordId) -or
-not [guid]::TryParse([string]$previous.timelineId, [ref]$timelineId) -or
$previous.recordId -in $historyIds -or $previousAttempt -in $historyAttempts) {
throw "Invalid or conflicting producing job history for '$baseName'."
}
$historyIds += $previous.recordId
$historyAttempts += $previousAttempt
}
$knownIds = @($producer.id) + @($producer.previousAttempts | ForEach-Object { $_.recordId })
if (@($candidates | Where-Object { -not $_.source -or $_.source -notin $knownIds }).Count -gt 0) {
throw "Unrecognized artifact provenance for '$baseName'."
}
if ($producer.state -ne 'completed') {
throw "The producing job for '$baseName' has not completed."
}
$allowedResults = @('succeeded', 'succeededWithIssues')
if ($env:ARTIFACT_ALLOW_FAILED -eq 'true') {
$allowedResults += 'failed'
}
if ($producer.result -notin $allowedResults) {
return $null
}

$attemptArtifacts = @($candidates | Where-Object { $_.name -cmatch $pattern })
if ($attemptArtifacts.Count -gt 0) {
$expectedName = "${baseName}_attempt$attempt"
} else {
# Historical runs can still be selected, but only if their fixed-name
# artifact belongs to the current attempt of the producing job.
$expectedName = $baseName
}
$selected = @($candidates | Where-Object { $_.name -ceq $expectedName -and $_.source -eq $producer.id })
if ($selected.Count -gt 1) {
throw "Duplicate artifact metadata for '$expectedName'."
}
if ($selected.Count -eq 1) {
return $selected[0].name
}
return $null
}

$collection = [uri]$env:SYSTEM_COLLECTIONURI
$validCollection = ($collection.Host -eq 'dev.azure.com' -and $collection.AbsolutePath -match '^/[^/]+/?$') -or
($collection.Host -match '^[a-z0-9-]+\.visualstudio\.com\z' -and $collection.AbsolutePath -in @('/', '/DefaultCollection/'))
if (-not $validCollection -or $collection.Scheme -ne 'https' -or -not $collection.IsDefaultPort -or
$collection.UserInfo -or $collection.Query -or $collection.Fragment) {
throw 'Expected an Azure DevOps collection URL.'
}
if (-not $env:SYSTEM_ACCESSTOKEN) {
throw 'The job access token is required to resolve artifact attempts.'
}
if ($env:ARTIFACT_VARIABLE_PREFIX -notmatch '^[A-Za-z_][A-Za-z0-9_]*\z') {
throw 'Invalid artifact variable prefix.'
}
# Windows PowerShell emits a JSON array as one pipeline object.
$names = @($env:ARTIFACT_NAMES | ConvertFrom-Json | ForEach-Object { $_ })
if ($env:ARTIFACT_NAMES_JSON) {
if ($env:ARTIFACT_NAME -or $names.Count -ne 0 -or -not $env:ARTIFACT_NAMES_JSON.TrimStart().StartsWith('[')) {
throw 'Specify exactly one logical artifact name, list, or JSON array.'
}
$names = @($env:ARTIFACT_NAMES_JSON | ConvertFrom-Json | ForEach-Object { $_ })
}
if ($env:ARTIFACT_NAME) {
if ($names.Count -ne 0) {
throw 'Specify artifactName or artifactNames, not both.'
}
$names = @($env:ARTIFACT_NAME)
}
if ($names.Count -eq 0 -or @($names | Where-Object { $_ -isnot [string] -or $_ -notmatch '^[A-Za-z0-9_.() -]+\z' }).Count -gt 0 -or
@($names | Select-Object -Unique).Count -ne $names.Count) {
throw 'Expected distinct logical artifact names without wildcards or path separators.'
}

$api = $collection.AbsoluteUri.TrimEnd('/') + '/' + [uri]::EscapeDataString($env:ARTIFACT_PROJECT) + '/_apis/build'
$headers = @{ Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" }
$buildId = $env:ARTIFACT_BUILD_ID
if ($env:ARTIFACT_DEFINITION) {
$definition = 0
if (-not [int]::TryParse($env:ARTIFACT_DEFINITION, [ref]$definition) -or $definition -lt 1 -or -not $env:ARTIFACT_BRANCH) {
throw 'Selecting the latest build requires a definition ID and an exact branch.'
}
$results = @('succeeded')
if ($env:ARTIFACT_ALLOW_PARTIAL -eq 'true') { $results += 'partiallySucceeded' }
if ($env:ARTIFACT_ALLOW_FAILED -eq 'true') { $results += 'failed' }
$query = 'builds?api-version=7.1&statusFilter=completed&queryOrder=finishTimeDescending&$top=1' +
"&definitions=$definition&branchName=$([uri]::EscapeDataString($env:ARTIFACT_BRANCH))" +
"&resultFilter=$($results -join ',')"
if ($env:ARTIFACT_TAGS) {
$query += '&tagFilters=' + [uri]::EscapeDataString($env:ARTIFACT_TAGS)
}
$builds = @((Get-BuildMetadata $query).value)
if ($builds.Count -ne 1) {
throw 'No build matches the requested definition, branch, tags, and result filters.'
}
$buildId = [string]$builds[0].id
}
$id = 0
if (-not [int]::TryParse($buildId, [ref]$id) -or $id -lt 1) {
throw 'A positive, resolved build ID is required.'
}
$artifactMetadata = Get-BuildMetadata "builds/$id/artifacts?api-version=7.1"
if ($artifactMetadata.value -isnot [array] -or $artifactMetadata.count -ne $artifactMetadata.value.Count) {
throw 'Invalid artifact-list metadata for the selected build.'
}
$artifacts = @($artifactMetadata.value)
$timeline = Get-BuildMetadata "builds/$id/timeline?api-version=7.1"
if ($timeline.records -isnot [array]) {
throw 'Invalid producing-job timeline metadata for the selected build.'
}
$jobs = @($timeline.records | Where-Object { $_.type -eq 'Job' })
$oldJobIds = @($jobs | ForEach-Object { $_.previousAttempts } | ForEach-Object { $_.recordId })
$jobs = @($jobs | Where-Object { $_.id -notin $oldJobIds })
if ($jobs.Count -eq 0) {
throw 'The selected build has no producing-job timeline metadata.'
}

$selectedNames = @()
foreach ($name in $names) {
$selected = Select-Artifact $name
if ($selected) {
Write-Host "Build ${id}: $name -> $selected"
$selectedNames += $selected
} elseif ($env:ARTIFACT_ALLOW_MISSING -eq 'true') {
Write-Host "Build ${id}: no usable artifact for '$name' from its current producing attempt."
} else {
throw "No usable artifact for '$name' from its current producing attempt in build $id."
}
}
Set-ArtifactVariable 'BuildId' ([string]$id)
Set-ArtifactVariable 'Patterns' (($selectedNames | ForEach-Object { "$_/**" }) -join "`n")
Set-ArtifactVariable 'Name' $(if ($names.Count -eq 1 -and $selectedNames.Count -eq 1) { $selectedNames[0] } else { '' })
displayName: 'Resolve artifact producer attempts'
${{ if ne(parameters.stepName, '') }}:
name: ${{ parameters.stepName }}
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
ARTIFACT_NAME: ${{ parameters.artifactName }}
ARTIFACT_NAMES: ${{ convertToJson(parameters.artifactNames) }}
ARTIFACT_NAMES_JSON: ${{ parameters.artifactNamesJson }}
ARTIFACT_BUILD_ID: ${{ parameters.buildId }}
ARTIFACT_PROJECT: ${{ parameters.project }}
ARTIFACT_DEFINITION: ${{ parameters.definition }}
ARTIFACT_BRANCH: ${{ parameters.branch }}
ARTIFACT_TAGS: ${{ parameters.tags }}
ARTIFACT_ALLOW_PARTIAL: ${{ parameters.allowPartiallySucceededBuilds }}
ARTIFACT_ALLOW_FAILED: ${{ parameters.allowFailedBuilds }}
ARTIFACT_ALLOW_MISSING: ${{ parameters.allowMissing }}
ARTIFACT_VARIABLE_PREFIX: ${{ parameters.variablePrefix }}
ARTIFACT_STEP_NAME: ${{ parameters.stepName }}
Loading
Loading