Skip to content

Commit 5e4c89e

Browse files
authored
fix(safeoutputs): rewrite upload-pipeline-artifact flow to fix HTTP 405 (#425)
1 parent 3eca09b commit 5e4c89e

5 files changed

Lines changed: 361 additions & 72 deletions

File tree

docs/safe-outputs.md

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,15 +480,26 @@ artifacts instead.
480480

481481
Publishes a workspace file as an Azure DevOps **pipeline artifact** that appears
482482
in the **Artifacts tab** of the build summary page. Uses the ADO build artifacts
483-
REST API (container creation + file upload + artifact association).
483+
REST API in two steps:
484+
485+
1. **Upload bytes** to the agent's own per-build file container (Azure DevOps
486+
creates one container per build and exposes its ID via `BUILD_CONTAINERID`).
487+
2. **Associate** the artifact record (`name = artifact_name`) with the target
488+
build via `POST /{project}/_apis/build/builds/{effective_build_id}/artifacts`.
484489

485490
**Omit `build_id` to target the current pipeline run** — the executor resolves
486491
the build ID from the `BUILD_BUILDID` environment variable automatically. When
487-
`build_id` is provided, the artifact is published to that specific build.
492+
`build_id` is provided, the artifact record is published to that specific build
493+
("cross-build publishing"). The artifact bytes still live in the agent's own
494+
build container; only the record's pointer is associated with the target build.
495+
This means cross-published artifacts share the agent build's retention — if the
496+
agent's build is purged, the cross-referenced artifact stops being downloadable.
497+
Cross-project publishing is not supported (the associate POST uses the current
498+
pipeline's project).
488499

489500
The tool stages the file during Stage 1 (MCP) by copying it into the
490-
safe-outputs directory; Stage 3 reads the staged copy and executes the three-step
491-
REST API flow to create the artifact.
501+
safe-outputs directory; Stage 3 reads the staged copy and executes the two-step
502+
REST flow.
492503

493504
**Agent parameters:**
494505
- `build_id` *(optional)* - Target build ID. Omit to publish to the current pipeline run. Must be positive when specified.
@@ -504,13 +515,37 @@ safe-outputs:
504515
allowed-artifact-names: [] # Optional — restrict names (suffix `*` = prefix match)
505516
allowed-build-ids: [] # Optional — restrict target builds (skipped when targeting current build)
506517
name-prefix: "" # Optional — prepended to the agent-supplied artifact name
518+
require-unique-names: false # Optional — see "Reusing artifact names" below
507519
max: 3 # Maximum per run (default: 3)
508520
```
509521
522+
**Reusing artifact names within one agent run:**
523+
By default, the same `artifact_name` may be reused across multiple
524+
`upload-pipeline-artifact` calls in one run (e.g. publishing a `TriageSummary`
525+
to many failing builds at once). The executor inserts a short hash suffix
526+
(`{artifact_name}__{6 hex}`) into the **internal container folder name** so
527+
the calls don't silently overwrite each other's bytes in the agent's shared
528+
build container. The hash lives only in internal addressing — it does not
529+
appear in the `record.name` your downstream consumers query for, in the web UI
530+
"Download as zip" filename, or in the contents of files extracted by the
531+
`DownloadBuildArtifacts@1` / `DownloadPipelineArtifact@2` tasks (all of which
532+
strip the container folder prefix).
533+
534+
Set `require-unique-names: true` to use a clean container folder
535+
(`{artifact_name}` only, no suffix) and reject in-run reuse of
536+
`(effective_build_id, artifact_name)` with a clear early error before any HTTP
537+
call. Use this when you guarantee one artifact per name per run and want the
538+
shortest possible internal addressing.
539+
540+
Two records with the same `name` on the **same** target build still collide at
541+
the record level (ADO returns 409 from the associate call) regardless of this
542+
setting; use distinct `artifact_name` values when targeting one build with
543+
multiple uploads.
544+
510545
**Notes:**
511546
- Single-file only; directory uploads are not supported.
512547
- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped — the current build is implicitly trusted.
513-
- Requires `SYSTEM_TEAMPROJECTID` to be available in the execution environment (set automatically by Azure DevOps).
548+
- Requires `BUILD_CONTAINERID`, `BUILD_BUILDID`, and `SYSTEM_TEAMPROJECTID` (all set automatically inside an Azure DevOps pipeline job) and `vso.build_execute` scope on the executor's token (the existing write service connection provides this).
514549

515550
### cache-memory (moved to `tools:`)
516551
Memory is now configured as a first-class tool under `tools: cache-memory:` instead of `safe-outputs: memory:`. See the [Cache Memory section](./tools.md#cache-memory-cache-memory) in `docs/tools.md` for details.

src/safeoutputs/create_pr.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2468,6 +2468,10 @@ new file mode 100755
24682468
pull_request_id: None,
24692469
pull_request_source_branch: None,
24702470
pull_request_target_branch: None,
2471+
build_container_id: None,
2472+
uploaded_pipeline_artifact_keys: std::sync::Arc::new(std::sync::Mutex::new(
2473+
std::collections::HashSet::new(),
2474+
)),
24712475
};
24722476
let outcome = result.execute_impl(&ctx).await.unwrap();
24732477
assert!(!outcome.success);

src/safeoutputs/result.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
use rmcp::ErrorData as McpError;
44
use rmcp::model::ErrorCode;
55
use serde::Serialize;
6-
use std::collections::HashMap;
6+
use std::collections::{HashMap, HashSet};
7+
use std::sync::{Arc, Mutex};
78

89
use crate::sanitize::{SanitizeConfig, SanitizeContent};
910

@@ -67,6 +68,11 @@ pub struct ExecutionContext {
6768
// ── ADO build variables (from BUILD_*/SYSTEM_*) ───────────────────────
6869
/// Numeric build ID (`BUILD_BUILDID`)
6970
pub build_id: Option<u64>,
71+
/// Numeric file-container ID for the current build (`BUILD_CONTAINERID`).
72+
/// Azure DevOps pre-creates one container per build at job initialization;
73+
/// all artifacts in the build share this container, differentiated by item path.
74+
/// Required by `upload-pipeline-artifact` to know where to upload bytes.
75+
pub build_container_id: Option<u64>,
7076
/// Human-readable build number (`BUILD_BUILDNUMBER`)
7177
#[allow(dead_code)]
7278
pub build_number: Option<String>,
@@ -110,6 +116,19 @@ pub struct ExecutionContext {
110116
/// PR target branch (`SYSTEM_PULLREQUEST_TARGETBRANCH`)
111117
#[allow(dead_code)]
112118
pub pull_request_target_branch: Option<String>,
119+
120+
/// Per-run dedupe set for `upload-pipeline-artifact` when the
121+
/// `require-unique-names` config is set. Stores `format!("{}/{}",
122+
/// effective_build_id, final_name)` keys; the executor checks-and-inserts
123+
/// before any HTTP call so a second call with the same target build /
124+
/// artifact name fails fast instead of silently overwriting bytes in
125+
/// the agent's shared file container.
126+
///
127+
/// Wrapped in `Arc<Mutex<…>>` so all calls in one Stage 3 run see the
128+
/// same set even though `ExecutionContext` is shared by reference and
129+
/// the `Clone` semantics need to share state. Each `Default` instance
130+
/// gets its own fresh empty set, which is correct for tests.
131+
pub uploaded_pipeline_artifact_keys: Arc<Mutex<HashSet<String>>>,
113132
}
114133

115134
impl ExecutionContext {
@@ -182,6 +201,7 @@ impl ExecutionContext {
182201

183202
// Build identification
184203
build_id: env("BUILD_BUILDID").and_then(|s| s.parse().ok()),
204+
build_container_id: env("BUILD_CONTAINERID").and_then(|s| s.parse().ok()),
185205
build_number: env("BUILD_BUILDNUMBER"),
186206
build_reason: env("BUILD_REASON"),
187207
definition_name: env("BUILD_DEFINITIONNAME"),
@@ -199,6 +219,9 @@ impl ExecutionContext {
199219
pull_request_id: env("SYSTEM_PULLREQUEST_PULLREQUESTID"),
200220
pull_request_source_branch: env("SYSTEM_PULLREQUEST_SOURCEBRANCH"),
201221
pull_request_target_branch: env("SYSTEM_PULLREQUEST_TARGETBRANCH"),
222+
223+
// Per-run state for upload-pipeline-artifact dedupe.
224+
uploaded_pipeline_artifact_keys: Arc::new(Mutex::new(HashSet::new())),
202225
}
203226
}
204227
}
@@ -729,6 +752,26 @@ mod tests {
729752
assert!(ctx.build_id.is_none());
730753
}
731754

755+
#[test]
756+
fn test_from_env_lookup_build_container_id_parses_numeric() {
757+
let ctx =
758+
ExecutionContext::from_env_lookup(env_from(&[("BUILD_CONTAINERID", "112233")]));
759+
assert_eq!(ctx.build_container_id, Some(112233));
760+
}
761+
762+
#[test]
763+
fn test_from_env_lookup_build_container_id_none_for_non_numeric() {
764+
let ctx =
765+
ExecutionContext::from_env_lookup(env_from(&[("BUILD_CONTAINERID", "not-numeric")]));
766+
assert!(ctx.build_container_id.is_none());
767+
}
768+
769+
#[test]
770+
fn test_from_env_lookup_build_container_id_none_when_unset() {
771+
let ctx = ExecutionContext::from_env_lookup(env_from(&[]));
772+
assert!(ctx.build_container_id.is_none());
773+
}
774+
732775
#[test]
733776
fn test_from_env_lookup_populates_triggered_by_fields() {
734777
let ctx = ExecutionContext::from_env_lookup(env_from(&[

src/safeoutputs/upload_build_attachment.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,10 @@ attachment-type: "agent-artifact"
817817
pull_request_id: None,
818818
pull_request_source_branch: None,
819819
pull_request_target_branch: None,
820+
build_container_id: None,
821+
uploaded_pipeline_artifact_keys: std::sync::Arc::new(std::sync::Mutex::new(
822+
std::collections::HashSet::new(),
823+
)),
820824
}
821825
}
822826

0 commit comments

Comments
 (0)