Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ jobs:
name: TypeScript SDK Unit Tests
uses: ./.github/workflows/typescript-sdk-tests.yml

typescript_api_compat:
name: TypeScript API Compatibility
uses: ./.github/workflows/typescript-api-compat.yml

results:
if: ${{ always() && github.repository_owner == 'microsoft' }}
runs-on: ubuntu-latest
Expand All @@ -336,6 +340,7 @@ jobs:
extension_tests_win,
cli_starter_validation_windows,
typescript_sdk_tests,
typescript_api_compat,
tests_no_nugets,
tests_no_nugets_overflow,
tests_requires_nugets_linux,
Expand Down Expand Up @@ -420,6 +425,7 @@ jobs:
(needs.extension_tests_win.result == 'skipped' ||
needs.cli_starter_validation_windows.result == 'skipped' ||
needs.typescript_sdk_tests.result == 'skipped' ||
needs.typescript_api_compat.result == 'skipped' ||
needs.tests_no_nugets.result == 'skipped' ||
needs.tests_requires_nugets_linux.result == 'skipped' ||
needs.tests_requires_nugets_windows.result == 'skipped' ||
Expand Down
169 changes: 169 additions & 0 deletions .github/workflows/typescript-api-compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: TypeScript API Compatibility

on:
workflow_call:

permissions:
contents: read

jobs:
typescript_api_compat:
name: TypeScript API Compatibility
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Restore
run: ./restore.sh

- name: Build CLI and compatibility tool
run: |
./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release
./dotnet.sh build tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj --configuration Release

- name: Generate base ATS surface
shell: bash
env:
BASE_BRANCH: ${{ github.base_ref }}
run: |
set -euo pipefail

BASE_WORKTREE="${RUNNER_TEMP}/base-worktree"
BASELINE_DIR="${RUNNER_TEMP}/ats-baseline"

git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}"
git worktree add --detach "$BASE_WORKTREE" "origin/${BASE_BRANCH}"

pushd "$BASE_WORKTREE"
./restore.sh
./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release

export ASPIRE_REPO_ROOT="$BASE_WORKTREE"
ASPIRE_CLI="./dotnet.sh run --no-build --project src/Aspire.Cli/Aspire.Cli.csproj --configuration Release -- --nologo"
PROJECTS_FILE="${RUNNER_TEMP}/ats-base-projects.txt"
SORTED_PROJECTS_FILE="${RUNNER_TEMP}/ats-base-projects-sorted.txt"

mkdir -p "$BASELINE_DIR/src/Aspire.Hosting/api"
: > "$PROJECTS_FILE"

echo "::group::Base Aspire.Hosting (core)"
$ASPIRE_CLI sdk dump --format ci -o "$BASELINE_DIR/src/Aspire.Hosting/api/Aspire.Hosting.ats.txt"
echo "::endgroup::"

while IFS= read -r file; do
case "$file" in
*/obj/*|*/bin/*|*Aspire.Hosting.Analyzers*|*Aspire.Hosting.CodeGeneration*|*Aspire.Hosting.RemoteHost*)
continue
;;
esac

dir=$(dirname "$file")
while [ "$dir" != "." ] && [ "$dir" != "/" ]; do
if compgen -G "$dir/*.csproj" > /dev/null; then
echo "$dir" >> "$PROJECTS_FILE"
break
fi

dir=$(dirname "$dir")
done
done < <(grep -rl '\[AspireExport(' src/Aspire.Hosting.*/ --include='*.cs' || true)

sort -u "$PROJECTS_FILE" > "$SORTED_PROJECTS_FILE"

while IFS= read -r proj; do
[ -n "$proj" ] || continue
proj_name=$(basename "$proj")
csproj="$proj/$proj_name.csproj"
if [ -f "$csproj" ]; then
echo "::group::Base $proj_name"
mkdir -p "$BASELINE_DIR/$proj/api"
$ASPIRE_CLI sdk dump --format ci "$csproj" -o "$BASELINE_DIR/$proj/api/$proj_name.ats.txt"
echo "::endgroup::"
fi
done < "$SORTED_PROJECTS_FILE"
popd

echo "BASELINE_DIR=$BASELINE_DIR" >> "$GITHUB_ENV"
echo "BASE_SUPPRESSIONS_ROOT=$BASE_WORKTREE" >> "$GITHUB_ENV"

- name: Generate current ATS surface
shell: bash
env:
ASPIRE_REPO_ROOT: ${{ github.workspace }}
run: |
set -euo pipefail

CURRENT_DIR="${RUNNER_TEMP}/ats-current"
PROJECTS_FILE="${RUNNER_TEMP}/ats-projects.txt"
SORTED_PROJECTS_FILE="${RUNNER_TEMP}/ats-projects-sorted.txt"
ASPIRE_CLI="./dotnet.sh run --no-build --project src/Aspire.Cli/Aspire.Cli.csproj --configuration Release -- --nologo"

mkdir -p "$CURRENT_DIR/src/Aspire.Hosting/api"
: > "$PROJECTS_FILE"

echo "::group::Aspire.Hosting (core)"
$ASPIRE_CLI sdk dump --format ci -o "$CURRENT_DIR/src/Aspire.Hosting/api/Aspire.Hosting.ats.txt"
echo "::endgroup::"

while IFS= read -r file; do
case "$file" in
*/obj/*|*/bin/*|*Aspire.Hosting.Analyzers*|*Aspire.Hosting.CodeGeneration*|*Aspire.Hosting.RemoteHost*)
continue
;;
esac

dir=$(dirname "$file")
while [ "$dir" != "." ] && [ "$dir" != "/" ]; do
if compgen -G "$dir/*.csproj" > /dev/null; then
echo "$dir" >> "$PROJECTS_FILE"
break
fi

dir=$(dirname "$dir")
done
done < <(grep -rl '\[AspireExport(' src/Aspire.Hosting.*/ --include='*.cs' || true)

sort -u "$PROJECTS_FILE" > "$SORTED_PROJECTS_FILE"

while IFS= read -r proj; do
[ -n "$proj" ] || continue
proj_name=$(basename "$proj")
csproj="$proj/$proj_name.csproj"
if [ -f "$csproj" ]; then
echo "::group::$proj_name"
mkdir -p "$CURRENT_DIR/$proj/api"
$ASPIRE_CLI sdk dump --format ci "$csproj" -o "$CURRENT_DIR/$proj/api/$proj_name.ats.txt"
echo "::endgroup::"
fi
done < "$SORTED_PROJECTS_FILE"

echo "CURRENT_DIR=$CURRENT_DIR" >> "$GITHUB_ENV"

- name: Check TypeScript API compatibility
shell: bash
run: |
set -euo pipefail

./dotnet.sh run \
--no-build \
--project tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj \
--configuration Release \
-- \
--baseline "$BASELINE_DIR" \
--current "$CURRENT_DIR" \
--suppressions-root "${{ github.workspace }}" \
--baseline-suppressions-root "$BASE_SUPPRESSIONS_ROOT" \
--report "${RUNNER_TEMP}/typescript-api-compat-report.md" \
--github-annotations

- name: Upload TypeScript API compatibility report
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: typescript-api-compat-report
path: ${{ runner.temp }}/typescript-api-compat-report.md
if-no-files-found: ignore
1 change: 1 addition & 0 deletions Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@
<Project Path="tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj" />
<Project Path="tools/CreateLayout/CreateLayout.csproj" />
<Project Path="tools/GenerateTestSummary/GenerateTestSummary.csproj" />
<Project Path="tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj" />
</Folder>
<Folder Name="/Tools/QuarantineTools/">
<Project Path="tools/QuarantineTools/QuarantineTools.csproj" />
Expand Down
85 changes: 85 additions & 0 deletions docs/ci/typescript-api-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# TypeScript API compatibility

The TypeScript API compatibility check prevents pull requests from introducing undeclared breaking changes in the ATS surface used to generate TypeScript polyglot AppHost SDKs.

## Baseline

The checked-in `src/Aspire.Hosting*/api/*.ats.txt` files are the release compatibility baseline. The scheduled `.github/workflows/generate-ats-diffs.yml` workflow remains the review and release mechanism for updating the checked-in ATS files after API changes are accepted.

In pull request CI, `.github/workflows/typescript-api-compat.yml` generates ATS output from the pull request target branch and compares it with fresh `aspire sdk dump --format ci` output generated from the pull request. This mergeable baseline avoids failing unrelated pull requests when the target branch contains accepted source changes that have not yet been rolled into the release baseline.

This intentionally differs from a plain git diff. A pull request cannot hide a breaking change by editing the checked-in ATS files in the same PR; the pull request check compares generated target-branch output with generated pull request output.

After a new version ships, reset the compatibility baseline by updating the checked-in ATS files to the shipped surface. Suppressions for breaks that are now part of that new baseline should be deleted in the same change; keep only suppressions that still describe intentional breaks relative to the release baseline. The compatibility checker fails on unused suppressions added by a pull request, but suppressions already present in the target branch are allowed to become unused against the generated target-branch baseline so merged intentional breaks do not block unrelated pull requests before the next release reset.

## Breaking changes

The checker treats these ATS changes as breaking:

- Removed packages, handle types, DTO types, enum types, exported values, or capabilities.
- Removed handle flags such as `ExposeProperties` or `ExposeMethods`.
- Removed DTO properties, optional-to-required DTO property changes, DTO property type changes, or newly added required DTO properties.
- Removed enum values.
- Exported value type or literal value changes.
- Removed capability parameters, optional-to-required parameter changes, required parameter additions, parameter type changes, parameter order changes, optional parameter insertions before existing parameters, or return type changes.

Additive changes such as new capabilities, optional capability parameters appended after all existing parameters, new optional DTO properties, new enum values, and new exported values do not require suppressions.

## Suppressions

Intentional breaks must be declared in a suppression file. Per-package suppressions should live next to the ATS file:

```text
src/Aspire.Hosting.Redis/api/Aspire.Hosting.Redis.tscompat.suppression.txt
```

Cross-cutting suppressions may use:

```text
eng/TypeScriptApiCompat/global.suppression.txt
```

The format is:

```text
BREAK <kind> <package> <symbol> -- <issue-or-pr-url> -- <reason>
```

The `<issue-or-pr-url>` field is required so every intentional breaking change is traceable to the issue or pull request where the API break was reviewed and approved. The `<reason>` should briefly explain why the break is intentional.

Supported `<kind>` values are:

- `package-removed`
- `handle-removed`
- `handle-flag-removed`
- `dto-removed`
- `dto-property-removed`
- `dto-property-type-changed`
- `dto-property-required`
- `dto-property-added-required`
- `enum-removed`
- `enum-value-removed`
- `exported-value-removed`
- `exported-value-type-changed`
- `exported-value-changed`
- `capability-removed`
- `capability-return-type-changed`
- `capability-parameter-removed`
- `capability-parameter-type-changed`
- `capability-parameter-required`
- `capability-parameter-added-required`
- `capability-parameter-order-changed`

Example:

```text
BREAK capability-removed Aspire.Hosting.Redis Aspire.Hosting.Redis/withRedisCommander -- https://github.com/microsoft/aspire/issues/16961 -- Removed unsupported API before GA
```

Suppression matching is exact. Unused suppressions fail the check so stale entries are removed when the API surface changes again.

## Generated TypeScript declarations

This check currently compares the ATS source surface that feeds TypeScript generation. Generator implementation changes are still covered by TypeScript code generation tests and `tests/PolyglotAppHosts/*/TypeScript` validation, but they are not yet classified against a generated `.d.ts` baseline.

If generator-only API shape changes need the same breaking-change treatment, extend `tools/TypeScriptApiCompat` to generate declaration-only output from the checked-in TypeScript validation AppHosts and compare those declarations with the same suppression format.
1 change: 1 addition & 0 deletions tests/Infrastructure.Tests/Infrastructure.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

<ItemGroup>
<ProjectReference Include="..\Aspire.TestUtilities\Aspire.TestUtilities.csproj" />
<ProjectReference Include="..\..\tools\TypeScriptApiCompat\TypeScriptApiCompat.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading