Skip to content

Re-restore integrations when another CLI replaced the assets - #19604

Open
Jonas Flodén (flojon) wants to merge 3 commits into
microsoft:mainfrom
flojon:fix/integration-restore-stamp-binds-assets
Open

Jonas Flodén (flojon) wants to merge 3 commits into
microsoft:mainfrom
flojon:fix/integration-restore-stamp-binds-assets

Conversation

@flojon

Copy link
Copy Markdown
Contributor

Fixes #19603.

Root cause

obj/aspire-restore.stamp records only the inputs of the last restore performed by PrebuiltAppHostServer, and CanSkipIntegrationRestore treats a matching fingerprint as proof that obj/project.assets.json still corresponds to those inputs. Nothing binds the stamp to the assets, so any other writer of the restore directory can replace them while the stamp keeps asserting the earlier fingerprint.

Switching CLI versions is exactly such a writer, which is why the reported failure needs two switches to show up:

  1. 13.6.0-pr restores cleanly and writes stamp = A.
  2. 13.5.1 restores the same synthesized project against its own package versions. It rewrites project.assets.json and bin/, and leaves the stamp alone — so the stamp still says A.
  3. 13.6.0-pr regenerates a byte-identical IntegrationRestore.csproj and therefore recomputes fingerprint A. It matches the stale stamp, restore is skipped, and the build runs against 13.5.1's closure — including Aspire.Hosting.CodeGeneration.TypeScript.dll — while reporting ✅ SDK code restored successfully.

That also explains the asymmetry in the issue: going down works because the older CLI has no skip logic and always restores.

Verified against the reported repro with both CLIs installed (13.5.1 and 13.6.0-pr.19577.gfa0aea2c). After step 3 the csproj read 13.6.0-pr while project.assets.json and the generator assembly were still 13.5.1; deleting just the stamp made the same command restore correctly, confirming the stamp was the only gate.

The issue's first suggestion — folding the CLI version into the stamp — does not fix this: the synthesized csproj already encodes the CLI version, so the input fingerprint already differs per version. The problem is that the stamp outlives a foreign restore and the fingerprint returns to a previously stamped value.

Fix

The stamp now records a fingerprint of the assets file it produced alongside the input fingerprint, and a skip requires the assets on disk to still be the ones it vouches for:

<input fingerprint>
<project.assets.json fingerprint>
  • A stamp carrying only the inputs (written by an earlier CLI) is treated as stale, costing one restore after an upgrade — the safe direction.
  • If the assets cannot be read at write time, no stamp is written at all rather than one that vouches only for the inputs.
  • Hashing is XxHash3 over the assets stream, consistent with the existing input fingerprint and negligible next to the ~5.6s restore it guards.

This also covers the general case the stamp never handled: any external dotnet restore/dotnet build against the same directory, or a partially cleaned obj/.

No warning was added for the version-skew case — with the stamp bound to its assets the state self-corrects on the next command, so there is nothing left to warn about.

Tests

Three new tests in PrebuiltAppHostServerTests, each verified to fail against the pre-fix logic:

  • CanSkipIntegrationRestore_RestoresWhenAnotherRestoreReplacedTheAssets — the reported scenario: inputs match again but the assets were replaced.
  • CanSkipIntegrationRestore_RestoresWhenTheStampDoesNotVouchForTheAssets — an inputs-only stamp from an earlier CLI.
  • WriteRestoreStampAsync_WritesNoStampWhenTheAssetsAreMissing.

The WriteRestoreState helper now writes through the production writer so the tests cannot drift from the stamp format.

Full Aspire.Cli.Tests suite passes (5401 passed, 30 skipped Windows-only, 0 failed).

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19604

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19604"

`obj/aspire-restore.stamp` recorded only the inputs of the last restore
performed by this code path, and `CanSkipIntegrationRestore` treated a
matching fingerprint as proof that `obj/project.assets.json` still
belonged to those inputs. Nothing bound the stamp to the assets, so any
other writer of the restore directory could replace them while the stamp
kept asserting the old fingerprint.

Switching CLI versions is exactly such a writer. A CLI without the stamp
logic restores the same synthesized project against its own package
versions and leaves the stamp untouched; switching back regenerates a
byte-identical `IntegrationRestore.csproj` and therefore the earlier
fingerprint, which matches the stale stamp. Restore is skipped and the
build runs against the other version's closure — including the
TypeScript code generator — while reporting success. Only `rm -rf
.aspire` recovered.

The stamp now also records a fingerprint of the assets file it produced,
and a skip requires the assets on disk to still be the ones it vouches
for. A stamp that carries only the inputs is treated as stale, which
costs one restore after an upgrade.

Fixes microsoft#19603

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Binds integration restore stamps to project.assets.json, preventing stale CLI assets from being reused after version switches.

Changes:

  • Stores both input and assets fingerprints.
  • Validates both fingerprints before skipping restore.
  • Adds regression and legacy-stamp tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs Adds assets fingerprinting and stamp validation.
tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs Covers replaced assets, legacy stamps, and missing assets.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

@flojon
Jonas Flodén (flojon) force-pushed the fix/integration-restore-stamp-binds-assets branch from ed270c4 to ad19858 Compare August 22, 2026 08:32
@flojon
Jonas Flodén (flojon) marked this pull request as draft August 22, 2026 08:53
Three follow-ups on the assets-fingerprint stamp, found while reproducing
microsoft#19603 end to end.

`WriteRestoreStampAsync` was gated on `!skipRestore`, but `skipRestore` only
says whether the *first* build attempt skipped restore. When
`ShouldRetryWithRestore` fires, the retry does restore and rewrites
`project.assets.json`, so leaving the stamp describing the assets that restore
replaced made it fail its own assets check on the next launch and pay a full
restore. Tracked as `restoreRan` instead.

`CanSkipIntegrationRestore` reported an unreadable assets file as a replaced
one, logging "the assets were replaced" immediately after
`TryComputeAssetsFingerprint` logged that it could not read them — two
contradictory lines for one event. A null fingerprint now returns without
claiming a writer. The stale-stamp check is also split so the log distinguishes
a stamp that predates the assets fingerprint from a genuine input change, and
the shared helper no longer says "restoring" on the write path, where nothing
is.

`CanSkipIntegrationRestore_RestoresWhenTheAssetsAreMissing` asked
`WriteRestoreState` not to write the assets, but that writes no stamp either --
so the test left neither file and was equivalent to the
nothing-restored-yet test above it. It now keeps the stamp and deletes the
assets. Note this pins the scenario, not the `File.Exists(assetsPath)` clause:
that clause is a cold-start fast path, redundant with the null-fingerprint
return, and is documented as such.

The remark on `CanSkipIntegrationRestore` said switching back reproduces "a
byte-identical project file", which reads as identical between the two CLI
versions -- it is not; the pin changes. Reworded to say identical to the one
this CLI itself wrote earlier, which is what makes its own stamp match.

Verified against the microsoft#19603 repro on a TypeScript AppHost with the released
13.5.1 CLI and the 13.6.0-pr.19577 dogfood build: step 3 logs "restore
skipped", rewrites the csproj to 13.6.0-pr, and leaves assets and the built
generator at 13.5.1 while reporting success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 22, 2026 09:12
@flojon

Jonas Flodén (flojon) commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Reproduced #19603 end to end to confirm this fix actually closes it. It does —
evidence below, plus three follow-ups pushed on top.

One remark worth rewording

"Switching back reproduces a byte-identical project file" reads naturally as
identical between the two CLI versions, and on that reading the fix looks like it
cannot work, since the pin demonstrably changes. It means identical to the project
file this CLI itself wrote earlier, which is right, and is what makes this CLI's
own stamp match. I misread it on the first pass and spent a while chasing a hole
that is not there, so I have reworded it.

The repro, confirmed

TypeScript AppHost with a project reference, released 13.5.1 and the
13.6.0-pr.19577.gfa0aea2c dogfood build, aspire restore -l Debug each step:

Step CLI csproj pin assets declared built generator stamp log
1 13.6.0-pr 13.6.0-pr 13.6.0-pr 13.6.0-pr written DCB5A63D60E2445D restore requested
2 13.5.1 13.5.1 13.5.1 13.5.1 untouched restore requested
3 13.6.0-pr 13.6.0-pr 13.5.1 13.5.1 untouched restore skipped

Step 3 printed ✅ SDK code restored successfully. mtimes land in the order the
issue reported — stamp oldest, project.assets.json next, IntegrationRestore.csproj
newest — because step 3 rewrote the csproj and then skipped restore.

The writer that replaces the assets without refreshing the stamp is simply a CLI
version that has no stamp logic at all
: 13.5.1 restores unconditionally and never
writes the stamp, so step 1's stamp survives step 2 intact and step 3 reproduces
step 1's inputs exactly. Binding the stamp to the assets closes it: step 3 hashes
13.5.1's assets, mismatches stampLines[1], and re-restores.

Pushed on top (2c03362)

Three things that stand independent of the mechanism, plus a wording fix:

  • WriteRestoreStampAsync was gated on !skipRestore, which only means "the
    first attempt skipped restore". When ShouldRetryWithRestore fires the retry
    does restore and rewrites the assets, and skipping the stamp there left it
    describing assets that restore had replaced — failing its own check next launch
    and paying a full restore. Now tracked as restoreRan.
  • An unreadable assets file was reported as a replaced one, logging "the assets
    were replaced" straight after TryComputeAssetsFingerprint logged that it
    couldn't read them. A null fingerprint now returns without naming a writer, the
    stale-stamp check is split so the log says which reason fired, and the helper
    no longer says "restoring" on the write path.
  • CanSkipIntegrationRestore_RestoresWhenTheAssetsAreMissing asked
    WriteRestoreState not to write the assets — but that writes no stamp either, so
    the test left neither file and was equivalent to the nothing-restored-yet test
    above it. It now keeps the stamp and deletes the assets. Worth noting it pins the
    scenario, not the File.Exists(assetsPath) clause: I mutation-tested that clause
    and the test still passes without it, because the null-fingerprint path returns
    the same answer. It's a cold-start fast path, now documented as one.
  • Reworded the remark above.

Aspire.Cli.Tests — 123/123 in PrebuiltAppHostServerTests, 5412/5459 overall;
the 17 failures are pre-existing on Linux (12 need pwsh, 3 macOS-only, 1
Windows path, 1 Windows file-locking).

Marking ready for review.

@flojon
Jonas Flodén (flojon) marked this pull request as ready for review August 22, 2026 09:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@github-actions

Copy link
Copy Markdown
Contributor

Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt.

The writer's comment claimed that leaving no stamp costs a restore on the
next launch, but its early return leaves whatever stamp an earlier restore
wrote sitting on disk. That is the right behaviour rather than a bug: a
skip against a surviving stamp still requires the assets on disk to hash to
the value it recorded, so the stamp can only be trusted while it describes
them, and deleting it would cost a restore for no correctness gain. Only
the comment was wrong.

The test meant to pin this ran on a fresh workspace, so no stamp existed
before the call and the File.Exists assertion would have held even if the
writer did nothing at all — it never reached the case its own comment
described. It now starts from a completed restore, deletes the assets, and
asserts the earlier stamp survives byte-for-byte, which fails on a
half-stamp. Dropping the writeAssets escape hatch from WriteRestoreState
keeps the helper honest about mirroring a completed restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 22, 2026 10:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

aspire restore skips re-restore when an older CLI replaced the integration assets, silently reusing the old code generator

2 participants