fix(safeoutputs): rewrite upload-pipeline-artifact flow to fix HTTP 405#425
Conversation
The prior implementation tried to create a per-artifact file container via `POST /_apis/resources/containers`, which is not a real ADO endpoint (only GET is registered for the unscoped path). The earlier #421 fix adding `scopeIdentifier` did not help because no POST handler exists on the unscoped route. Rewrite to use the canonical 2-step flow that all official Azure DevOps tools use: upload bytes to the agent's own pre-existing build container (exposed via `BUILD_CONTAINERID`), then associate an artifact record on the target build with `resource.data` pointing back at our container. Cross-build publishing now works as a side-effect: the artifact record on the target build holds a pointer; ADO does not require the container to belong to the target build (this is exactly how `DownloadBuildArtifacts@1 buildType=specific` already works in the wild). The artifact bytes physically live in the agent's own build's container, so cross-published artifacts share the agent build's retention — documented in docs/safe-outputs.md. Avoid silent byte-overwrites in the agent's shared container when multiple calls in one run share an artifact name (e.g. publishing the same `TriageSummary` to many failing builds at once) by inserting a 6-character hash discriminator into the internal container folder name. The hash is invisible in standard download paths (web UI zip wrapper, DownloadBuildArtifacts@1, DownloadPipelineArtifact@2 — all strip the prefix) and the user-visible `artifact_name` is unaffected. Add a `require-unique-names: true` config opt-out that uses a clean folder name and rejects in-run reuse of `(effective_build_id, artifact_name)` early. The dedupe state lives on `ExecutionContext` in an `Arc<Mutex<HashSet>>` so all calls in one Stage 3 run share it. Other fixes: - Switch upload query param `scopeIdentifier` -> `scope` (canonical name; all SDKs use `scope`; `scopeIdentifier` is a response model field name, not a query key). - Fix `resource.type` casing: `Container` (was lowercase `container`). - Improve associate-POST error messages on 401/403/404/409 with actionable hints. Manual smoke test recommended before relying on this in production: 1. Cross-build publish to a *completed* target build — Microsoft does not document a build-state restriction on the artifact-record POST, but it is the riskiest unverified piece of the flow. 2. Confirm the container folder hash suffix does not surface prominently in the inline 'Browse' view of the ADO Artifacts tab. If either fails, follow-ups are: switch to build attachments for the completed-build case (loses Artifacts-tab visibility), or use `require-unique-names: true` to remove the suffix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🔍 Rust PR ReviewSummary: The core fix is correct and well-motivated. Two design issues worth addressing before merge — one is a genuine bug in the Findings🐛 Bugs / Logic Issues
if config.require_unique_names {
let mut seen = ctx.uploaded_pipeline_artifact_keys.lock()...;
if seen.contains(&dedupe_key) { return Ok(ExecutionResult::failure(...)); }
seen.insert(dedupe_key); // ← inserted here, before any HTTP call
}
// ... then Step 1 PUT + Step 2 POST ...If Step 1 (PUT bytes) or Step 2 (POST artifact record) fails with a transient/network error, the key stays in the set permanently for that Stage 3 run. A second entry in the same NDJSON batch that retries the same
|
|
@copilot address review bugs and suggestions |
…ine-artifact - Bug fix: move seen.insert(dedupe_key) to after successful HTTP execution so transient errors don't permanently block retries of the same artifact - Switch discriminator from staged_file name bytes to content hash (live_hash) so distinct content always maps to a distinct container folder regardless of filename; identical content maps to the same folder (idempotent PUT) - Replace .expect() on mutex lock with map_err(|e| anyhow!(...))? to stay consistent with the error-propagation style used throughout the executor - Update test to mirror new content-hash-based discriminator logic Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/1b52c2a1-cca5-48be-b6ae-8bb0c68d0f97 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Done in bfb4535. Three fixes applied to
|
Why
Stage 3 was failing with
HTTP 405 Method Not Allowedon everyupload-pipeline-artifact:The prior implementation tried to create a per-artifact file container via
POST /_apis/resources/containers, which is not a real Azure DevOps endpoint — onlyGET(list containers) is registered for the unscoped/_apis/resources/Containers/{containerId}/{*itemPath}route. The earlier #421 attempt to add ascopeIdentifier=query parameter did not help because noPOSThandler exists at the unscoped path on the server side.What changed
New 2-step flow that all official Microsoft tools (
PublishBuildArtifacts@1, the agent'sartifact.uploadlog command, every SDK'sFileContainerApi) actually use:/_apis/resources/Containers/{BUILD_CONTAINERID}?itemPath={folder}/{file}&scope={projectId}— into the agent's own pre-existing build container (Azure DevOps creates one container per build at job init and exposes its ID viaBUILD_CONTAINERID)./{project}/_apis/build/builds/{effective_build_id}/artifactswithresource.type = "Container"andresource.data = "#/{containerId}/{folder}".Cross-build publishing now works as a side effect. The artifact record on a target build is just a pointer; ADO does not require the container to belong to the target build (this is exactly how
DownloadBuildArtifacts@1 buildType=specificalready works in the wild). The bytes physically live in the agent's own build container, so cross-published artifacts share the agent build's retention — documented indocs/safe-outputs.md.Avoid silent byte-overwrites when multiple calls in one run share an
artifact_name(e.g. publishing the sameTriageSummaryto many failing builds at once): the executor inserts a 6-character hash discriminator into the internal container folder name ({artifact_name}__{6 hex}). The hash is invisible in standard download paths (web UI zip wrapper,DownloadBuildArtifacts@1,DownloadPipelineArtifact@2— all strip the prefix); the user-visibleartifact_nameyour downstream consumers query is unaffected.New
require-unique-names: trueconfig opt-out — uses a clean folder name and rejects in-run reuse of(effective_build_id, artifact_name)early with a clear error. Use this when you guarantee one artifact per name per run and want the shortest possible internal addressing.Other fixes:
scopeIdentifier=→scope=(canonical name; all SDKs usescope;scopeIdentifieris a response model field name, not a query key).resource.typecasing:Container(was lowercasecontainer).How to test
Compiles, all 1269 existing tests + 4 new tests pass, no new clippy warnings.
cargo build cargo test cargo clippy --all-targets --all-featuresManual smoke test (recommended before relying on this in production)
POST /builds/{B}/artifacts, but it is the riskiest unverified piece of the flow. The triage scenario specifically depends on this working.If either fails, the follow-ups are:
PUT .../builds/{B}/attachments/...(build attachments). This loses Artifacts-tab visibility but is otherwise equivalent.require-unique-names: trueper-pipeline to remove the suffix entirely.Closes
Supersedes #421 (which addressed the wrong root cause).