Skip to content

Make the brownfield attach-web path explicit in docs #276

Make the brownfield attach-web path explicit in docs

Make the brownfield attach-web path explicit in docs #276

Workflow file for this run

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