Make the brownfield attach-web path explicit in docs #276
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI and Release | |
| on: | |
| push: | |
| branches: [main, develop, "release/**"] | |
| paths-ignore: | |
| - 'docs/**' | |
| - '*.md' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| - '.github/PULL_REQUEST_TEMPLATE.md' | |
| - 'LICENSE' | |
| pull_request: | |
| branches: [main] | |
| paths-ignore: | |
| - 'docs/**' | |
| - '*.md' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| - '.github/PULL_REQUEST_TEMPLATE.md' | |
| - 'LICENSE' | |
| workflow_dispatch: | |
| inputs: | |
| prerelease_suffix: | |
| description: 'Pre-release channel suffix (e.g. rc.1, beta.2). Leave empty for standard CI build.' | |
| required: false | |
| default: '' | |
| permissions: | |
| contents: read | |
| checks: write | |
| concurrency: | |
| group: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ci-{0}', github.ref) }} | |
| cancel-in-progress: true | |
| env: | |
| DOTNET_NOLOGO: true | |
| DOTNET_CLI_TELEMETRY_OPTOUT: true | |
| NUKE_TELEMETRY_OPTOUT: true | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| jobs: | |
| version: | |
| name: Resolve Version | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| version: ${{ steps.resolve.outputs.version }} | |
| full_version: ${{ steps.resolve.outputs.full_version }} | |
| version_suffix: ${{ steps.resolve.outputs.version_suffix }} | |
| tag: ${{ steps.resolve.outputs.tag }} | |
| sha: ${{ steps.resolve.outputs.sha }} | |
| is_release: ${{ steps.resolve.outputs.is_release }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-tags: true | |
| - name: Resolve version and context | |
| id: resolve | |
| shell: bash | |
| env: | |
| PRERELEASE_SUFFIX: ${{ inputs.prerelease_suffix }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| GIT_REF: ${{ github.ref }} | |
| RUN_NUMBER: ${{ github.run_number }} | |
| run: | | |
| SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' | |
| VERSION=$(perl -0777 -ne 'if (m{<VersionPrefix>\s*([^<]+?)\s*</VersionPrefix>}s) { print $1 }' Directory.Build.props | head -n1 | xargs) | |
| if [ -z "$VERSION" ]; then | |
| echo "::error::VersionPrefix not found in Directory.Build.props" | |
| exit 1 | |
| fi | |
| if ! [[ "$VERSION" =~ $SEMVER_REGEX ]]; then | |
| echo "::error::Invalid VersionPrefix: $VERSION" | |
| exit 1 | |
| fi | |
| SHA="$(git rev-parse HEAD)" | |
| TAG="v$VERSION" | |
| IS_RELEASE="false" | |
| if [ "$EVENT_NAME" != "pull_request" ] && [ "$GIT_REF" = "refs/heads/main" ]; then | |
| git fetch --tags --force | |
| if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then | |
| echo "::notice::Tag $TAG already exists. Version not bumped — skipping release." | |
| else | |
| IS_RELEASE="true" | |
| fi | |
| fi | |
| VERSION_SUFFIX="" | |
| if [ -n "$PRERELEASE_SUFFIX" ]; then | |
| if ! [[ "$PRERELEASE_SUFFIX" =~ ^[0-9A-Za-z.-]+$ ]]; then | |
| echo "::error::Invalid prerelease_suffix: must match [0-9A-Za-z.-]+" | |
| exit 1 | |
| fi | |
| VERSION_SUFFIX="$PRERELEASE_SUFFIX" | |
| elif [ "$IS_RELEASE" != "true" ]; then | |
| VERSION_SUFFIX="ci.${RUN_NUMBER}" | |
| fi | |
| if [ -n "$VERSION_SUFFIX" ]; then | |
| FULL_VERSION="${VERSION}-${VERSION_SUFFIX}" | |
| else | |
| FULL_VERSION="${VERSION}" | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "full_version=$FULL_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "version_suffix=$VERSION_SUFFIX" >> "$GITHUB_OUTPUT" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "sha=$SHA" >> "$GITHUB_OUTPUT" | |
| echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT" | |
| echo "Resolved: version=$VERSION full_version=$FULL_VERSION tag=$TAG sha=$SHA is_release=$IS_RELEASE" | |
| - name: Version summary | |
| shell: bash | |
| run: | | |
| VERSION="${{ steps.resolve.outputs.version }}" | |
| FULL_VERSION="${{ steps.resolve.outputs.full_version }}" | |
| VERSION_SUFFIX="${{ steps.resolve.outputs.version_suffix }}" | |
| IS_RELEASE="${{ steps.resolve.outputs.is_release }}" | |
| SHA="${{ steps.resolve.outputs.sha }}" | |
| TAG="${{ steps.resolve.outputs.tag }}" | |
| IFS='.' read -r CUR_MAJOR CUR_MINOR CUR_PATCH <<< "$VERSION" | |
| PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") | |
| if [ -n "$PREV_TAG" ]; then | |
| PREV_VER="${PREV_TAG#v}" | |
| IFS='.' read -r PRE_MAJOR PRE_MINOR PRE_PATCH <<< "$PREV_VER" | |
| if [ "$CUR_MAJOR" != "$PRE_MAJOR" ]; then | |
| BUMP="MAJOR" | |
| elif [ "$CUR_MINOR" != "$PRE_MINOR" ]; then | |
| BUMP="MINOR" | |
| elif [ "$CUR_PATCH" != "$PRE_PATCH" ]; then | |
| BUMP="PATCH" | |
| else | |
| BUMP="NONE (same as $PREV_TAG)" | |
| fi | |
| else | |
| PREV_TAG="(none)" | |
| BUMP="INITIAL" | |
| fi | |
| MODE=$( [ "$IS_RELEASE" = "true" ] && echo "Release" || echo "CI" ) | |
| CHANNEL="stable" | |
| if [ -n "$VERSION_SUFFIX" ]; then | |
| CHANNEL="$VERSION_SUFFIX" | |
| fi | |
| { | |
| echo "## Version Info" | |
| echo "" | |
| echo "| Property | Value |" | |
| echo "|----------|-------|" | |
| echo "| **Version** | \`$VERSION\` |" | |
| echo "| **Full Version** | \`$FULL_VERSION\` |" | |
| echo "| **Channel** | \`$CHANNEL\` |" | |
| echo "| **Tag** | \`$TAG\` |" | |
| echo "| **Previous Tag** | \`$PREV_TAG\` |" | |
| echo "| **Bump Type** | **$BUMP** |" | |
| echo "| **Mode** | $MODE |" | |
| echo "| **Commit** | \`${SHA::8}\` |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fast-feedback: | |
| name: Fast Feedback (CLI/Docs) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: version | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 | |
| with: | |
| dotnet-version: "10.0.x" | |
| dotnet-quality: "preview" | |
| - name: Cache NuGet packages | |
| uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Run fast CLI/docs tests | |
| run: ./build.sh --target FastUnitTests --configuration Release | |
| - name: Upload fast test results | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: test-results-fast-feedback | |
| path: artifacts/test-results/ | |
| if-no-files-found: ignore | |
| - name: Report fast test results | |
| if: always() && !cancelled() | |
| uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 | |
| with: | |
| name: Tests (Fast Feedback) | |
| path: artifacts/test-results/**/*.trx | |
| reporter: dotnet-trx | |
| governance-feedback: | |
| name: Governance Feedback | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 12 | |
| needs: version | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 | |
| with: | |
| dotnet-version: "10.0.x" | |
| dotnet-quality: "preview" | |
| - name: Cache NuGet packages | |
| uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} | |
| restore-keys: ${{ runner.os }}-nuget- | |
| - name: Run governance tests | |
| run: ./build.sh --target GovernanceUnitTests --configuration Release | |
| - name: Upload governance test results | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: test-results-governance-feedback | |
| path: artifacts/test-results/ | |
| if-no-files-found: ignore | |
| - name: Report governance test results | |
| if: always() && !cancelled() | |
| uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 | |
| with: | |
| name: Tests (Governance Feedback) | |
| path: artifacts/test-results/**/*.trx | |
| reporter: dotnet-trx | |
| build-macos: | |
| name: Build and Test (macOS) | |
| runs-on: macos-latest | |
| timeout-minutes: 45 | |
| needs: [version, fast-feedback, governance-feedback] | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - uses: ./.github/actions/setup-build-env | |
| with: | |
| dotnet-workloads: android ios macos | |
| - name: Run CI (Compile + Coverage + Pack) | |
| run: ./build.sh --target Ci --configuration Release --version-suffix "${{ needs.version.outputs.version_suffix }}" | |
| - name: Build npm release tarball | |
| run: | | |
| set -euo pipefail | |
| npm version "${{ needs.version.outputs.full_version }}" --no-git-tag-version --allow-same-version | |
| mkdir -p "../../artifacts/release-bundle/npm" | |
| npm pack --pack-destination "../../artifacts/release-bundle/npm" | |
| working-directory: packages/bridge | |
| - name: Assemble release bundle and manifest | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p artifacts/release-bundle/nuget | |
| cp artifacts/packages/*.nupkg artifacts/release-bundle/nuget/ | |
| python3 - <<'PY' | |
| import hashlib | |
| import json | |
| from pathlib import Path | |
| bundle = Path("artifacts/release-bundle") | |
| nuget_dir = bundle / "nuget" | |
| npm_dir = bundle / "npm" | |
| full_version = "${{ needs.version.outputs.full_version }}" | |
| commit_sha = "${{ needs.version.outputs.sha }}" | |
| workflow_run_id = "${{ github.run_id }}" | |
| def digest(path: Path) -> str: | |
| h = hashlib.sha256() | |
| with path.open("rb") as f: | |
| for chunk in iter(lambda: f.read(1024 * 1024), b""): | |
| h.update(chunk) | |
| return h.hexdigest() | |
| nuget_files = sorted([p for p in nuget_dir.glob("*.nupkg") if p.is_file()]) | |
| npm_files = sorted([p for p in npm_dir.glob("*.tgz") if p.is_file()]) | |
| payload = { | |
| "schemaVersion": 2, | |
| "version": full_version, | |
| "commitSha": commit_sha, | |
| "workflowRunId": workflow_run_id, | |
| "nugetPackages": [ | |
| {"file": p.name, "sha256": digest(p)} for p in nuget_files | |
| ], | |
| "npmPackages": [ | |
| {"file": p.name, "sha256": digest(p)} for p in npm_files | |
| ], | |
| } | |
| manifest_path = bundle / "release-manifest.json" | |
| manifest_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") | |
| print(f"Wrote {manifest_path}") | |
| PY | |
| - name: Upload release bundle | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: release-bundle | |
| path: artifacts/release-bundle/ | |
| if-no-files-found: error | |
| - name: Collect all TRX files into test-results | |
| if: always() | |
| run: find artifacts/coverage -name '*.trx' -exec cp {} artifacts/test-results/ \; 2>/dev/null || true | |
| - name: Upload test results | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: test-results-macos | |
| path: artifacts/test-results/ | |
| if-no-files-found: ignore | |
| - name: Upload coverage report | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: coverage-report-macos | |
| path: artifacts/coverage-report/ | |
| if-no-files-found: ignore | |
| - name: Report test results | |
| if: always() && !cancelled() | |
| uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 | |
| with: | |
| name: Tests (macOS) | |
| path: artifacts/test-results/**/*.trx | |
| reporter: dotnet-trx | |
| build-windows: | |
| name: Build and Test (Windows) | |
| runs-on: windows-latest | |
| timeout-minutes: 45 | |
| needs: [version, fast-feedback, governance-feedback] | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - uses: ./.github/actions/setup-build-env | |
| with: | |
| dotnet-workloads: android | |
| - name: Run matrix CI validation | |
| run: ./build.ps1 -Target CiMatrix -Configuration Release -VersionSuffix "${{ needs.version.outputs.version_suffix }}" | |
| - name: Upload test results | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: test-results-windows | |
| path: artifacts/test-results/ | |
| if-no-files-found: ignore | |
| - name: Upload coverage report | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: coverage-report-windows | |
| path: artifacts/coverage-report/ | |
| if-no-files-found: ignore | |
| - name: Report test results | |
| if: always() && !cancelled() | |
| uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 | |
| with: | |
| name: Tests (Windows) | |
| path: artifacts/test-results/**/*.trx | |
| reporter: dotnet-trx | |
| build-linux: | |
| name: Build and Test (Linux) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| needs: [version, fast-feedback, governance-feedback] | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev clang zlib1g-dev | |
| - uses: ./.github/actions/setup-build-env | |
| with: | |
| dotnet-workloads: android | |
| - name: Check code format | |
| run: ./build.sh --target Format --configuration Release | |
| - name: Run matrix CI validation | |
| run: ./build.sh --target CiMatrix --configuration Release --version-suffix "${{ needs.version.outputs.version_suffix }}" | |
| - name: Run Linux NativeAOT NuGet smoke publish | |
| run: ./build.sh --target ValidateLinuxNativeAotNugetPackagePublish --configuration Release --version-suffix "${{ needs.version.outputs.version_suffix }}" | |
| - name: Upload test results | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: test-results-linux | |
| path: artifacts/test-results/ | |
| if-no-files-found: ignore | |
| - name: Upload coverage report | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| if: always() | |
| with: | |
| name: coverage-report-linux | |
| path: artifacts/coverage-report/ | |
| if-no-files-found: ignore | |
| - name: Report test results | |
| if: always() && !cancelled() | |
| uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 | |
| with: | |
| name: Tests (Linux) | |
| path: artifacts/test-results/**/*.trx | |
| reporter: dotnet-trx | |
| merge-coverage: | |
| name: Merge Coverage Reports | |
| needs: [build-macos, build-windows, build-linux] | |
| if: always() && !cancelled() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 | |
| with: | |
| dotnet-version: '10.0.x' | |
| dotnet-quality: 'preview' | |
| - name: Download all coverage reports | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| pattern: coverage-report-* | |
| path: artifacts/coverage-reports | |
| merge-multiple: false | |
| - name: Merge coverage reports | |
| run: | | |
| dotnet tool restore | |
| shopt -s nullglob globstar | |
| REPORTS=(artifacts/coverage-reports/**/coverage.cobertura.xml artifacts/coverage-reports/**/Cobertura.xml) | |
| if [ ${#REPORTS[@]} -eq 0 ]; then | |
| echo "No coverage reports found — skipping merge." | |
| exit 0 | |
| fi | |
| REPORT_ARGS="" | |
| for r in "${REPORTS[@]}"; do | |
| REPORT_ARGS+="-reports:$r;" | |
| done | |
| dotnet reportgenerator ${REPORT_ARGS} \ | |
| -targetdir:artifacts/merged-coverage-report \ | |
| -reporttypes:"Html;Cobertura;MarkdownSummaryGithub" | |
| - name: Upload merged coverage report | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: merged-coverage-report | |
| path: artifacts/merged-coverage-report/ | |
| if-no-files-found: ignore | |
| - name: Write coverage summary | |
| if: hashFiles('artifacts/merged-coverage-report/SummaryGithub.md') != '' | |
| run: cat artifacts/merged-coverage-report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" | |
| update-badges: | |
| name: Update Quality Badges | |
| needs: [build-macos, merge-coverage] | |
| if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Download coverage report | |
| continue-on-error: true | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: merged-coverage-report | |
| path: coverage-report | |
| - name: Download test results | |
| continue-on-error: true | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: test-results-macos | |
| path: test-results | |
| - name: Extract metrics and push to badges branch | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| import json, sys | |
| from xml.etree import ElementTree as ET | |
| from pathlib import Path | |
| TRX_NS = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010" | |
| def parse_trx(path): | |
| tree = ET.parse(path) | |
| c = tree.find(f".//{{{TRX_NS}}}Counters") | |
| if c is None: return None | |
| return {"total": int(c.get("total","0")), "passed": int(c.get("passed","0")), "failed": int(c.get("failed","0"))} | |
| def find(name, dirs): | |
| for d in dirs: | |
| for p in d.rglob(name): | |
| return p | |
| return None | |
| def parse_cob(path): | |
| r = ET.parse(path).getroot() | |
| return round(float(r.get("line-rate","0"))*100,2), round(float(r.get("branch-rate","0"))*100,2) | |
| def badge(label, msg, color): | |
| return json.dumps({"schemaVersion":1,"label":label,"message":msg,"color":color}, indent=2) | |
| def cc(pct): | |
| if pct>=95: return "brightgreen" | |
| if pct>=90: return "green" | |
| if pct>=80: return "yellowgreen" | |
| if pct>=70: return "yellow" | |
| return "red" | |
| s = [Path("test-results"), Path("coverage-report")] | |
| ut = find("unit-tests.trx", s) | |
| it = find("runtime-automation.trx", s) or find("integration-tests.trx", s) | |
| cb = find("Cobertura.xml", [Path("coverage-report")]) or find("coverage.cobertura.xml", [Path("coverage-report")]) | |
| u = parse_trx(ut) if ut else None | |
| i = parse_trx(it) if it else None | |
| lp, bp = parse_cob(cb) if cb else (0.0, 0.0) | |
| out = Path("badge-output") | |
| out.mkdir(exist_ok=True) | |
| wrote = False | |
| if u: | |
| c = "brightgreen" if u["failed"]==0 else "red" | |
| m = f"{u['total']} passed" if u["failed"]==0 else f"{u['passed']}/{u['total']} passed" | |
| (out/"unit-tests.json").write_text(badge("unit tests", m, c)) | |
| wrote = True | |
| if i: | |
| c = "brightgreen" if i["failed"]==0 else "red" | |
| m = f"{i['total']} passed" if i["failed"]==0 else f"{i['passed']}/{i['total']} passed" | |
| (out/"integration-tests.json").write_text(badge("integration tests", m, c)) | |
| wrote = True | |
| if cb: | |
| (out/"line-coverage.json").write_text(badge("line coverage", f"{lp}%", cc(lp))) | |
| (out/"branch-coverage.json").write_text(badge("branch coverage", f"{bp}%", cc(bp))) | |
| wrote = True | |
| if not wrote: | |
| print("No metrics found — skipping.") | |
| sys.exit(0) | |
| print("Badge JSON files generated.") | |
| for f in sorted(out.iterdir()): | |
| print(f" {f.name}: {f.read_text()}") | |
| PY | |
| - name: Push badge JSON to badges branch | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git fetch origin badges:badges || true | |
| if git show-ref --verify --quiet refs/heads/badges; then | |
| git checkout badges | |
| else | |
| git checkout --orphan badges | |
| git rm -rf . | |
| fi | |
| rm -f ./*.json | |
| cp badge-output/*.json . | |
| git add *.json | |
| if git diff --cached --quiet; then | |
| echo "No badge changes detected." | |
| exit 0 | |
| fi | |
| git commit -m "Update quality badges [skip ci]" | |
| git push origin badges | |
| build-docs: | |
| name: Build Documentation | |
| needs: version | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 | |
| with: | |
| dotnet-version: '10.0.x' | |
| dotnet-quality: 'preview' | |
| - name: Restore .NET tools | |
| run: dotnet tool restore | |
| - name: Build docfx site | |
| run: dotnet docfx docs/docfx.json | |
| - name: Upload docs artifact | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: docs-site | |
| path: docs/_site | |
| if-no-files-found: error | |
| release-gate: | |
| name: Release Gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [version, build-macos, build-windows, build-linux, build-docs] | |
| if: needs.version.outputs.is_release == 'true' | |
| environment: release | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ needs.version.outputs.sha }} | |
| fetch-depth: 0 | |
| token: ${{ secrets.GH_TOKEN }} | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 | |
| with: | |
| dotnet-version: "10.0.x" | |
| dotnet-quality: "preview" | |
| - name: Log release environment gate status | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GH_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| REPO="${{ github.repository }}" | |
| RUN_ID="${{ github.run_id }}" | |
| ENV_NAME="release" | |
| APPROVAL_COUNT="$(gh api "repos/$REPO/actions/runs/$RUN_ID/approvals" --jq 'length' 2>/dev/null || echo "unavailable")" | |
| PENDING_COUNT="$(gh api "repos/$REPO/actions/runs/$RUN_ID/pending_deployments" --jq 'length' 2>/dev/null || echo "unavailable")" | |
| LATEST_APPROVAL="$(gh api "repos/$REPO/actions/runs/$RUN_ID/approvals" --jq 'if length == 0 then "none" else .[-1] | "\(.state) by \(.user.login)" end' 2>/dev/null || echo "unavailable")" | |
| echo "Release environment diagnostics:" | |
| echo " repository: $REPO" | |
| echo " run_id: $RUN_ID" | |
| echo " environment: $ENV_NAME" | |
| echo " actor: ${{ github.actor }}" | |
| echo " triggering_actor: ${{ github.triggering_actor }}" | |
| echo " approvals: $APPROVAL_COUNT" | |
| echo " latest_approval: $LATEST_APPROVAL" | |
| echo " pending_deployments: $PENDING_COUNT" | |
| { | |
| echo "## Release Environment Gate Diagnostics" | |
| echo "" | |
| echo "| Field | Value |" | |
| echo "|---|---|" | |
| echo "| Repository | \`$REPO\` |" | |
| echo "| Run ID | \`$RUN_ID\` |" | |
| echo "| Environment | \`$ENV_NAME\` |" | |
| echo "| Actor | \`${{ github.actor }}\` |" | |
| echo "| Triggering Actor | \`${{ github.triggering_actor }}\` |" | |
| echo "| Approvals | \`$APPROVAL_COUNT\` |" | |
| echo "| Latest Approval | \`$LATEST_APPROVAL\` |" | |
| echo "| Pending Deployments | \`$PENDING_COUNT\` |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Download release bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: release-bundle | |
| path: artifacts/release-bundle | |
| - name: Verify release bundle manifest and hashes | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import hashlib | |
| import json | |
| import sys | |
| from pathlib import Path | |
| manifest_path = Path("artifacts/release-bundle/release-manifest.json") | |
| if not manifest_path.exists(): | |
| print(f"Missing manifest: {manifest_path}") | |
| sys.exit(1) | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| expected_version = "${{ needs.version.outputs.full_version }}" | |
| if manifest.get("version") != expected_version: | |
| print(f"Version mismatch: expected {expected_version}, actual {manifest.get('version')}") | |
| sys.exit(1) | |
| expected_commit_sha = "${{ needs.version.outputs.sha }}" | |
| if manifest.get("commitSha") != expected_commit_sha: | |
| print(f"Commit SHA mismatch: expected {expected_commit_sha}, actual {manifest.get('commitSha')}") | |
| sys.exit(1) | |
| expected_workflow_run_id = "${{ github.run_id }}" | |
| if str(manifest.get("workflowRunId")) != expected_workflow_run_id: | |
| print(f"Workflow run id mismatch: expected {expected_workflow_run_id}, actual {manifest.get('workflowRunId')}") | |
| sys.exit(1) | |
| def digest(path: Path) -> str: | |
| h = hashlib.sha256() | |
| with path.open("rb") as f: | |
| for chunk in iter(lambda: f.read(1024 * 1024), b""): | |
| h.update(chunk) | |
| return h.hexdigest() | |
| for item in manifest.get("nugetPackages", []): | |
| path = Path("artifacts/release-bundle/nuget") / item["file"] | |
| if not path.exists(): | |
| print(f"Missing NuGet package: {path}") | |
| sys.exit(1) | |
| if digest(path) != item["sha256"]: | |
| print(f"Hash mismatch: {path}") | |
| sys.exit(1) | |
| for item in manifest.get("npmPackages", []): | |
| path = Path("artifacts/release-bundle/npm") / item["file"] | |
| if not path.exists(): | |
| print(f"Missing npm package: {path}") | |
| sys.exit(1) | |
| if digest(path) != item["sha256"]: | |
| print(f"Hash mismatch: {path}") | |
| sys.exit(1) | |
| print("Release bundle verification passed.") | |
| PY | |
| publish-nuget: | |
| name: Publish NuGet | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [version, release-gate] | |
| if: needs.version.outputs.is_release == 'true' | |
| steps: | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 | |
| with: | |
| dotnet-version: "10.0.x" | |
| dotnet-quality: "preview" | |
| - name: Download release bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: release-bundle | |
| path: artifacts/release-bundle | |
| - name: Push NuGet packages | |
| run: | | |
| dotnet nuget push "artifacts/release-bundle/nuget/*.nupkg" \ | |
| --source https://api.nuget.org/v3/index.json \ | |
| --api-key "${{ secrets.NUGET_API_KEY }}" \ | |
| --skip-duplicate | |
| publish-npm: | |
| name: Publish npm | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [version, release-gate] | |
| if: needs.version.outputs.is_release == 'true' | |
| steps: | |
| - name: Setup Node.js | |
| uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 | |
| with: | |
| node-version: "22" | |
| registry-url: "https://registry.npmjs.org" | |
| - name: Download release bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: release-bundle | |
| path: artifacts/release-bundle | |
| - name: Publish bridge package to npm | |
| run: | | |
| set -euo pipefail | |
| mapfile -t tgz_files < <(ls artifacts/release-bundle/npm/*.tgz) | |
| if [[ "${#tgz_files[@]}" -eq 0 ]]; then | |
| echo "No npm tarball found in release bundle." | |
| exit 1 | |
| fi | |
| VERSION_SUFFIX="${{ needs.version.outputs.version_suffix }}" | |
| if [ -z "$VERSION_SUFFIX" ]; then | |
| NPM_TAG="latest" | |
| else | |
| NPM_TAG="next" | |
| fi | |
| npm publish "${tgz_files[0]}" --access public --tag "$NPM_TAG" | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| create-github-release: | |
| name: Create Tag and GitHub Release | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [version, publish-nuget, publish-npm] | |
| if: needs.version.outputs.is_release == 'true' | |
| permissions: | |
| contents: write | |
| attestations: write | |
| id-token: write | |
| concurrency: | |
| group: release-tag-${{ needs.version.outputs.tag }} | |
| cancel-in-progress: true | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ needs.version.outputs.sha }} | |
| fetch-depth: 0 | |
| - name: Download release bundle | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: release-bundle | |
| path: artifacts/release-bundle | |
| - name: Generate SBOM | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| artifact-name: sbom-spdx.json | |
| output-file: sbom-spdx.json | |
| format: spdx-json | |
| - name: Collect attestation subjects | |
| id: attest-subjects | |
| shell: bash | |
| run: | | |
| shopt -s nullglob | |
| SUBJECTS="" | |
| for f in artifacts/release-bundle/nuget/*.nupkg; do | |
| SUBJECTS+="$f"$'\n' | |
| done | |
| for f in artifacts/release-bundle/npm/*.tgz; do | |
| SUBJECTS+="$f"$'\n' | |
| done | |
| SUBJECTS=$(echo "$SUBJECTS" | sed '/^$/d') | |
| echo "subjects<<EOF" >> "$GITHUB_OUTPUT" | |
| echo "$SUBJECTS" >> "$GITHUB_OUTPUT" | |
| echo "EOF" >> "$GITHUB_OUTPUT" | |
| - name: Attest build provenance | |
| if: steps.attest-subjects.outputs.subjects != '' | |
| uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 | |
| with: | |
| subject-path: ${{ steps.attest-subjects.outputs.subjects }} | |
| - name: Attest SBOM | |
| if: steps.attest-subjects.outputs.subjects != '' && hashFiles('sbom-spdx.json') != '' | |
| uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 | |
| with: | |
| subject-path: ${{ steps.attest-subjects.outputs.subjects }} | |
| sbom-path: sbom-spdx.json | |
| - name: Create Tag and GitHub Release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| TAG="${{ needs.version.outputs.tag }}" | |
| SHA="${{ needs.version.outputs.sha }}" | |
| RELEASE_NAME="Fulora $TAG" | |
| VERSION_SUFFIX="${{ needs.version.outputs.version_suffix }}" | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| echo "Release for tag $TAG already exists. Assuming success." | |
| exit 0 | |
| fi | |
| PRERELEASE_FLAG="" | |
| if [ -n "$VERSION_SUFFIX" ]; then | |
| PRERELEASE_FLAG="--prerelease" | |
| fi | |
| # The --target parameter instructs the GitHub API to create the tag | |
| # at the specified SHA if it doesn't already exist. | |
| gh release create "$TAG" \ | |
| --target "$SHA" \ | |
| --title "$RELEASE_NAME" \ | |
| --generate-notes \ | |
| $PRERELEASE_FLAG | |
| echo "Created release and tag (if absent) $TAG at $SHA" | |
| deploy-docs: | |
| name: Deploy Documentation | |
| needs: [version, create-github-release, build-docs] | |
| if: needs.version.outputs.is_release == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| pages: write | |
| id-token: write | |
| concurrency: | |
| group: pages | |
| cancel-in-progress: false | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deployment.outputs.page_url }} | |
| steps: | |
| - name: Download docs artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: docs-site | |
| path: docs-site | |
| - name: Upload pages artifact | |
| uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 | |
| with: | |
| path: docs-site | |
| - name: Deploy to GitHub Pages | |
| id: deployment | |
| uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 |