mirror of
https://github.com/actions/runner.git
synced 2025-12-11 04:46:58 +00:00
Compare commits
42 Commits
copilot/fi
...
chore/npm-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2cd591665 | ||
|
|
b5b7986cd6 | ||
|
|
53d69ff441 | ||
|
|
bca18f71d0 | ||
|
|
1b8efb99f6 | ||
|
|
0b2c71fc31 | ||
|
|
60af948051 | ||
|
|
ff775ca101 | ||
|
|
f74be39e77 | ||
|
|
1eb15f28a7 | ||
|
|
afe4fc8446 | ||
|
|
a12731d34d | ||
|
|
18f2450d71 | ||
|
|
2c5f29c3ca | ||
|
|
c9de9a8699 | ||
|
|
68ff57dbc4 | ||
|
|
c774eb8d46 | ||
|
|
f184048a9a | ||
|
|
338d83a941 | ||
|
|
0b074a3e93 | ||
|
|
25faeabaa8 | ||
|
|
b121ef832b | ||
|
|
170033c92b | ||
|
|
f9c4e17fd9 | ||
|
|
646da708ba | ||
|
|
bf8236344b | ||
|
|
720f16aef6 | ||
|
|
f77066a6a8 | ||
|
|
df83df2a32 | ||
|
|
97b2254146 | ||
|
|
7f72ba9e48 | ||
|
|
f8ae5bb1a7 | ||
|
|
a5631456a2 | ||
|
|
65dfa460ba | ||
|
|
80ee51f164 | ||
|
|
c95883f28e | ||
|
|
6e940643a9 | ||
|
|
629f2384a4 | ||
|
|
c3bf70becb | ||
|
|
8b65f5f9df | ||
|
|
5f1efec208 | ||
|
|
20d82ad357 |
@@ -4,7 +4,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
|
||||
"ghcr.io/devcontainers/features/dotnet": {
|
||||
"version": "8.0.412"
|
||||
"version": "8.0.415"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🛑 Request a feature in the runner application
|
||||
url: https://github.com/orgs/community/discussions/categories/actions-and-packages
|
||||
about: If you have feature requests for GitHub Actions, please use the Actions and Packages section on the Github Product Feedback page.
|
||||
url: https://github.com/orgs/community/discussions/categories/actions
|
||||
about: If you have feature requests for GitHub Actions, please use the Actions section on the Github Product Feedback page.
|
||||
- name: ✅ Support for GitHub Actions
|
||||
url: https://github.community/c/code-to-cloud/52
|
||||
about: If you have questions about GitHub Actions or need support writing workflows, please ask in the GitHub Community Support forum.
|
||||
|
||||
2
.github/workflows/close-bugs-bot.yml
vendored
2
.github/workflows/close-bugs-bot.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
close-issue-message: "This issue does not seem to be a problem with the runner application, it concerns the GitHub actions platform more generally. Could you please post your feedback on the [GitHub Community Support Forum](https://github.com/orgs/community/discussions/categories/actions) which is actively monitored. Using the forum ensures that we route your problem to the correct team. 😃"
|
||||
exempt-issue-labels: "keep"
|
||||
|
||||
4
.github/workflows/close-features-bot.yml
vendored
4
.github/workflows/close-features-bot.yml
vendored
@@ -7,9 +7,9 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
close-issue-message: "Thank you for your interest in the runner application and taking the time to provide your valuable feedback. We kindly ask you to redirect this feedback to the [GitHub Community Support Forum](https://github.com/orgs/community/discussions/categories/actions-and-packages) which our team actively monitors and would be a better place to start a discussion for new feature requests in GitHub Actions. For more information on this policy please [read our contribution guidelines](https://github.com/actions/runner#contribute). 😃"
|
||||
close-issue-message: "Thank you for your interest in the runner application and taking the time to provide your valuable feedback. We kindly ask you to redirect this feedback to the [GitHub Community Support Forum](https://github.com/orgs/community/discussions/categories/actions) which our team actively monitors and would be a better place to start a discussion for new feature requests in GitHub Actions. For more information on this policy please [read our contribution guidelines](https://github.com/actions/runner#contribute). 😃"
|
||||
exempt-issue-labels: "keep"
|
||||
stale-issue-label: "actions-feature"
|
||||
only-labels: "actions-feature"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
@@ -38,4 +38,4 @@ jobs:
|
||||
working-directory: src
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
211
.github/workflows/dependency-check.yml
vendored
Normal file
211
.github/workflows/dependency-check.yml
vendored
Normal file
@@ -0,0 +1,211 @@
|
||||
name: Dependency Status Check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
check_type:
|
||||
description: "Type of dependency check"
|
||||
required: false
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- node
|
||||
- dotnet
|
||||
- docker
|
||||
- npm
|
||||
schedule:
|
||||
- cron: "0 11 * * 1" # Weekly on Monday at 11 AM
|
||||
|
||||
jobs:
|
||||
dependency-status:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
node20-status: ${{ steps.check-versions.outputs.node20-status }}
|
||||
node24-status: ${{ steps.check-versions.outputs.node24-status }}
|
||||
dotnet-status: ${{ steps.check-versions.outputs.dotnet-status }}
|
||||
docker-status: ${{ steps.check-versions.outputs.docker-status }}
|
||||
buildx-status: ${{ steps.check-versions.outputs.buildx-status }}
|
||||
npm-vulnerabilities: ${{ steps.check-versions.outputs.npm-vulnerabilities }}
|
||||
open-dependency-prs: ${{ steps.check-prs.outputs.open-dependency-prs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Check dependency versions
|
||||
id: check-versions
|
||||
run: |
|
||||
echo "## Dependency Status Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Generated on: $(date)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check Node versions
|
||||
if [[ "${{ github.event.inputs.check_type }}" == "all" || "${{ github.event.inputs.check_type }}" == "node" ]]; then
|
||||
echo "### Node.js Versions" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
VERSIONS_JSON=$(curl -s https://raw.githubusercontent.com/actions/node-versions/main/versions-manifest.json)
|
||||
LATEST_NODE20=$(echo "$VERSIONS_JSON" | jq -r '.[] | select(.version | startswith("20.")) | .version' | head -1)
|
||||
LATEST_NODE24=$(echo "$VERSIONS_JSON" | jq -r '.[] | select(.version | startswith("24.")) | .version' | head -1)
|
||||
|
||||
CURRENT_NODE20=$(grep "NODE20_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
|
||||
CURRENT_NODE24=$(grep "NODE24_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
|
||||
|
||||
NODE20_STATUS="✅ up-to-date"
|
||||
NODE24_STATUS="✅ up-to-date"
|
||||
|
||||
if [ "$CURRENT_NODE20" != "$LATEST_NODE20" ]; then
|
||||
NODE20_STATUS="⚠️ outdated"
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_NODE24" != "$LATEST_NODE24" ]; then
|
||||
NODE24_STATUS="⚠️ outdated"
|
||||
fi
|
||||
|
||||
echo "| Version | Current | Latest | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---------|---------|--------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Node 20 | $CURRENT_NODE20 | $LATEST_NODE20 | $NODE20_STATUS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Node 24 | $CURRENT_NODE24 | $LATEST_NODE24 | $NODE24_STATUS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "node20-status=$NODE20_STATUS" >> $GITHUB_OUTPUT
|
||||
echo "node24-status=$NODE24_STATUS" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check .NET version
|
||||
if [[ "${{ github.event.inputs.check_type }}" == "all" || "${{ github.event.inputs.check_type }}" == "dotnet" ]]; then
|
||||
echo "### .NET SDK Version" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
current_dotnet_version=$(jq -r .sdk.version ./src/global.json)
|
||||
current_major_minor=$(echo "$current_dotnet_version" | cut -d '.' -f 1,2)
|
||||
latest_dotnet_version=$(curl -sb -H "Accept: application/json" "https://dotnetcli.blob.core.windows.net/dotnet/Sdk/$current_major_minor/latest.version")
|
||||
|
||||
DOTNET_STATUS="✅ up-to-date"
|
||||
if [ "$current_dotnet_version" != "$latest_dotnet_version" ]; then
|
||||
DOTNET_STATUS="⚠️ outdated"
|
||||
fi
|
||||
|
||||
echo "| Component | Current | Latest | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-----------|---------|--------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| .NET SDK | $current_dotnet_version | $latest_dotnet_version | $DOTNET_STATUS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "dotnet-status=$DOTNET_STATUS" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check Docker versions
|
||||
if [[ "${{ github.event.inputs.check_type }}" == "all" || "${{ github.event.inputs.check_type }}" == "docker" ]]; then
|
||||
echo "### Docker Versions" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
current_docker=$(grep "ARG DOCKER_VERSION=" ./images/Dockerfile | cut -d'=' -f2)
|
||||
current_buildx=$(grep "ARG BUILDX_VERSION=" ./images/Dockerfile | cut -d'=' -f2)
|
||||
|
||||
latest_docker=$(curl -s https://download.docker.com/linux/static/stable/x86_64/ | grep -o 'docker-[0-9]*\.[0-9]*\.[0-9]*\.tgz' | sort -V | tail -n 1 | sed 's/docker-\(.*\)\.tgz/\1/')
|
||||
latest_buildx=$(curl -s https://api.github.com/repos/docker/buildx/releases/latest | jq -r '.tag_name' | sed 's/^v//')
|
||||
|
||||
DOCKER_STATUS="✅ up-to-date"
|
||||
BUILDX_STATUS="✅ up-to-date"
|
||||
|
||||
if [ "$current_docker" != "$latest_docker" ]; then
|
||||
DOCKER_STATUS="⚠️ outdated"
|
||||
fi
|
||||
|
||||
if [ "$current_buildx" != "$latest_buildx" ]; then
|
||||
BUILDX_STATUS="⚠️ outdated"
|
||||
fi
|
||||
|
||||
echo "| Component | Current | Latest | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-----------|---------|--------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Docker | $current_docker | $latest_docker | $DOCKER_STATUS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Docker Buildx | $current_buildx | $latest_buildx | $BUILDX_STATUS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "docker-status=$DOCKER_STATUS" >> $GITHUB_OUTPUT
|
||||
echo "buildx-status=$BUILDX_STATUS" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check npm vulnerabilities
|
||||
if [[ "${{ github.event.inputs.check_type }}" == "all" || "${{ github.event.inputs.check_type }}" == "npm" ]]; then
|
||||
echo "### NPM Security Audit" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
cd src/Misc/expressionFunc/hashFiles
|
||||
npm install --silent
|
||||
|
||||
AUDIT_OUTPUT=""
|
||||
AUDIT_EXIT_CODE=0
|
||||
# Run npm audit and capture output and exit code
|
||||
if ! AUDIT_OUTPUT=$(npm audit --json 2>&1); then
|
||||
AUDIT_EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
# Check if output is valid JSON
|
||||
if echo "$AUDIT_OUTPUT" | jq . >/dev/null 2>&1; then
|
||||
VULN_COUNT=$(echo "$AUDIT_OUTPUT" | jq '.metadata.vulnerabilities.total // 0')
|
||||
# Ensure VULN_COUNT is a number
|
||||
VULN_COUNT=$(echo "$VULN_COUNT" | grep -o '[0-9]*' | head -1)
|
||||
VULN_COUNT=${VULN_COUNT:-0}
|
||||
|
||||
NPM_STATUS="✅ no vulnerabilities"
|
||||
if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
NPM_STATUS="⚠️ $VULN_COUNT vulnerabilities found"
|
||||
|
||||
# Get vulnerability details
|
||||
HIGH_VULNS=$(echo "$AUDIT_OUTPUT" | jq '.metadata.vulnerabilities.high // 0')
|
||||
CRITICAL_VULNS=$(echo "$AUDIT_OUTPUT" | jq '.metadata.vulnerabilities.critical // 0')
|
||||
|
||||
echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Critical | $CRITICAL_VULNS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| High | $HIGH_VULNS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No npm vulnerabilities found ✅" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
NPM_STATUS="❌ npm audit failed"
|
||||
echo "npm audit failed to run or returned invalid JSON ❌" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Exit code: $AUDIT_EXIT_CODE" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Output: $AUDIT_OUTPUT" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "npm-vulnerabilities=$NPM_STATUS" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check for open dependency PRs
|
||||
id: check-prs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "### Open Dependency PRs" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Get open PRs with dependency label
|
||||
OPEN_PRS=$(gh pr list --label "dependencies" --state open --json number,title,url)
|
||||
PR_COUNT=$(echo "$OPEN_PRS" | jq '. | length')
|
||||
|
||||
if [ "$PR_COUNT" -gt 0 ]; then
|
||||
echo "Found $PR_COUNT open dependency PR(s):" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "$OPEN_PRS" | jq -r '.[] | "- [#\(.number)](\(.url)) \(.title)"' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No open dependency PRs found ✅" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "open-dependency-prs=$PR_COUNT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Check for open PRs with the \`dependency\` label before releases" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Review and merge dependency updates regularly" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Critical vulnerabilities should be addressed immediately" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Automated workflows run weekly to check for updates:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Node.js versions (Mondays at 6 AM)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- NPM audit fix (Mondays at 7 AM)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- .NET SDK updates (Mondays at midnight)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Docker/Buildx updates (Mondays at midnight)" >> $GITHUB_STEP_SUMMARY
|
||||
58
.github/workflows/docker-buildx-upgrade.yml
vendored
58
.github/workflows/docker-buildx-upgrade.yml
vendored
@@ -2,8 +2,8 @@ name: "Docker/Buildx Version Upgrade"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # Run every Monday at midnight
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
- cron: "0 0 * * 1" # Run every Monday at midnight
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
check-versions:
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
echo "Failed to retrieve a valid Docker version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
should_update=0
|
||||
[ "$current_version" != "$latest_version" ] && should_update=1
|
||||
|
||||
@@ -64,17 +64,17 @@ jobs:
|
||||
run: |
|
||||
docker_should_update="${{ steps.check_docker_version.outputs.SHOULD_UPDATE }}"
|
||||
buildx_should_update="${{ steps.check_buildx_version.outputs.SHOULD_UPDATE }}"
|
||||
|
||||
|
||||
# Show annotation if only Docker needs update
|
||||
if [[ "$docker_should_update" == "1" && "$buildx_should_update" == "0" ]]; then
|
||||
echo "::warning ::Docker version (${{ steps.check_docker_version.outputs.LATEST_VERSION }}) needs update but Buildx is current. Only updating when both need updates."
|
||||
fi
|
||||
|
||||
|
||||
# Show annotation if only Buildx needs update
|
||||
if [[ "$docker_should_update" == "0" && "$buildx_should_update" == "1" ]]; then
|
||||
echo "::warning ::Buildx version (${{ steps.check_buildx_version.outputs.LATEST_VERSION }}) needs update but Docker is current. Only updating when both need updates."
|
||||
fi
|
||||
|
||||
|
||||
# Show annotation when both are current
|
||||
if [[ "$docker_should_update" == "0" && "$buildx_should_update" == "0" ]]; then
|
||||
echo "::warning ::Latest Docker version is ${{ steps.check_docker_version.outputs.LATEST_VERSION }} and Buildx version is ${{ steps.check_buildx_version.outputs.LATEST_VERSION }}. No updates needed."
|
||||
@@ -90,25 +90,25 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
|
||||
- name: Update Docker version
|
||||
shell: bash
|
||||
run: |
|
||||
latest_version="${{ needs.check-versions.outputs.DOCKER_LATEST_VERSION }}"
|
||||
current_version="${{ needs.check-versions.outputs.DOCKER_CURRENT_VERSION }}"
|
||||
|
||||
|
||||
# Update version in Dockerfile
|
||||
sed -i "s/ARG DOCKER_VERSION=$current_version/ARG DOCKER_VERSION=$latest_version/g" ./images/Dockerfile
|
||||
|
||||
|
||||
- name: Update Buildx version
|
||||
shell: bash
|
||||
run: |
|
||||
latest_version="${{ needs.check-versions.outputs.BUILDX_LATEST_VERSION }}"
|
||||
current_version="${{ needs.check-versions.outputs.BUILDX_CURRENT_VERSION }}"
|
||||
|
||||
|
||||
# Update version in Dockerfile
|
||||
sed -i "s/ARG BUILDX_VERSION=$current_version/ARG BUILDX_VERSION=$latest_version/g" ./images/Dockerfile
|
||||
|
||||
|
||||
- name: Commit changes and create Pull Request
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
branch_name="feature/docker-buildx-upgrade"
|
||||
commit_message="Upgrade Docker to v${{ needs.check-versions.outputs.DOCKER_LATEST_VERSION }} and Buildx to v${{ needs.check-versions.outputs.BUILDX_LATEST_VERSION }}"
|
||||
pr_title="Update Docker to v${{ needs.check-versions.outputs.DOCKER_LATEST_VERSION }} and Buildx to v${{ needs.check-versions.outputs.BUILDX_LATEST_VERSION }}"
|
||||
|
||||
|
||||
# Configure git
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "<41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
@@ -129,16 +129,38 @@ jobs:
|
||||
else
|
||||
git checkout -b "$branch_name"
|
||||
fi
|
||||
|
||||
|
||||
# Commit and push changes
|
||||
git commit -a -m "$commit_message"
|
||||
git push --force origin "$branch_name"
|
||||
|
||||
# Create PR body using here-doc for proper formatting
|
||||
cat > pr_body.txt << 'EOF'
|
||||
Automated Docker and Buildx version update:
|
||||
|
||||
- Docker: ${{ needs.check-versions.outputs.DOCKER_CURRENT_VERSION }} → ${{ needs.check-versions.outputs.DOCKER_LATEST_VERSION }}
|
||||
- Buildx: ${{ needs.check-versions.outputs.BUILDX_CURRENT_VERSION }} → ${{ needs.check-versions.outputs.BUILDX_LATEST_VERSION }}
|
||||
|
||||
This update ensures we're using the latest stable Docker and Buildx versions for security and performance improvements.
|
||||
|
||||
**Release notes:** https://docs.docker.com/engine/release-notes/
|
||||
|
||||
**Next steps:**
|
||||
- Review the version changes
|
||||
- Verify container builds work as expected
|
||||
- Test multi-platform builds if applicable
|
||||
- Merge when ready
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [Docker/Buildx Version Upgrade Workflow](https://github.com/actions/runner/blob/main/.github/workflows/docker-buildx-upgrade.yml)
|
||||
EOF
|
||||
|
||||
# Create PR
|
||||
pr_body="Upgrades Docker version from ${{ needs.check-versions.outputs.DOCKER_CURRENT_VERSION }} to ${{ needs.check-versions.outputs.DOCKER_LATEST_VERSION }} and Docker Buildx version from ${{ needs.check-versions.outputs.BUILDX_CURRENT_VERSION }} to ${{ needs.check-versions.outputs.BUILDX_LATEST_VERSION }}.\n\n"
|
||||
pr_body+="Release notes: https://docs.docker.com/engine/release-notes/\n\n"
|
||||
pr_body+="---\n\nAutogenerated by [Docker/Buildx Version Upgrade Workflow](https://github.com/actions/runner/blob/main/.github/workflows/docker-buildx-upgrade.yml)"
|
||||
|
||||
gh pr create -B main -H "$branch_name" \
|
||||
--title "$pr_title" \
|
||||
--body "$pr_body"
|
||||
--label "dependencies" \
|
||||
--label "dependencies-weekly-check" \
|
||||
--label "dependencies-not-dependabot" \
|
||||
--label "docker" \
|
||||
--body-file pr_body.txt
|
||||
|
||||
32
.github/workflows/dotnet-upgrade.yml
vendored
32
.github/workflows/dotnet-upgrade.yml
vendored
@@ -2,13 +2,13 @@ name: "DotNet SDK Upgrade"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
- cron: "0 8 * * 1" # Weekly on Monday at 8 AM UTC (independent of Node.js/NPM)
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dotnet-update:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
outputs:
|
||||
SHOULD_UPDATE: ${{ steps.fetch_latest_version.outputs.SHOULD_UPDATE }}
|
||||
BRANCH_EXISTS: ${{ steps.fetch_latest_version.outputs.BRANCH_EXISTS }}
|
||||
DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION: ${{ steps.fetch_latest_version.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
# check if git branch already exists for the upgrade
|
||||
branch_already_exists=0
|
||||
|
||||
|
||||
if git ls-remote --heads --exit-code origin refs/heads/feature/dotnetsdk-upgrade/${latest_patch_version};
|
||||
then
|
||||
branch_already_exists=1
|
||||
@@ -89,17 +89,17 @@ jobs:
|
||||
if: ${{ needs.dotnet-update.outputs.SHOULD_UPDATE == 1 && needs.dotnet-update.outputs.BRANCH_EXISTS == 0 }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
|
||||
- name: Create Pull Request
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr create -B main -H feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }} --title "Update dotnet sdk to latest version @${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}" --body "
|
||||
https://dotnetcli.blob.core.windows.net/dotnet/Sdk/${{ needs.dotnet-update.outputs.DOTNET_CURRENT_MAJOR_MINOR_VERSION }}/latest.version
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
|
||||
- name: Create Pull Request
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr create -B main -H feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }} --title "Update dotnet sdk to latest version @${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}" --label "dependencies" --label "dependencies-weekly-check" --label "dependencies-not-dependabot" --label "dotnet" --body "
|
||||
https://dotnetcli.blob.core.windows.net/dotnet/Sdk/${{ needs.dotnet-update.outputs.DOTNET_CURRENT_MAJOR_MINOR_VERSION }}/latest.version
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [DotNet SDK Upgrade Workflow](https://github.com/actions/runner/blob/main/.github/workflows/dotnet-upgrade.yml)"
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [DotNet SDK Upgrade Workflow](https://github.com/actions/runner/blob/main/.github/workflows/dotnet-upgrade.yml)"
|
||||
|
||||
194
.github/workflows/node-upgrade.yml
vendored
Normal file
194
.github/workflows/node-upgrade.yml
vendored
Normal file
@@ -0,0 +1,194 @@
|
||||
name: Auto Update Node Version
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly, every Monday
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-node:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Get latest Node versions
|
||||
id: node-versions
|
||||
run: |
|
||||
# Get latest Node.js releases from official GitHub releases
|
||||
echo "Fetching latest Node.js releases..."
|
||||
|
||||
# Get latest v20.x release
|
||||
LATEST_NODE20=$(curl -s https://api.github.com/repos/nodejs/node/releases | \
|
||||
jq -r '.[] | select(.tag_name | startswith("v20.")) | .tag_name' | \
|
||||
head -1 | sed 's/^v//')
|
||||
|
||||
# Get latest v24.x release
|
||||
LATEST_NODE24=$(curl -s https://api.github.com/repos/nodejs/node/releases | \
|
||||
jq -r '.[] | select(.tag_name | startswith("v24.")) | .tag_name' | \
|
||||
head -1 | sed 's/^v//')
|
||||
|
||||
echo "Found Node.js releases: 20=$LATEST_NODE20, 24=$LATEST_NODE24"
|
||||
|
||||
# Verify these versions are available in alpine_nodejs releases
|
||||
echo "Verifying availability in alpine_nodejs..."
|
||||
ALPINE_RELEASES=$(curl -s https://api.github.com/repos/actions/alpine_nodejs/releases | jq -r '.[].tag_name')
|
||||
|
||||
if ! echo "$ALPINE_RELEASES" | grep -q "^v$LATEST_NODE20$"; then
|
||||
echo "::warning title=Node 20 Fallback::Node 20 version $LATEST_NODE20 not found in alpine_nodejs releases, using fallback"
|
||||
# Fall back to latest available alpine_nodejs v20 release
|
||||
LATEST_NODE20=$(echo "$ALPINE_RELEASES" | grep "^v20\." | head -1 | sed 's/^v//')
|
||||
echo "Using latest available alpine_nodejs Node 20: $LATEST_NODE20"
|
||||
fi
|
||||
|
||||
if ! echo "$ALPINE_RELEASES" | grep -q "^v$LATEST_NODE24$"; then
|
||||
echo "::warning title=Node 24 Fallback::Node 24 version $LATEST_NODE24 not found in alpine_nodejs releases, using fallback"
|
||||
# Fall back to latest available alpine_nodejs v24 release
|
||||
LATEST_NODE24=$(echo "$ALPINE_RELEASES" | grep "^v24\." | head -1 | sed 's/^v//')
|
||||
echo "Using latest available alpine_nodejs Node 24: $LATEST_NODE24"
|
||||
fi
|
||||
|
||||
# Validate that we have non-empty version numbers
|
||||
if [ -z "$LATEST_NODE20" ] || [ "$LATEST_NODE20" = "" ]; then
|
||||
echo "::error title=Invalid Node 20 Version::Failed to determine valid Node 20 version. Got: '$LATEST_NODE20'"
|
||||
echo "Available alpine_nodejs releases:"
|
||||
echo "$ALPINE_RELEASES" | head -10
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$LATEST_NODE24" ] || [ "$LATEST_NODE24" = "" ]; then
|
||||
echo "::error title=Invalid Node 24 Version::Failed to determine valid Node 24 version. Got: '$LATEST_NODE24'"
|
||||
echo "Available alpine_nodejs releases:"
|
||||
echo "$ALPINE_RELEASES" | head -10
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Additional validation: ensure versions match expected format (x.y.z)
|
||||
if ! echo "$LATEST_NODE20" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error title=Invalid Node 20 Format::Node 20 version '$LATEST_NODE20' does not match expected format (x.y.z)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$LATEST_NODE24" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error title=Invalid Node 24 Format::Node 24 version '$LATEST_NODE24' does not match expected format (x.y.z)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Validated Node versions: 20=$LATEST_NODE20, 24=$LATEST_NODE24"
|
||||
echo "latest_node20=$LATEST_NODE20" >> $GITHUB_OUTPUT
|
||||
echo "latest_node24=$LATEST_NODE24" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check current versions in externals.sh
|
||||
CURRENT_NODE20=$(grep "NODE20_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
|
||||
CURRENT_NODE24=$(grep "NODE24_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
|
||||
|
||||
echo "current_node20=$CURRENT_NODE20" >> $GITHUB_OUTPUT
|
||||
echo "current_node24=$CURRENT_NODE24" >> $GITHUB_OUTPUT
|
||||
|
||||
# Determine if updates are needed
|
||||
NEEDS_UPDATE20="false"
|
||||
NEEDS_UPDATE24="false"
|
||||
|
||||
if [ "$CURRENT_NODE20" != "$LATEST_NODE20" ]; then
|
||||
NEEDS_UPDATE20="true"
|
||||
echo "::notice title=Node 20 Update Available::Current: $CURRENT_NODE20 → Latest: $LATEST_NODE20"
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_NODE24" != "$LATEST_NODE24" ]; then
|
||||
NEEDS_UPDATE24="true"
|
||||
echo "::notice title=Node 24 Update Available::Current: $CURRENT_NODE24 → Latest: $LATEST_NODE24"
|
||||
fi
|
||||
|
||||
if [ "$NEEDS_UPDATE20" == "false" ] && [ "$NEEDS_UPDATE24" == "false" ]; then
|
||||
echo "::notice title=No Updates Needed::All Node.js versions are up to date"
|
||||
fi
|
||||
|
||||
echo "needs_update20=$NEEDS_UPDATE20" >> $GITHUB_OUTPUT
|
||||
echo "needs_update24=$NEEDS_UPDATE24" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update externals.sh and create PR
|
||||
if: steps.node-versions.outputs.needs_update20 == 'true' || steps.node-versions.outputs.needs_update24 == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Final validation before making changes
|
||||
NODE20_VERSION="${{ steps.node-versions.outputs.latest_node20 }}"
|
||||
NODE24_VERSION="${{ steps.node-versions.outputs.latest_node24 }}"
|
||||
|
||||
echo "Final validation of versions before PR creation:"
|
||||
echo "Node 20: '$NODE20_VERSION'"
|
||||
echo "Node 24: '$NODE24_VERSION'"
|
||||
|
||||
# Validate versions are not empty and match expected format
|
||||
if [ -z "$NODE20_VERSION" ] || ! echo "$NODE20_VERSION" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error title=Invalid Node 20 Version::Refusing to create PR with invalid Node 20 version: '$NODE20_VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$NODE24_VERSION" ] || ! echo "$NODE24_VERSION" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error title=Invalid Node 24 Version::Refusing to create PR with invalid Node 24 version: '$NODE24_VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All versions validated successfully"
|
||||
|
||||
# Update the files
|
||||
if [ "${{ steps.node-versions.outputs.needs_update20 }}" == "true" ]; then
|
||||
sed -i 's/NODE20_VERSION="[^"]*"/NODE20_VERSION="'"$NODE20_VERSION"'"/' src/Misc/externals.sh
|
||||
fi
|
||||
|
||||
if [ "${{ steps.node-versions.outputs.needs_update24 }}" == "true" ]; then
|
||||
sed -i 's/NODE24_VERSION="[^"]*"/NODE24_VERSION="'"$NODE24_VERSION"'"/' src/Misc/externals.sh
|
||||
fi
|
||||
|
||||
# Verify the changes were applied correctly
|
||||
echo "Verifying changes in externals.sh:"
|
||||
grep "NODE20_VERSION=" src/Misc/externals.sh
|
||||
grep "NODE24_VERSION=" src/Misc/externals.sh
|
||||
|
||||
# Ensure we actually have valid versions in the file
|
||||
UPDATED_NODE20=$(grep "NODE20_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
|
||||
UPDATED_NODE24=$(grep "NODE24_VERSION=" src/Misc/externals.sh | cut -d'"' -f2)
|
||||
|
||||
if [ -z "$UPDATED_NODE20" ] || [ -z "$UPDATED_NODE24" ]; then
|
||||
echo "::error title=Update Failed::Failed to properly update externals.sh"
|
||||
echo "Updated Node 20: '$UPDATED_NODE20'"
|
||||
echo "Updated Node 24: '$UPDATED_NODE24'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configure git
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "<41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
|
||||
# Create branch and commit changes
|
||||
branch_name="chore/update-node"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -a -m "chore: update Node versions (20: $NODE20_VERSION, 24: $NODE24_VERSION)"
|
||||
git push --force origin "$branch_name"
|
||||
|
||||
# Create PR body using here-doc for proper formatting
|
||||
cat > pr_body.txt << EOF
|
||||
Automated Node.js version update:
|
||||
|
||||
- Node 20: ${{ steps.node-versions.outputs.current_node20 }} → $NODE20_VERSION
|
||||
- Node 24: ${{ steps.node-versions.outputs.current_node24 }} → $NODE24_VERSION
|
||||
|
||||
This update ensures we're using the latest stable Node.js versions for security and performance improvements.
|
||||
|
||||
**Note**: When updating Node versions, remember to also create a new release of alpine_nodejs at the updated version following the instructions at: https://github.com/actions/alpine_nodejs
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [Node Version Upgrade Workflow](https://github.com/actions/runner/blob/main/.github/workflows/node-upgrade.yml)
|
||||
EOF
|
||||
|
||||
# Create PR
|
||||
gh pr create -B main -H "$branch_name" \
|
||||
--title "chore: update Node versions" \
|
||||
--label "dependencies" \
|
||||
--label "dependencies-weekly-check" \
|
||||
--label "dependencies-not-dependabot" \
|
||||
--label "node" \
|
||||
--label "javascript" \
|
||||
--body-file pr_body.txt
|
||||
|
||||
echo "::notice title=PR Created::Successfully created Node.js version update PR on branch $branch_name"
|
||||
235
.github/workflows/npm-audit-typescript.yml
vendored
Normal file
235
.github/workflows/npm-audit-typescript.yml
vendored
Normal file
@@ -0,0 +1,235 @@
|
||||
name: NPM Audit Fix with TypeScript Auto-Fix
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
npm-audit-with-ts-fix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: NPM install and audit fix with TypeScript auto-repair
|
||||
working-directory: src/Misc/expressionFunc/hashFiles
|
||||
run: |
|
||||
npm install
|
||||
|
||||
# Check for vulnerabilities first
|
||||
echo "Checking for npm vulnerabilities..."
|
||||
if npm audit --audit-level=moderate; then
|
||||
echo "✅ No moderate or higher vulnerabilities found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "⚠️ Vulnerabilities found, attempting npm audit fix..."
|
||||
|
||||
# Attempt audit fix and capture the result
|
||||
if npm audit fix; then
|
||||
echo "✅ npm audit fix completed successfully"
|
||||
AUDIT_FIX_STATUS="success"
|
||||
else
|
||||
echo "⚠️ npm audit fix failed or had issues"
|
||||
AUDIT_FIX_STATUS="failed"
|
||||
|
||||
# Try audit fix with --force as a last resort for critical/high vulns only
|
||||
echo "Checking if critical/high vulnerabilities remain..."
|
||||
if ! npm audit --audit-level=high; then
|
||||
echo "🚨 Critical/high vulnerabilities remain, attempting --force fix..."
|
||||
if npm audit fix --force; then
|
||||
echo "⚠️ npm audit fix --force completed (may have breaking changes)"
|
||||
AUDIT_FIX_STATUS="force-fixed"
|
||||
else
|
||||
echo "❌ npm audit fix --force also failed"
|
||||
AUDIT_FIX_STATUS="force-failed"
|
||||
fi
|
||||
else
|
||||
echo "✅ Only moderate/low vulnerabilities remain after failed fix"
|
||||
AUDIT_FIX_STATUS="partial-success"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "AUDIT_FIX_STATUS=$AUDIT_FIX_STATUS" >> $GITHUB_ENV
|
||||
|
||||
# Try to fix TypeScript issues automatically
|
||||
echo "Attempting to fix TypeScript compatibility issues..."
|
||||
|
||||
# Check if build fails
|
||||
if ! npm run build 2>/dev/null; then
|
||||
echo "Build failed, attempting automated fixes..."
|
||||
|
||||
# Common fix 1: Update @types/node to latest compatible version
|
||||
echo "Trying to update @types/node to latest version..."
|
||||
npm update @types/node
|
||||
|
||||
# Common fix 2: If that doesn't work, try installing a specific known-good version
|
||||
if ! npm run build 2>/dev/null; then
|
||||
echo "Trying specific @types/node version..."
|
||||
# Try Node 20 compatible version
|
||||
npm install --save-dev @types/node@^20.0.0
|
||||
fi
|
||||
|
||||
# Common fix 3: Clear node_modules and reinstall if still failing
|
||||
if ! npm run build 2>/dev/null; then
|
||||
echo "Clearing node_modules and reinstalling..."
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# Re-run audit fix after clean install if it was successful before
|
||||
if [[ "$AUDIT_FIX_STATUS" == "success" || "$AUDIT_FIX_STATUS" == "force-fixed" ]]; then
|
||||
echo "Re-running npm audit fix after clean install..."
|
||||
npm audit fix || echo "Audit fix failed on second attempt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Common fix 4: Try updating TypeScript itself
|
||||
if ! npm run build 2>/dev/null; then
|
||||
echo "Trying to update TypeScript..."
|
||||
npm update typescript
|
||||
fi
|
||||
|
||||
# Final check
|
||||
if npm run build 2>/dev/null; then
|
||||
echo "✅ Successfully fixed TypeScript issues automatically"
|
||||
else
|
||||
echo "⚠️ Could not automatically fix TypeScript issues"
|
||||
fi
|
||||
else
|
||||
echo "✅ Build passes after audit fix"
|
||||
fi
|
||||
|
||||
- name: Create PR if changes exist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HUSKY: 0 # Disable husky hooks for automated commits
|
||||
run: |
|
||||
# Check if there are any changes
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
# Configure git
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "<41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
|
||||
# Create branch and commit changes
|
||||
branch_name="chore/npm-audit-fix-with-ts-repair"
|
||||
git checkout -b "$branch_name"
|
||||
|
||||
# Commit with --no-verify to skip husky hooks
|
||||
git commit -a -m "chore: npm audit fix with automated TypeScript compatibility fixes" --no-verify
|
||||
git push --force origin "$branch_name"
|
||||
|
||||
# Check final build status and gather info about what was changed
|
||||
build_status="✅ Build passes"
|
||||
fixes_applied=""
|
||||
cd src/Misc/expressionFunc/hashFiles
|
||||
|
||||
# Check what packages were updated
|
||||
if git diff HEAD~1 package.json | grep -q "@types/node"; then
|
||||
fixes_applied+="\n- Updated @types/node version for TypeScript compatibility"
|
||||
fi
|
||||
if git diff HEAD~1 package.json | grep -q "typescript"; then
|
||||
fixes_applied+="\n- Updated TypeScript version"
|
||||
fi
|
||||
if git diff HEAD~1 package-lock.json | grep -q "resolved"; then
|
||||
fixes_applied+="\n- Updated package dependencies via npm audit fix"
|
||||
fi
|
||||
|
||||
if ! npm run build 2>/dev/null; then
|
||||
build_status="⚠️ Build fails - manual review required"
|
||||
fi
|
||||
cd - > /dev/null
|
||||
|
||||
# Create enhanced PR body using here-doc for proper formatting
|
||||
audit_status_msg=""
|
||||
case "$AUDIT_FIX_STATUS" in
|
||||
"success")
|
||||
audit_status_msg="✅ **Audit Fix**: Completed successfully"
|
||||
;;
|
||||
"partial-success")
|
||||
audit_status_msg="⚠️ **Audit Fix**: Partial success (only moderate/low vulnerabilities remain)"
|
||||
;;
|
||||
"force-fixed")
|
||||
audit_status_msg="⚠️ **Audit Fix**: Completed with --force (may have breaking changes)"
|
||||
;;
|
||||
"failed"|"force-failed")
|
||||
audit_status_msg="❌ **Audit Fix**: Failed to resolve vulnerabilities"
|
||||
;;
|
||||
*)
|
||||
audit_status_msg="❓ **Audit Fix**: Status unknown"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$build_status" == *"fails"* ]]; then
|
||||
cat > pr_body.txt << EOF
|
||||
Automated npm audit fix with TypeScript auto-repair for hashFiles dependencies.
|
||||
|
||||
**Build Status**: ⚠️ Build fails - manual review required
|
||||
$audit_status_msg
|
||||
|
||||
This workflow attempts to automatically fix TypeScript compatibility issues that may arise from npm audit fixes.
|
||||
|
||||
⚠️ **Manual Review Required**: The build is currently failing after automated fixes were attempted.
|
||||
|
||||
Common issues and solutions:
|
||||
- Check for TypeScript version compatibility with Node.js types
|
||||
- Review breaking changes in updated dependencies
|
||||
- Consider pinning problematic dependency versions temporarily
|
||||
- Review tsconfig.json for compatibility settings
|
||||
|
||||
**Automated Fix Strategy**:
|
||||
1. Run npm audit fix with proper error handling
|
||||
2. Update @types/node to latest compatible version
|
||||
3. Try Node 20 specific @types/node version if needed
|
||||
4. Clean reinstall dependencies if conflicts persist
|
||||
5. Update TypeScript compiler if necessary
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [NPM Audit Fix with TypeScript Auto-Fix Workflow](https://github.com/actions/runner/blob/main/.github/workflows/npm-audit-ts-fix.yml)
|
||||
EOF
|
||||
else
|
||||
cat > pr_body.txt << EOF
|
||||
Automated npm audit fix with TypeScript auto-repair for hashFiles dependencies.
|
||||
|
||||
**Build Status**: ✅ Build passes
|
||||
$audit_status_msg
|
||||
|
||||
This workflow attempts to automatically fix TypeScript compatibility issues that may arise from npm audit fixes.
|
||||
|
||||
✅ **Ready to Merge**: All automated fixes were successful and the build passes.
|
||||
|
||||
**Automated Fix Strategy**:
|
||||
1. Run npm audit fix with proper error handling
|
||||
2. Update @types/node to latest compatible version
|
||||
3. Try Node 20 specific @types/node version if needed
|
||||
4. Clean reinstall dependencies if conflicts persist
|
||||
5. Update TypeScript compiler if necessary
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [NPM Audit Fix with TypeScript Auto-Fix Workflow](https://github.com/actions/runner/blob/main/.github/workflows/npm-audit-ts-fix.yml)
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ -n "$fixes_applied" ]; then
|
||||
# Add the fixes applied section to the file
|
||||
sed -i "/This workflow attempts/a\\
|
||||
\\
|
||||
**Automated Fixes Applied**:$fixes_applied" pr_body.txt
|
||||
fi
|
||||
|
||||
# Create PR with appropriate labels
|
||||
labels="dependencies,dependencies-not-dependabot,typescript,npm,security"
|
||||
if [[ "$build_status" == *"fails"* ]]; then
|
||||
labels="dependencies,dependencies-not-dependabot,typescript,npm,security,needs-manual-review"
|
||||
fi
|
||||
|
||||
# Create PR
|
||||
gh pr create -B main -H "$branch_name" \
|
||||
--title "chore: npm audit fix with TypeScript auto-repair" \
|
||||
--label "$labels" \
|
||||
--body-file pr_body.txt
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
137
.github/workflows/npm-audit.yml
vendored
Normal file
137
.github/workflows/npm-audit.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
name: NPM Audit Fix
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 7 * * 1" # Weekly on Monday at 7 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
npm-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: NPM install and audit fix
|
||||
working-directory: src/Misc/expressionFunc/hashFiles
|
||||
run: |
|
||||
npm install
|
||||
|
||||
# Check what vulnerabilities exist
|
||||
echo "=== Checking current vulnerabilities ==="
|
||||
npm audit || true
|
||||
|
||||
# Apply audit fix --force to get security updates
|
||||
echo "=== Applying npm audit fix --force ==="
|
||||
npm audit fix --force
|
||||
|
||||
# Test if build still works and set status
|
||||
echo "=== Testing build compatibility ==="
|
||||
if npm run all; then
|
||||
echo "✅ Build successful after audit fix"
|
||||
echo "AUDIT_FIX_STATUS=success" >> $GITHUB_ENV
|
||||
else
|
||||
echo "❌ Build failed after audit fix - will create PR with fix instructions"
|
||||
echo "AUDIT_FIX_STATUS=build_failed" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Create PR if changes exist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Check if there are any changes
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
# Configure git
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Create branch and commit changes
|
||||
branch_name="chore/npm-audit-fix-$(date +%Y%m%d)"
|
||||
git checkout -b "$branch_name"
|
||||
git add .
|
||||
git commit -m "chore: npm audit fix for hashFiles dependencies" --no-verify
|
||||
git push origin "$branch_name"
|
||||
|
||||
# Create PR body based on what actually happened
|
||||
if [ "$AUDIT_FIX_STATUS" = "success" ]; then
|
||||
cat > pr_body.txt << 'EOF'
|
||||
Automated npm audit fix for security vulnerabilities in hashFiles dependencies.
|
||||
|
||||
**✅ Full Fix Applied Successfully**
|
||||
This update addresses npm security advisories and ensures dependencies are secure and up-to-date.
|
||||
|
||||
**Changes made:**
|
||||
- Applied `npm audit fix --force` to resolve security vulnerabilities
|
||||
- Updated package-lock.json with security patches
|
||||
- Verified build compatibility with `npm run all`
|
||||
|
||||
**Next steps:**
|
||||
- Review the dependency changes
|
||||
- Verify the hashFiles functionality still works as expected
|
||||
- Merge when ready
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [NPM Audit Fix Workflow](https://github.com/actions/runner/blob/main/.github/workflows/npm-audit.yml)
|
||||
EOF
|
||||
elif [ "$AUDIT_FIX_STATUS" = "build_failed" ]; then
|
||||
cat > pr_body.txt << 'EOF'
|
||||
Automated npm audit fix for security vulnerabilities in hashFiles dependencies.
|
||||
|
||||
**⚠️ Security Fixes Applied - Build Issues Need Manual Resolution**
|
||||
This update applies important security patches but causes build failures that require manual fixes.
|
||||
|
||||
**Changes made:**
|
||||
- Applied `npm audit fix --force` to resolve security vulnerabilities
|
||||
- Updated package-lock.json with security patches
|
||||
|
||||
**⚠️ Build Issues Detected:**
|
||||
The build fails after applying security fixes, likely due to TypeScript compatibility issues with updated `@types/node`.
|
||||
|
||||
**Required Manual Fixes:**
|
||||
1. Review TypeScript compilation errors in the build output
|
||||
2. Update TypeScript configuration if needed
|
||||
3. Consider pinning `@types/node` to a compatible version
|
||||
4. Run `npm run all` locally to verify fixes
|
||||
|
||||
**Next steps:**
|
||||
- **DO NOT merge until build issues are resolved**
|
||||
- Apply manual fixes for TypeScript compatibility
|
||||
- Test the hashFiles functionality still works as expected
|
||||
- Merge when build passes
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [NPM Audit Fix Workflow](https://github.com/actions/runner/blob/main/.github/workflows/npm-audit.yml)
|
||||
EOF
|
||||
else
|
||||
# Fallback case
|
||||
cat > pr_body.txt << 'EOF'
|
||||
Automated npm audit attempted for security vulnerabilities in hashFiles dependencies.
|
||||
|
||||
**ℹ️ No Changes Applied**
|
||||
No security vulnerabilities were found or no changes were needed.
|
||||
|
||||
---
|
||||
|
||||
Autogenerated by [NPM Audit Fix Workflow](https://github.com/actions/runner/blob/main/.github/workflows/npm-audit.yml)
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Create PR
|
||||
gh pr create -B main -H "$branch_name" \
|
||||
--title "chore: npm audit fix for hashFiles dependencies" \
|
||||
--label "dependencies" \
|
||||
--label "dependencies-weekly-check" \
|
||||
--label "dependencies-not-dependabot" \
|
||||
--label "npm" \
|
||||
--label "typescript" \
|
||||
--label "security" \
|
||||
--body-file pr_body.txt
|
||||
else
|
||||
echo "✅ No changes to commit - npm audit fix did not modify any files"
|
||||
fi
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
# Make sure ./releaseVersion match ./src/runnerversion
|
||||
# Query GitHub release ensure version is not used
|
||||
- name: Check version
|
||||
uses: actions/github-script@v7.0.1
|
||||
uses: actions/github-script@v8.0.0
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
# Create ReleaseNote file
|
||||
- name: Create ReleaseNote
|
||||
id: releaseNote
|
||||
uses: actions/github-script@v7.0.1
|
||||
uses: actions/github-script@v8.0.0
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
@@ -300,7 +300,7 @@ jobs:
|
||||
|
||||
- name: Compute image version
|
||||
id: image
|
||||
uses: actions/github-script@v7.0.1
|
||||
uses: actions/github-script@v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
- name: Generate attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.build-and-push.outputs.digest }}
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-message: "This issue is stale because it has been open 365 days with no activity. Remove stale label or comment or this will be closed in 15 days."
|
||||
close-issue-message: "This issue was closed because it has been stalled for 15 days with no activity."
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd src/Misc/expressionFunc/hashFiles
|
||||
|
||||
npx lint-staged
|
||||
cd src/Misc/expressionFunc/hashFiles && npx lint-staged
|
||||
|
||||
10
README.md
10
README.md
@@ -8,16 +8,6 @@
|
||||
|
||||
The runner is the application that runs a job from a GitHub Actions workflow. It is used by GitHub Actions in the [hosted virtual environments](https://github.com/actions/virtual-environments), or you can [self-host the runner](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/about-self-hosted-runners) in your own environment.
|
||||
|
||||
## Understanding How Actions Work
|
||||
|
||||
**New to GitHub Actions development?** The runner (this repository) is compiled C# code that executes actions. Actions themselves typically do NOT require compilation:
|
||||
|
||||
- **JavaScript Actions** run source `.js` files directly
|
||||
- **Container Actions** use Docker images (pre-built or built from Dockerfile)
|
||||
- **Composite Actions** are YAML step definitions
|
||||
|
||||
📖 See [docs/action-execution-model.md](docs/action-execution-model.md) for detailed information and [examples](docs/examples/action-execution-examples.md).
|
||||
|
||||
## Get Started
|
||||
|
||||
For more information about installing and using self-hosted runners, see [Adding self-hosted runners](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/adding-self-hosted-runners) and [Using self-hosted runners in a workflow](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/using-self-hosted-runners-in-a-workflow)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# GitHub Actions Execution Model
|
||||
|
||||
## Question: Do Actions Need to be Compiled?
|
||||
|
||||
**Short Answer**: No, GitHub Actions themselves do **NOT** need to be compiled from source code. They run directly as interpreted code, container images, or step definitions.
|
||||
|
||||
## How Different Action Types Are Executed
|
||||
|
||||
### 1. JavaScript Actions (`using: node12/16/20/24`)
|
||||
|
||||
JavaScript actions execute source code directly without compilation:
|
||||
|
||||
```yaml
|
||||
# action.yml
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
```
|
||||
|
||||
**Execution Process**:
|
||||
1. Runner downloads the action repository
|
||||
2. Locates the `main` JavaScript file (e.g., `index.js`)
|
||||
3. Executes it directly using Node.js runtime: `node index.js`
|
||||
4. No compilation or build step required
|
||||
|
||||
**Code Reference**: `src/Runner.Worker/Handlers/NodeScriptActionHandler.cs`
|
||||
- Resolves the target script file
|
||||
- Executes using Node.js: `StepHost.ExecuteAsync()` with node executable
|
||||
|
||||
### 2. Container Actions (`using: docker`)
|
||||
|
||||
Container actions run pre-built images or build from Dockerfile:
|
||||
|
||||
```yaml
|
||||
# action.yml - Pre-built image
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'docker://alpine:3.10'
|
||||
```
|
||||
|
||||
```yaml
|
||||
# action.yml - Build from Dockerfile
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
```
|
||||
|
||||
**Execution Process**:
|
||||
1. If using pre-built image: Pull and run the container
|
||||
2. If using Dockerfile: Build the container image, then run it
|
||||
3. No compilation of action source code - Docker handles image building
|
||||
|
||||
**Code Reference**: `src/Runner.Worker/Handlers/ContainerActionHandler.cs`
|
||||
- Handles both pre-built images and Dockerfile builds
|
||||
- Uses Docker commands to run containers
|
||||
|
||||
### 3. Composite Actions (`using: composite`)
|
||||
|
||||
Composite actions are collections of steps defined in YAML:
|
||||
|
||||
```yaml
|
||||
# action.yml
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- run: echo "Hello"
|
||||
shell: bash
|
||||
- uses: actions/checkout@v3
|
||||
```
|
||||
|
||||
**Execution Process**:
|
||||
1. Parse the YAML step definitions
|
||||
2. Execute each step in sequence
|
||||
3. No compilation - just step orchestration
|
||||
|
||||
**Code Reference**: `src/Runner.Worker/Handlers/CompositeActionHandler.cs`
|
||||
- Iterates through defined steps
|
||||
- Executes each step using appropriate handlers
|
||||
|
||||
## What Does Get Compiled?
|
||||
|
||||
### The GitHub Actions Runner (This Repository)
|
||||
|
||||
The runner itself is compiled from C# source code:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./dev.sh build # Compiles the runner binaries
|
||||
```
|
||||
|
||||
**What gets compiled**:
|
||||
- `Runner.Listener` - Registers with GitHub and receives jobs
|
||||
- `Runner.Worker` - Executes individual jobs and steps
|
||||
- `Runner.PluginHost` - Handles plugin execution
|
||||
- Supporting libraries
|
||||
|
||||
**Build Output**: Compiled binaries in `_layout/bin/`
|
||||
|
||||
## Key Distinctions
|
||||
|
||||
| Component | Compilation Required | Execution Method |
|
||||
|-----------|---------------------|------------------|
|
||||
| **Runner** (this repo) | ✅ Yes - C# → binaries | Compiled executable |
|
||||
| **JavaScript Actions** | ❌ No | Direct interpretation |
|
||||
| **Container Actions** | ❌ No* | Container runtime |
|
||||
| **Composite Actions** | ❌ No | YAML interpretation |
|
||||
|
||||
*Container actions may involve building Docker images, but not compiling action source code.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Action Loading Process
|
||||
|
||||
1. **Action Discovery** (`ActionManager.LoadAction()`)
|
||||
- Parses `action.yml` manifest
|
||||
- Determines action type from `using` field
|
||||
- Creates appropriate execution data object
|
||||
|
||||
2. **Handler Selection** (`HandlerFactory.Create()`)
|
||||
- Routes to appropriate handler based on action type
|
||||
- `NodeScriptActionHandler` for JavaScript
|
||||
- `ContainerActionHandler` for Docker
|
||||
- `CompositeActionHandler` for composite
|
||||
|
||||
3. **Execution** (Handler-specific `RunAsync()`)
|
||||
- Each handler implements execution logic
|
||||
- No compilation step - direct execution
|
||||
|
||||
### Source Code References
|
||||
|
||||
- **Action Type Detection**: `src/Runner.Worker/ActionManifestManager.cs:428-495`
|
||||
- **Handler Factory**: `src/Runner.Worker/Handlers/HandlerFactory.cs`
|
||||
- **JavaScript Execution**: `src/Runner.Worker/Handlers/NodeScriptActionHandler.cs:143-153`
|
||||
- **Container Execution**: `src/Runner.Worker/Handlers/ContainerActionHandler.cs:247-261`
|
||||
|
||||
## Conclusion
|
||||
|
||||
GitHub Actions are designed for **runtime interpretation**, not compilation:
|
||||
|
||||
- **JavaScript actions** run source `.js` files directly
|
||||
- **Container actions** use existing images or build from Dockerfile
|
||||
- **Composite actions** are YAML step definitions
|
||||
|
||||
The only compilation involved is building the **runner infrastructure** (this repository) that interprets and executes the actions.
|
||||
@@ -4,14 +4,6 @@ We welcome contributions in the form of issues and pull requests. We view the co
|
||||
|
||||
> IMPORTANT: Building your own runner is critical for the dev inner loop process when contributing changes. However, only runners built and distributed by GitHub (releases) are supported in production. Be aware that workflows and orchestrations run service side with the runner being a remote process to run steps. For that reason, the service can pull the runner forward so customizations can be lost.
|
||||
|
||||
## Understanding Actions vs Runner
|
||||
|
||||
**New to GitHub Actions development?** See [Action Execution Model](action-execution-model.md) to understand the difference between:
|
||||
- **Actions** (JavaScript, containers, composite) - Run without compilation
|
||||
- **Runner** (this repository) - Compiled C# application that executes actions
|
||||
|
||||
For examples of how different action types work, see [Action Execution Examples](examples/action-execution-examples.md).
|
||||
|
||||
## Issues
|
||||
|
||||
Log issues for both bugs and enhancement requests. Logging issues are important for the open community.
|
||||
|
||||
217
docs/dependency-management.md
Normal file
217
docs/dependency-management.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Runner Dependency Management Process
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the automated dependency management process for the GitHub Actions Runner, designed to ensure we maintain up-to-date and secure dependencies while providing predictable release cycles.
|
||||
|
||||
## Release Schedule
|
||||
|
||||
- **Monthly Runner Releases**: New runner versions are released monthly
|
||||
- **Weekly Dependency Checks**: Automated workflows check for dependency updates every Monday
|
||||
- **Security Patches**: Critical security vulnerabilities are addressed immediately outside the regular schedule
|
||||
|
||||
## Automated Workflows
|
||||
|
||||
**Note**: These workflows are implemented across separate PRs for easier review and independent deployment. Each workflow includes comprehensive error handling and security-focused vulnerability detection.
|
||||
|
||||
### 1. Foundation Labels
|
||||
|
||||
- **Workflow**: `.github/workflows/setup-labels.yml` (PR #4024)
|
||||
- **Purpose**: Creates consistent dependency labels for all automation workflows
|
||||
- **Labels**: `dependencies`, `security`, `typescript`, `needs-manual-review`
|
||||
- **Prerequisite**: Must be merged before other workflows for proper labeling
|
||||
|
||||
### 2. Node.js Version Updates
|
||||
|
||||
- **Workflow**: `.github/workflows/node-upgrade.yml`
|
||||
- **Schedule**: Mondays at 6:00 AM UTC
|
||||
- **Purpose**: Updates Node.js 20 and 24 versions in `src/Misc/externals.sh`
|
||||
- **Source**: [nodejs.org](https://nodejs.org) and [actions/alpine_nodejs](https://github.com/actions/alpine_nodejs)
|
||||
- **Priority**: First (NPM depends on current Node.js versions)
|
||||
|
||||
### 3. NPM Security Audit
|
||||
|
||||
- **Primary Workflow**: `.github/workflows/npm-audit.yml` ("NPM Audit Fix")
|
||||
- **Schedule**: Mondays at 7:00 AM UTC
|
||||
- **Purpose**: Automated security vulnerability detection and basic fixes
|
||||
- **Location**: `src/Misc/expressionFunc/hashFiles/`
|
||||
- **Features**: npm audit, security patch application, PR creation
|
||||
- **Dependency**: Runs after Node.js updates for optimal compatibility
|
||||
|
||||
- **Fallback Workflow**: `.github/workflows/npm-audit-typescript.yml` ("NPM Audit Fix with TypeScript Auto-Fix")
|
||||
- **Trigger**: Manual dispatch only
|
||||
- **Purpose**: Manual security audit with TypeScript compatibility fixes
|
||||
- **Use Case**: When scheduled workflow fails or needs custom intervention
|
||||
- **Features**: Enhanced TypeScript auto-repair, graduated security response
|
||||
- **How to Use**:
|
||||
1. If the scheduled "NPM Audit Fix" workflow fails, go to Actions tab
|
||||
2. Select "NPM Audit Fix with TypeScript Auto-Fix" workflow
|
||||
3. Click "Run workflow" and optionally specify fix level (auto/manual)
|
||||
4. Review the generated PR for TypeScript compatibility issues
|
||||
|
||||
### 4. .NET SDK Updates
|
||||
|
||||
- **Workflow**: `.github/workflows/dotnet-upgrade.yml`
|
||||
- **Schedule**: Mondays at midnight UTC
|
||||
- **Purpose**: Updates .NET SDK and package versions with build validation
|
||||
- **Features**: Global.json updates, NuGet package management, compatibility checking
|
||||
- **Independence**: Runs independently of Node.js/NPM updates
|
||||
|
||||
### 5. Docker/Buildx Updates
|
||||
|
||||
- **Workflow**: `.github/workflows/docker-buildx-upgrade.yml` ("Docker/Buildx Version Upgrade")
|
||||
- **Schedule**: Mondays at midnight UTC
|
||||
- **Purpose**: Updates Docker and Docker Buildx versions with multi-platform validation
|
||||
- **Features**: Container security scanning, multi-architecture build testing
|
||||
- **Independence**: Runs independently of other dependency updates
|
||||
|
||||
### 6. Dependency Monitoring
|
||||
|
||||
- **Workflow**: `.github/workflows/dependency-check.yml` ("Dependency Status Check")
|
||||
- **Schedule**: Mondays at 11:00 AM UTC
|
||||
- **Purpose**: Comprehensive status report of all dependencies with security audit
|
||||
- **Features**: Multi-dependency checking, npm audit status, build validation, choice of specific component checks
|
||||
- **Summary**: Runs last to capture results from all morning dependency updates
|
||||
|
||||
## Release Process Integration
|
||||
|
||||
### Pre-Release Checklist
|
||||
|
||||
Before each monthly runner release:
|
||||
|
||||
1. **Check Dependency PRs**:
|
||||
|
||||
```bash
|
||||
# List all open dependency PRs
|
||||
gh pr list --label "dependencies" --state open
|
||||
|
||||
# List only automated weekly dependency updates
|
||||
gh pr list --label "dependencies-weekly-check" --state open
|
||||
|
||||
# List only custom dependency automation (not dependabot)
|
||||
gh pr list --label "dependencies-not-dependabot" --state open
|
||||
```
|
||||
|
||||
2. **Run Manual Dependency Check**:
|
||||
- Go to Actions tab → "Dependency Status Check" → "Run workflow"
|
||||
- Review the summary for any outdated dependencies
|
||||
|
||||
3. **Review and Merge Updates**:
|
||||
- Prioritize security-related updates
|
||||
- Test dependency updates in development environment
|
||||
- Merge approved dependency PRs
|
||||
|
||||
### Vulnerability Response
|
||||
|
||||
#### Critical Security Vulnerabilities
|
||||
|
||||
- **Response Time**: Within 24 hours
|
||||
- **Process**:
|
||||
1. Assess impact on runner security
|
||||
2. Create hotfix branch if runner data security is affected
|
||||
3. Expedite patch release if necessary
|
||||
4. Document in security advisory if applicable
|
||||
|
||||
#### Non-Critical Vulnerabilities
|
||||
|
||||
- **Response Time**: Next monthly release
|
||||
- **Process**:
|
||||
1. Evaluate if vulnerability affects runner functionality
|
||||
2. Include fix in regular dependency update cycle
|
||||
3. Document in release notes
|
||||
|
||||
## Monitoring and Alerts
|
||||
|
||||
### GitHub Actions Workflow Status
|
||||
|
||||
- All dependency workflows create PRs with the `dependencies` label
|
||||
- Failed workflows should be investigated immediately
|
||||
- Weekly dependency status reports are generated automatically
|
||||
|
||||
### Manual Checks
|
||||
|
||||
You can manually trigger dependency checks:
|
||||
|
||||
- **Full Status**: Run "Dependency Status Check" workflow
|
||||
- **Specific Component**: Use the dropdown to check individual dependencies
|
||||
|
||||
## Dependency Labels
|
||||
|
||||
All automated dependency PRs are tagged with labels for easy filtering and management:
|
||||
|
||||
### Primary Labels
|
||||
|
||||
- **`dependencies`**: All automated dependency-related PRs
|
||||
- **`dependencies-weekly-check`**: Automated weekly dependency updates from scheduled workflows
|
||||
- **`dependencies-not-dependabot`**: Custom dependency automation (not created by dependabot)
|
||||
- **`security`**: Security vulnerability fixes and patches
|
||||
- **`typescript`**: TypeScript compatibility and type definition updates
|
||||
- **`needs-manual-review`**: Complex updates requiring human verification
|
||||
|
||||
### Technology-Specific Labels
|
||||
|
||||
- **`node`**: Node.js version updates
|
||||
- **`javascript`**: JavaScript runtime and tooling updates
|
||||
- **`npm`**: NPM package and security updates
|
||||
- **`dotnet`**: .NET SDK and NuGet package updates
|
||||
- **`docker`**: Docker and container tooling updates
|
||||
|
||||
### Workflow-Specific Branches
|
||||
|
||||
- **Node.js updates**: `chore/update-node` branch
|
||||
- **NPM security fixes**: `chore/npm-audit-fix-YYYYMMDD` and `chore/npm-audit-fix-with-ts-repair` branches
|
||||
- **NuGet/.NET updates**: `feature/dotnetsdk-upgrade/{version}` branches
|
||||
- **Docker updates**: `feature/docker-buildx-upgrade` branch
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### Node.js Updates
|
||||
|
||||
When updating Node.js versions, remember to:
|
||||
|
||||
1. Create a corresponding release in [actions/alpine_nodejs](https://github.com/actions/alpine_nodejs)
|
||||
2. Follow the alpine_nodejs getting started guide
|
||||
3. Test container builds with new Node versions
|
||||
|
||||
### .NET SDK Updates
|
||||
|
||||
- Only patch versions are auto-updated within the same major.minor version
|
||||
- Major/minor version updates require manual review and testing
|
||||
|
||||
### Docker Updates
|
||||
|
||||
- Updates include both Docker Engine and Docker Buildx
|
||||
- Verify compatibility with runner container workflows
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **NPM Audit Workflow Fails**:
|
||||
- Check if `package.json` exists in `src/Misc/expressionFunc/hashFiles/`
|
||||
- Verify Node.js setup step succeeded
|
||||
|
||||
2. **Version Detection Fails**:
|
||||
- Check if upstream APIs are available
|
||||
- Verify parsing logic for version extraction
|
||||
|
||||
3. **PR Creation Fails**:
|
||||
- Ensure `GITHUB_TOKEN` has sufficient permissions
|
||||
- Check if branch already exists
|
||||
|
||||
### Contact
|
||||
|
||||
For questions about the dependency management process:
|
||||
|
||||
- Create an issue with the `dependencies` label
|
||||
- Review existing dependency management workflows
|
||||
- Consult the runner team for security-related concerns
|
||||
|
||||
## Metrics and KPIs
|
||||
|
||||
Track these metrics to measure dependency management effectiveness:
|
||||
|
||||
- Number of open dependency PRs at release time
|
||||
- Time to merge dependency updates
|
||||
- Number of security vulnerabilities by severity
|
||||
- Release cycle adherence (monthly target)
|
||||
@@ -1,117 +0,0 @@
|
||||
# Action Execution Examples
|
||||
|
||||
This directory contains examples demonstrating how different types of GitHub Actions are executed without compilation.
|
||||
|
||||
## JavaScript Action Example
|
||||
|
||||
A simple JavaScript action that runs source code directly:
|
||||
|
||||
### action.yml
|
||||
```yaml
|
||||
name: 'JavaScript Example'
|
||||
description: 'Demonstrates direct JavaScript execution'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
```
|
||||
|
||||
### index.js
|
||||
```javascript
|
||||
// This file runs directly - no compilation needed
|
||||
console.log('Hello from JavaScript action!');
|
||||
console.log('Process args:', process.argv);
|
||||
console.log('Environment:', process.env.INPUT_MESSAGE || 'No input provided');
|
||||
```
|
||||
|
||||
**Execution**: The runner directly executes `node index.js` - no build step.
|
||||
|
||||
## Container Action Example
|
||||
|
||||
### action.yml (Pre-built image)
|
||||
```yaml
|
||||
name: 'Container Example'
|
||||
description: 'Demonstrates container execution'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'docker://alpine:latest'
|
||||
entrypoint: '/bin/sh'
|
||||
args:
|
||||
- '-c'
|
||||
- 'echo "Hello from container!" && env | grep INPUT_'
|
||||
```
|
||||
|
||||
### action.yml (Build from source)
|
||||
```yaml
|
||||
name: 'Container Build Example'
|
||||
description: 'Demonstrates building from Dockerfile'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
args:
|
||||
- 'Hello from built container!'
|
||||
```
|
||||
|
||||
### Dockerfile
|
||||
```dockerfile
|
||||
FROM alpine:latest
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
### entrypoint.sh
|
||||
```bash
|
||||
#!/bin/sh
|
||||
echo "Container built and running: $1"
|
||||
echo "Environment variables:"
|
||||
env | grep INPUT_ || echo "No INPUT_ variables found"
|
||||
```
|
||||
|
||||
**Execution**: Docker builds the image (if needed) and runs the container - action source isn't compiled.
|
||||
|
||||
## Composite Action Example
|
||||
|
||||
### action.yml
|
||||
```yaml
|
||||
name: 'Composite Example'
|
||||
description: 'Demonstrates composite action execution'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run shell command
|
||||
run: echo "Step 1: Hello from composite action!"
|
||||
shell: bash
|
||||
|
||||
- name: Use another action
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: 'checked-out-code'
|
||||
|
||||
- name: Run another shell command
|
||||
run: |
|
||||
echo "Step 3: Files in workspace:"
|
||||
ls -la
|
||||
shell: bash
|
||||
```
|
||||
|
||||
**Execution**: The runner interprets the YAML and executes each step - no compilation.
|
||||
|
||||
## Comparison with Runner Compilation
|
||||
|
||||
The **runner itself** (this repository) must be compiled:
|
||||
|
||||
```bash
|
||||
# This compiles the runner from C# source code
|
||||
cd src
|
||||
./dev.sh build
|
||||
|
||||
# The compiled runner then executes actions WITHOUT compiling them
|
||||
./_layout/bin/Runner.Worker
|
||||
```
|
||||
|
||||
## Key Takeaway
|
||||
|
||||
- **Actions** = Interpreted at runtime (JavaScript, containers, YAML)
|
||||
- **Runner** = Compiled from source (C# → binaries)
|
||||
|
||||
The runner compiles once and then executes many different actions without compiling them.
|
||||
@@ -5,8 +5,8 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG RUNNER_VERSION
|
||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||
ARG DOCKER_VERSION=28.3.2
|
||||
ARG BUILDX_VERSION=0.26.1
|
||||
ARG DOCKER_VERSION=28.5.1
|
||||
ARG BUILDX_VERSION=0.29.1
|
||||
|
||||
RUN apt update -y && apt install curl unzip -y
|
||||
|
||||
@@ -21,6 +21,10 @@ RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-c
|
||||
&& unzip ./runner-container-hooks.zip -d ./k8s \
|
||||
&& rm runner-container-hooks.zip
|
||||
|
||||
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.0/actions-runner-hooks-k8s-0.8.0.zip \
|
||||
&& unzip ./runner-container-hooks.zip -d ./k8s-novolume \
|
||||
&& rm runner-container-hooks.zip
|
||||
|
||||
RUN export RUNNER_ARCH=${TARGETARCH} \
|
||||
&& if [ "$RUNNER_ARCH" = "amd64" ]; then export DOCKER_ARCH=x86_64 ; fi \
|
||||
&& if [ "$RUNNER_ARCH" = "arm64" ]; then export DOCKER_ARCH=aarch64 ; fi \
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
## What's Changed
|
||||
* Update Docker to v28.3.2 and Buildx to v0.26.1 by @github-actions[bot] in https://github.com/actions/runner/pull/3953
|
||||
* Fix if statement structure in update script and variable reference by @salmanmkc in https://github.com/actions/runner/pull/3956
|
||||
* Add V2 flow for runner deletion by @Samirat in https://github.com/actions/runner/pull/3954
|
||||
* Node 20 -> Node 24 migration feature flagging, opt-in and opt-out environment variables by @salmanmkc in https://github.com/actions/runner/pull/3948
|
||||
* Update Node20 and Node24 to latest by @djs-intel in https://github.com/actions/runner/pull/3972
|
||||
* Redirect supported OS doc section to current public Docs location by @corycalahan in https://github.com/actions/runner/pull/3979
|
||||
* Bump Microsoft.NET.Test.Sdk from 17.13.0 to 17.14.1 by @dependabot[bot] in https://github.com/actions/runner/pull/3975
|
||||
* Bump Azure.Storage.Blobs from 12.24.0 to 12.25.0 by @dependabot[bot] in https://github.com/actions/runner/pull/3974
|
||||
* Bump actions/download-artifact from 4 to 5 by @dependabot[bot] in https://github.com/actions/runner/pull/3973
|
||||
* Bump actions/checkout from 4 to 5 by @dependabot[bot] in https://github.com/actions/runner/pull/3982
|
||||
* Update safe_sleep.sh for bug when scheduler is paused for more than 1 second by @horner in https://github.com/actions/runner/pull/3157
|
||||
* Acknowledge runner request by @ericsciple in https://github.com/actions/runner/pull/3996
|
||||
* Update Docker to v28.3.3 and Buildx to v0.27.0 by @github-actions[bot] in https://github.com/actions/runner/pull/3999
|
||||
* Update dotnet sdk to latest version @8.0.413 by @github-actions[bot] in https://github.com/actions/runner/pull/4000
|
||||
* Bump actions/attest-build-provenance from 2 to 3 by @dependabot[bot] in https://github.com/actions/runner/pull/4002
|
||||
* Bump @typescript-eslint/eslint-plugin from 6.7.2 to 8.35.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/3920
|
||||
* Bump husky from 8.0.3 to 9.1.7 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/3842
|
||||
* Bump @vercel/ncc from 0.38.0 to 0.38.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/3841
|
||||
* Bump eslint-plugin-github from 4.10.0 to 4.10.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/3180
|
||||
* Bump typescript from 5.2.2 to 5.9.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4007
|
||||
* chore: migrate Husky config from v8 to v9 format by @salmanmkc in https://github.com/actions/runner/pull/4003
|
||||
* Map RUNNER_TEMP for container action by @ericsciple in https://github.com/actions/runner/pull/4011
|
||||
* Break UseV2Flow into UseV2Flow and UseRunnerAdminFlow. by @TingluoHuang in https://github.com/actions/runner/pull/4013
|
||||
* Update Docker to v28.4.0 and Buildx to v0.28.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4020
|
||||
* Bump node.js to latest version in runner. by @TingluoHuang in https://github.com/actions/runner/pull/4022
|
||||
* feat: add automated .NET dependency management workflow by @salmanmkc in https://github.com/actions/runner/pull/4028
|
||||
* feat: add automated Docker BuildX dependency management workflow by @salmanmkc in https://github.com/actions/runner/pull/4029
|
||||
* feat: add automated Node.js version management workflow by @salmanmkc in https://github.com/actions/runner/pull/4026
|
||||
* feat: add comprehensive NPM security management workflow by @salmanmkc in https://github.com/actions/runner/pull/4027
|
||||
* feat: add comprehensive dependency monitoring system by @salmanmkc in https://github.com/actions/runner/pull/4025
|
||||
* Use BrokerURL when using RunnerAdmin by @luketomlinson in https://github.com/actions/runner/pull/4044
|
||||
* Bump actions/github-script from 7.0.1 to 8.0.0 by @dependabot[bot] in https://github.com/actions/runner/pull/4016
|
||||
* Bump actions/stale from 9 to 10 by @dependabot[bot] in https://github.com/actions/runner/pull/4015
|
||||
* fix: prevent Node.js upgrade workflow from creating PRs with empty versions by @salmanmkc in https://github.com/actions/runner/pull/4055
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4057
|
||||
* Bump actions/setup-node from 4 to 5 by @dependabot[bot] in https://github.com/actions/runner/pull/4037
|
||||
* Bump Azure.Storage.Blobs from 12.25.0 to 12.25.1 by @dependabot[bot] in https://github.com/actions/runner/pull/4058
|
||||
* Update Docker to v28.5.0 and Buildx to v0.29.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4069
|
||||
* Bump github/codeql-action from 3 to 4 by @dependabot[bot] in https://github.com/actions/runner/pull/4072
|
||||
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4075
|
||||
* Include k8s novolume (version v0.8.0) by @nikola-jokic in https://github.com/actions/runner/pull/4063
|
||||
* Make sure runner-admin has both auth_url and auth_url_v2. by @TingluoHuang in https://github.com/actions/runner/pull/4066
|
||||
* Report job has infra failure to run-service by @TingluoHuang in https://github.com/actions/runner/pull/4073
|
||||
* Bump actions/setup-node from 5 to 6 by @dependabot[bot] in https://github.com/actions/runner/pull/4078
|
||||
|
||||
## New Contributors
|
||||
* @Samirat made their first contribution in https://github.com/actions/runner/pull/3954
|
||||
* @djs-intel made their first contribution in https://github.com/actions/runner/pull/3972
|
||||
* @horner made their first contribution in https://github.com/actions/runner/pull/3157
|
||||
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.327.1...v2.328.0
|
||||
**Full Changelog**: https://github.com/actions/runner/compare/v2.328.0...v2.329.0
|
||||
|
||||
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
|
||||
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.
|
||||
|
||||
1085
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
1085
src/Misc/expressionFunc/hashFiles/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"pack": "ncc build -o ../../layoutbin/hashFiles",
|
||||
"all": "npm run format && npm run lint && npm run build && npm run pack",
|
||||
"prepare": "cd ../../../../ && husky install"
|
||||
"prepare": "cd ../../../../ && husky"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -36,15 +36,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.7.2",
|
||||
"@vercel/ncc": "^0.38.0",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-plugin-github": "^4.10.0",
|
||||
"eslint-plugin-github": "^4.10.2",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2"
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ NODE_URL=https://nodejs.org/dist
|
||||
NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
|
||||
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
|
||||
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||
NODE20_VERSION="20.19.4"
|
||||
NODE24_VERSION="24.5.0"
|
||||
NODE20_VERSION="20.19.5"
|
||||
NODE24_VERSION="24.11.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
SECONDS=0
|
||||
while [[ $SECONDS != $1 ]]; do
|
||||
while [[ $SECONDS -lt $1 ]]; do
|
||||
:
|
||||
done
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace GitHub.Runner.Common
|
||||
|
||||
Task<TaskAgentMessage> GetRunnerMessageAsync(Guid? sessionId, TaskAgentStatus status, string version, string os, string architecture, bool disableUpdate, CancellationToken token);
|
||||
|
||||
Task AcknowledgeRunnerRequestAsync(string runnerRequestId, Guid? sessionId, TaskAgentStatus status, string version, string os, string architecture, CancellationToken token);
|
||||
|
||||
Task UpdateConnectionIfNeeded(Uri serverUri, VssCredentials credentials);
|
||||
|
||||
Task ForceRefreshConnection(VssCredentials credentials);
|
||||
@@ -67,10 +69,17 @@ namespace GitHub.Runner.Common
|
||||
var brokerSession = RetryRequest<TaskAgentMessage>(
|
||||
async () => await _brokerHttpClient.GetRunnerMessageAsync(sessionId, version, status, os, architecture, disableUpdate, cancellationToken), cancellationToken, shouldRetry: ShouldRetryException);
|
||||
|
||||
|
||||
return brokerSession;
|
||||
}
|
||||
|
||||
public async Task AcknowledgeRunnerRequestAsync(string runnerRequestId, Guid? sessionId, TaskAgentStatus status, string version, string os, string architecture, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection();
|
||||
|
||||
// No retries
|
||||
await _brokerHttpClient.AcknowledgeRunnerRequestAsync(runnerRequestId, sessionId, version, status, os, architecture, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
@@ -53,6 +53,9 @@ namespace GitHub.Runner.Common
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool UseV2Flow { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool UseRunnerAdminFlow { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ServerUrlV2 { get; set; }
|
||||
|
||||
@@ -61,8 +64,20 @@ namespace GitHub.Runner.Common
|
||||
{
|
||||
get
|
||||
{
|
||||
// Old runners do not have this property. Hosted runners likely don't have this property either.
|
||||
return _isHostedServer ?? true;
|
||||
// If the value has been explicitly set, return it.
|
||||
if (_isHostedServer.HasValue)
|
||||
{
|
||||
return _isHostedServer.Value;
|
||||
}
|
||||
|
||||
// Otherwise, try to infer it from the GitHubUrl.
|
||||
if (!string.IsNullOrEmpty(GitHubUrl))
|
||||
{
|
||||
return UrlUtil.IsHostedServer(new UriBuilder(GitHubUrl));
|
||||
}
|
||||
|
||||
// Default to true since Hosted runners likely don't have this property set.
|
||||
return true;
|
||||
}
|
||||
|
||||
set
|
||||
|
||||
@@ -169,6 +169,10 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string AllowRunnerContainerHooks = "DistributedTask.AllowRunnerContainerHooks";
|
||||
public static readonly string AddCheckRunIdToJobContext = "actions_add_check_run_id_to_job_context";
|
||||
public static readonly string DisplayHelpfulActionsDownloadErrors = "actions_display_helpful_actions_download_errors";
|
||||
public static readonly string ContainerActionRunnerTemp = "actions_container_action_runner_temp";
|
||||
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
|
||||
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
|
||||
public static readonly string CompareTemplateEvaluator = "actions_runner_compare_template_evaluator";
|
||||
}
|
||||
|
||||
// Node version migration related constants
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace GitHub.Runner.Common
|
||||
string environmentUrl,
|
||||
IList<Telemetry> telemetry,
|
||||
string billingOwnerId,
|
||||
string infrastructureFailureCategory,
|
||||
CancellationToken token);
|
||||
|
||||
Task<RenewJobResponse> RenewJobAsync(Guid planId, Guid jobId, CancellationToken token);
|
||||
@@ -80,11 +81,12 @@ namespace GitHub.Runner.Common
|
||||
string environmentUrl,
|
||||
IList<Telemetry> telemetry,
|
||||
string billingOwnerId,
|
||||
string infrastructureFailureCategory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection();
|
||||
return RetryRequest(
|
||||
async () => await _runServiceHttpClient.CompleteJobAsync(requestUri, planId, jobId, result, outputs, stepResults, jobAnnotations, environmentUrl, telemetry, billingOwnerId, cancellationToken), cancellationToken,
|
||||
async () => await _runServiceHttpClient.CompleteJobAsync(requestUri, planId, jobId, result, outputs, stepResults, jobAnnotations, environmentUrl, telemetry, billingOwnerId, infrastructureFailureCategory, cancellationToken), cancellationToken,
|
||||
shouldRetry: ex =>
|
||||
ex is not VssUnauthorizedException && // HTTP status 401
|
||||
ex is not TaskOrchestrationJobNotFoundException); // HTTP status 404
|
||||
|
||||
@@ -70,7 +70,7 @@ namespace GitHub.Runner.Common
|
||||
|
||||
protected async Task RetryRequest(Func<Task> func,
|
||||
CancellationToken cancellationToken,
|
||||
int maxRetryAttemptsCount = 5,
|
||||
int maxAttempts = 5,
|
||||
Func<Exception, bool> shouldRetry = null
|
||||
)
|
||||
{
|
||||
@@ -79,31 +79,31 @@ namespace GitHub.Runner.Common
|
||||
await func();
|
||||
return Unit.Value;
|
||||
}
|
||||
await RetryRequest<Unit>(wrappedFunc, cancellationToken, maxRetryAttemptsCount, shouldRetry);
|
||||
await RetryRequest<Unit>(wrappedFunc, cancellationToken, maxAttempts, shouldRetry);
|
||||
}
|
||||
|
||||
protected async Task<T> RetryRequest<T>(Func<Task<T>> func,
|
||||
CancellationToken cancellationToken,
|
||||
int maxRetryAttemptsCount = 5,
|
||||
int maxAttempts = 5,
|
||||
Func<Exception, bool> shouldRetry = null
|
||||
)
|
||||
{
|
||||
var retryCount = 0;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
{
|
||||
retryCount++;
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
return await func();
|
||||
}
|
||||
// TODO: Add handling of non-retriable exceptions: https://github.com/github/actions-broker/issues/122
|
||||
catch (Exception ex) when (retryCount < maxRetryAttemptsCount && (shouldRetry == null || shouldRetry(ex)))
|
||||
catch (Exception ex) when (attempt < maxAttempts && (shouldRetry == null || shouldRetry(ex)))
|
||||
{
|
||||
Trace.Error("Catch exception during request");
|
||||
Trace.Error(ex);
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
Trace.Warning($"Back off {backOff.TotalSeconds} seconds before next retry. {maxRetryAttemptsCount - retryCount} attempt left.");
|
||||
Trace.Warning($"Back off {backOff.TotalSeconds} seconds before next retry. {maxAttempts - attempt} attempt left.");
|
||||
await Task.Delay(backOff, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace GitHub.Runner.Listener
|
||||
private RunnerSettings _settings;
|
||||
private ITerminal _term;
|
||||
private TimeSpan _getNextMessageRetryInterval;
|
||||
private TaskAgentStatus runnerStatus = TaskAgentStatus.Online;
|
||||
private TaskAgentStatus _runnerStatus = TaskAgentStatus.Online;
|
||||
private CancellationTokenSource _getMessagesTokenSource;
|
||||
private VssCredentials _creds;
|
||||
private VssCredentials _credsV2;
|
||||
@@ -258,7 +258,7 @@ namespace GitHub.Runner.Listener
|
||||
public void OnJobStatus(object sender, JobStatusEventArgs e)
|
||||
{
|
||||
Trace.Info("Received job status event. JobState: {0}", e.Status);
|
||||
runnerStatus = e.Status;
|
||||
_runnerStatus = e.Status;
|
||||
try
|
||||
{
|
||||
_getMessagesTokenSource?.Cancel();
|
||||
@@ -291,7 +291,7 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
|
||||
message = await _brokerServer.GetRunnerMessageAsync(_session.SessionId,
|
||||
runnerStatus,
|
||||
_runnerStatus,
|
||||
BuildConstants.RunnerPackage.Version,
|
||||
VarUtil.OS,
|
||||
VarUtil.OSArchitecture,
|
||||
@@ -417,6 +417,21 @@ namespace GitHub.Runner.Listener
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task AcknowledgeMessageAsync(string runnerRequestId, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Short timeout
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||
Trace.Info($"Acknowledging runner request '{runnerRequestId}'.");
|
||||
await _brokerServer.AcknowledgeRunnerRequestAsync(
|
||||
runnerRequestId,
|
||||
_session.SessionId,
|
||||
_runnerStatus,
|
||||
BuildConstants.RunnerPackage.Version,
|
||||
VarUtil.OS,
|
||||
VarUtil.OSArchitecture,
|
||||
linkedCts.Token);
|
||||
}
|
||||
|
||||
private bool IsGetNextMessageExceptionRetriable(Exception ex)
|
||||
{
|
||||
if (ex is TaskAgentNotFoundException ||
|
||||
|
||||
@@ -153,8 +153,8 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
registerToken = await GetRunnerTokenAsync(command, inputUrl, "registration");
|
||||
GitHubAuthResult authResult = await GetTenantCredential(inputUrl, registerToken, Constants.RunnerEvent.Register);
|
||||
runnerSettings.ServerUrl = authResult.TenantUrl;
|
||||
runnerSettings.UseV2Flow = authResult.UseV2Flow;
|
||||
Trace.Info($"Using V2 flow: {runnerSettings.UseV2Flow}");
|
||||
runnerSettings.UseRunnerAdminFlow = authResult.UseRunnerAdminFlow;
|
||||
Trace.Info($"Using runner-admin flow: {runnerSettings.UseRunnerAdminFlow}");
|
||||
creds = authResult.ToVssCredentials();
|
||||
Trace.Info("cred retrieved via GitHub auth");
|
||||
}
|
||||
@@ -211,7 +211,7 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
string poolName = null;
|
||||
TaskAgentPool agentPool = null;
|
||||
List<TaskAgentPool> agentPools;
|
||||
if (runnerSettings.UseV2Flow)
|
||||
if (runnerSettings.UseRunnerAdminFlow)
|
||||
{
|
||||
agentPools = await _dotcomServer.GetRunnerGroupsAsync(runnerSettings.GitHubUrl, registerToken);
|
||||
}
|
||||
@@ -259,7 +259,7 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
var userLabels = command.GetLabels();
|
||||
_term.WriteLine();
|
||||
List<TaskAgent> agents;
|
||||
if (runnerSettings.UseV2Flow)
|
||||
if (runnerSettings.UseRunnerAdminFlow)
|
||||
{
|
||||
agents = await _dotcomServer.GetRunnerByNameAsync(runnerSettings.GitHubUrl, registerToken, runnerSettings.AgentName);
|
||||
}
|
||||
@@ -280,10 +280,11 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
|
||||
try
|
||||
{
|
||||
if (runnerSettings.UseV2Flow)
|
||||
if (runnerSettings.UseRunnerAdminFlow)
|
||||
{
|
||||
var runner = await _dotcomServer.ReplaceRunnerAsync(runnerSettings.PoolId, agent, runnerSettings.GitHubUrl, registerToken, publicKeyXML);
|
||||
runnerSettings.ServerUrlV2 = runner.RunnerAuthorization.ServerUrl;
|
||||
runnerSettings.UseV2Flow = true; // if we are using runner admin, we also need to hit broker
|
||||
|
||||
agent.Id = runner.Id;
|
||||
agent.Authorization = new TaskAgentAuthorization()
|
||||
@@ -291,6 +292,13 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
AuthorizationUrl = runner.RunnerAuthorization.AuthorizationUrl,
|
||||
ClientId = new Guid(runner.RunnerAuthorization.ClientId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(runner.RunnerAuthorization.LegacyAuthorizationUrl?.AbsoluteUri))
|
||||
{
|
||||
agent.Authorization.AuthorizationUrl = runner.RunnerAuthorization.LegacyAuthorizationUrl;
|
||||
agent.Properties["EnableAuthMigrationByDefault"] = true;
|
||||
agent.Properties["AuthorizationUrlV2"] = runner.RunnerAuthorization.AuthorizationUrl.AbsoluteUri;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -330,10 +338,11 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
|
||||
try
|
||||
{
|
||||
if (runnerSettings.UseV2Flow)
|
||||
if (runnerSettings.UseRunnerAdminFlow)
|
||||
{
|
||||
var runner = await _dotcomServer.AddRunnerAsync(runnerSettings.PoolId, agent, runnerSettings.GitHubUrl, registerToken, publicKeyXML);
|
||||
runnerSettings.ServerUrlV2 = runner.RunnerAuthorization.ServerUrl;
|
||||
runnerSettings.UseV2Flow = true; // if we are using runner admin, we also need to hit broker
|
||||
|
||||
agent.Id = runner.Id;
|
||||
agent.Authorization = new TaskAgentAuthorization()
|
||||
@@ -341,6 +350,13 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
AuthorizationUrl = runner.RunnerAuthorization.AuthorizationUrl,
|
||||
ClientId = new Guid(runner.RunnerAuthorization.ClientId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(runner.RunnerAuthorization.LegacyAuthorizationUrl?.AbsoluteUri))
|
||||
{
|
||||
agent.Authorization.AuthorizationUrl = runner.RunnerAuthorization.LegacyAuthorizationUrl;
|
||||
agent.Properties["EnableAuthMigrationByDefault"] = true;
|
||||
agent.Properties["AuthorizationUrlV2"] = runner.RunnerAuthorization.AuthorizationUrl.AbsoluteUri;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -400,13 +416,26 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
throw new NotSupportedException("Message queue listen OAuth token.");
|
||||
}
|
||||
|
||||
// allow the server to override the serverUrlV2 and useV2Flow
|
||||
if (agent.Properties.TryGetValue("ServerUrlV2", out string serverUrlV2) &&
|
||||
!string.IsNullOrEmpty(serverUrlV2))
|
||||
{
|
||||
Trace.Info($"Service enforced serverUrlV2: {serverUrlV2}");
|
||||
runnerSettings.ServerUrlV2 = serverUrlV2;
|
||||
}
|
||||
|
||||
if (agent.Properties.TryGetValue("UseV2Flow", out bool useV2Flow) && useV2Flow)
|
||||
{
|
||||
Trace.Info($"Service enforced useV2Flow: {useV2Flow}");
|
||||
runnerSettings.UseV2Flow = useV2Flow;
|
||||
}
|
||||
|
||||
// Testing agent connection, detect any potential connection issue, like local clock skew that cause OAuth token expired.
|
||||
|
||||
if (!runnerSettings.UseV2Flow)
|
||||
if (!runnerSettings.UseV2Flow && !runnerSettings.UseRunnerAdminFlow)
|
||||
{
|
||||
var credMgr = HostContext.GetService<ICredentialManager>();
|
||||
VssCredentials credential = credMgr.LoadCredentials(allowAuthUrlV2: false);
|
||||
@@ -429,20 +458,6 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
}
|
||||
}
|
||||
|
||||
// allow the server to override the serverUrlV2 and useV2Flow
|
||||
if (agent.Properties.TryGetValue("ServerUrlV2", out string serverUrlV2) &&
|
||||
!string.IsNullOrEmpty(serverUrlV2))
|
||||
{
|
||||
Trace.Info($"Service enforced serverUrlV2: {serverUrlV2}");
|
||||
runnerSettings.ServerUrlV2 = serverUrlV2;
|
||||
}
|
||||
|
||||
if (agent.Properties.TryGetValue("UseV2Flow", out bool useV2Flow) && useV2Flow)
|
||||
{
|
||||
Trace.Info($"Service enforced useV2Flow: {useV2Flow}");
|
||||
runnerSettings.UseV2Flow = useV2Flow;
|
||||
}
|
||||
|
||||
_term.WriteSection("Runner settings");
|
||||
|
||||
// We will Combine() what's stored with root. Defaults to string a relative path
|
||||
@@ -538,7 +553,7 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
RunnerSettings settings = _store.GetSettings();
|
||||
|
||||
if (settings.UseV2Flow)
|
||||
if (settings.UseRunnerAdminFlow)
|
||||
{
|
||||
var deletionToken = await GetRunnerTokenAsync(command, settings.GitHubUrl, "remove");
|
||||
await _dotcomServer.DeleteRunnerAsync(settings.GitHubUrl, deletionToken, settings.AgentId);
|
||||
|
||||
@@ -89,7 +89,7 @@ namespace GitHub.Runner.Listener.Configuration
|
||||
public string Token { get; set; }
|
||||
|
||||
[DataMember(Name = "use_v2_flow")]
|
||||
public bool UseV2Flow { get; set; }
|
||||
public bool UseRunnerAdminFlow { get; set; }
|
||||
|
||||
public VssCredentials ToVssCredentials()
|
||||
{
|
||||
|
||||
@@ -1211,7 +1211,7 @@ namespace GitHub.Runner.Listener
|
||||
jobAnnotations.Add(annotation.Value);
|
||||
}
|
||||
|
||||
await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, TaskResult.Failed, outputs: null, stepResults: null, jobAnnotations: jobAnnotations, environmentUrl: null, telemetry: null, billingOwnerId: message.BillingOwnerId, CancellationToken.None);
|
||||
await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, TaskResult.Failed, outputs: null, stepResults: null, jobAnnotations: jobAnnotations, environmentUrl: null, telemetry: null, billingOwnerId: message.BillingOwnerId, infrastructureFailureCategory: null, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace GitHub.Runner.Listener
|
||||
Task DeleteSessionAsync();
|
||||
Task<TaskAgentMessage> GetNextMessageAsync(CancellationToken token);
|
||||
Task DeleteMessageAsync(TaskAgentMessage message);
|
||||
Task AcknowledgeMessageAsync(string runnerRequestId, CancellationToken cancellationToken);
|
||||
|
||||
Task RefreshListenerTokenAsync();
|
||||
void OnJobStatus(object sender, JobStatusEventArgs e);
|
||||
@@ -52,7 +53,7 @@ namespace GitHub.Runner.Listener
|
||||
private readonly TimeSpan _sessionConflictRetryLimit = TimeSpan.FromMinutes(4);
|
||||
private readonly TimeSpan _clockSkewRetryLimit = TimeSpan.FromMinutes(30);
|
||||
private readonly Dictionary<string, int> _sessionCreationExceptionTracker = new();
|
||||
private TaskAgentStatus runnerStatus = TaskAgentStatus.Online;
|
||||
private TaskAgentStatus _runnerStatus = TaskAgentStatus.Online;
|
||||
private CancellationTokenSource _getMessagesTokenSource;
|
||||
private VssCredentials _creds;
|
||||
private VssCredentials _credsV2;
|
||||
@@ -217,7 +218,7 @@ namespace GitHub.Runner.Listener
|
||||
public void OnJobStatus(object sender, JobStatusEventArgs e)
|
||||
{
|
||||
Trace.Info("Received job status event. JobState: {0}", e.Status);
|
||||
runnerStatus = e.Status;
|
||||
_runnerStatus = e.Status;
|
||||
try
|
||||
{
|
||||
_getMessagesTokenSource?.Cancel();
|
||||
@@ -250,7 +251,7 @@ namespace GitHub.Runner.Listener
|
||||
message = await _runnerServer.GetAgentMessageAsync(_settings.PoolId,
|
||||
_session.SessionId,
|
||||
_lastMessageId,
|
||||
runnerStatus,
|
||||
_runnerStatus,
|
||||
BuildConstants.RunnerPackage.Version,
|
||||
VarUtil.OS,
|
||||
VarUtil.OSArchitecture,
|
||||
@@ -274,7 +275,7 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
|
||||
message = await _brokerServer.GetRunnerMessageAsync(_session.SessionId,
|
||||
runnerStatus,
|
||||
_runnerStatus,
|
||||
BuildConstants.RunnerPackage.Version,
|
||||
VarUtil.OS,
|
||||
VarUtil.OSArchitecture,
|
||||
@@ -437,6 +438,21 @@ namespace GitHub.Runner.Listener
|
||||
await _brokerServer.ForceRefreshConnection(_credsV2);
|
||||
}
|
||||
|
||||
public async Task AcknowledgeMessageAsync(string runnerRequestId, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Short timeout
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||
Trace.Info($"Acknowledging runner request '{runnerRequestId}'.");
|
||||
await _brokerServer.AcknowledgeRunnerRequestAsync(
|
||||
runnerRequestId,
|
||||
_session.SessionId,
|
||||
_runnerStatus,
|
||||
BuildConstants.RunnerPackage.Version,
|
||||
VarUtil.OS,
|
||||
VarUtil.OSArchitecture,
|
||||
linkedCts.Token);
|
||||
}
|
||||
|
||||
private TaskAgentMessage DecryptMessage(TaskAgentMessage message)
|
||||
{
|
||||
if (_session.EncryptionKey == null ||
|
||||
|
||||
@@ -654,22 +654,42 @@ namespace GitHub.Runner.Listener
|
||||
else
|
||||
{
|
||||
var messageRef = StringUtil.ConvertFromJson<RunnerJobRequestRef>(message.Body);
|
||||
Pipelines.AgentJobRequestMessage jobRequestMessage = null;
|
||||
|
||||
// Create connection
|
||||
var credMgr = HostContext.GetService<ICredentialManager>();
|
||||
// Acknowledge (best-effort)
|
||||
if (messageRef.ShouldAcknowledge) // Temporary feature flag
|
||||
{
|
||||
try
|
||||
{
|
||||
await _listener.AcknowledgeMessageAsync(messageRef.RunnerRequestId, messageQueueLoopTokenSource.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Best-effort acknowledge failed for request '{messageRef.RunnerRequestId}'");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
Pipelines.AgentJobRequestMessage jobRequestMessage = null;
|
||||
if (string.IsNullOrEmpty(messageRef.RunServiceUrl))
|
||||
{
|
||||
// Connect
|
||||
var credMgr = HostContext.GetService<ICredentialManager>();
|
||||
var creds = credMgr.LoadCredentials(allowAuthUrlV2: false);
|
||||
var actionsRunServer = HostContext.CreateService<IActionsRunServer>();
|
||||
await actionsRunServer.ConnectAsync(new Uri(settings.ServerUrl), creds);
|
||||
|
||||
// Get job message
|
||||
jobRequestMessage = await actionsRunServer.GetJobMessageAsync(messageRef.RunnerRequestId, messageQueueLoopTokenSource.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Connect
|
||||
var credMgr = HostContext.GetService<ICredentialManager>();
|
||||
var credsV2 = credMgr.LoadCredentials(allowAuthUrlV2: true);
|
||||
var runServer = HostContext.CreateService<IRunServer>();
|
||||
await runServer.ConnectAsync(new Uri(messageRef.RunServiceUrl), credsV2);
|
||||
|
||||
// Get job message
|
||||
try
|
||||
{
|
||||
jobRequestMessage = await runServer.GetJobMessageAsync(messageRef.RunnerRequestId, messageRef.BillingOwnerId, messageQueueLoopTokenSource.Token);
|
||||
@@ -698,7 +718,10 @@ namespace GitHub.Runner.Listener
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch
|
||||
jobDispatcher.Run(jobRequestMessage, runOnce);
|
||||
|
||||
// Run once?
|
||||
if (runOnce)
|
||||
{
|
||||
Trace.Info("One time used runner received job message.");
|
||||
|
||||
@@ -10,6 +10,9 @@ namespace GitHub.Runner.Listener
|
||||
|
||||
[DataMember(Name = "runner_request_id")]
|
||||
public string RunnerRequestId { get; set; }
|
||||
|
||||
[DataMember(Name = "should_acknowledge")]
|
||||
public bool ShouldAcknowledge { get; set; }
|
||||
|
||||
[DataMember(Name = "run_service_url")]
|
||||
public string RunServiceUrl { get; set; }
|
||||
|
||||
@@ -111,7 +111,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
// Log the error and fail the PrepareActionsAsync Initialization.
|
||||
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
|
||||
executionContext.InfrastructureError(ex.Message);
|
||||
executionContext.InfrastructureError(ex.Message, category: "resolve_action");
|
||||
executionContext.Result = TaskResult.Failed;
|
||||
throw;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
// Log the error and fail the PrepareActionsAsync Initialization.
|
||||
Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}");
|
||||
executionContext.InfrastructureError(ex.Message);
|
||||
executionContext.InfrastructureError(ex.Message, category: "invalid_action_download");
|
||||
executionContext.Result = TaskResult.Failed;
|
||||
throw;
|
||||
}
|
||||
@@ -777,15 +777,15 @@ namespace GitHub.Runner.Worker
|
||||
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
|
||||
Directory.CreateDirectory(destDirectory);
|
||||
|
||||
if (downloadInfo.PackageDetails != null)
|
||||
if (downloadInfo.PackageDetails != null)
|
||||
{
|
||||
executionContext.Output($"##[group]Download immutable action package '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}'");
|
||||
executionContext.Output($"Version: {downloadInfo.PackageDetails.Version}");
|
||||
executionContext.Output($"Digest: {downloadInfo.PackageDetails.ManifestDigest}");
|
||||
executionContext.Output($"Source commit SHA: {downloadInfo.ResolvedSha}");
|
||||
executionContext.Output("##[endgroup]");
|
||||
}
|
||||
else
|
||||
}
|
||||
else
|
||||
{
|
||||
executionContext.Output($"Download action repository '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}' (SHA:{downloadInfo.ResolvedSha})");
|
||||
}
|
||||
|
||||
@@ -522,6 +522,10 @@ namespace GitHub.Runner.Worker
|
||||
if (annotation != null)
|
||||
{
|
||||
stepResult.Annotations.Add(annotation.Value);
|
||||
if (annotation.Value.IsInfrastructureIssue && string.IsNullOrEmpty(Global.InfrastructureFailureCategory))
|
||||
{
|
||||
Global.InfrastructureFailureCategory = issue.Category;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1302,10 +1306,14 @@ namespace GitHub.Runner.Worker
|
||||
UpdateGlobalStepsContext();
|
||||
}
|
||||
|
||||
internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter);
|
||||
}
|
||||
|
||||
private static void NoOp()
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// The Error/Warning/etc methods are created as extension methods to simplify unit testing.
|
||||
@@ -1335,9 +1343,9 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
|
||||
// Do not add a format string overload. See comment on ExecutionContext.Write().
|
||||
public static void InfrastructureError(this IExecutionContext context, string message)
|
||||
public static void InfrastructureError(this IExecutionContext context, string message, string category = null)
|
||||
{
|
||||
var issue = new Issue() { Type = IssueType.Error, Message = message, IsInfrastructureIssue = true };
|
||||
var issue = new Issue() { Type = IssueType.Error, Message = message, IsInfrastructureIssue = true, Category = category };
|
||||
context.AddIssue(issue, ExecutionContextLogOptions.Default);
|
||||
}
|
||||
|
||||
@@ -1386,8 +1394,15 @@ namespace GitHub.Runner.Worker
|
||||
return new[] { new KeyValuePair<string, object>(nameof(IExecutionContext), context) };
|
||||
}
|
||||
|
||||
public static PipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
// Create wrapper?
|
||||
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareTemplateEvaluator) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_TEMPLATE_EVALUATOR")))
|
||||
{
|
||||
return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter);
|
||||
}
|
||||
|
||||
// Legacy
|
||||
if (traceWriter == null)
|
||||
{
|
||||
traceWriter = context.ToTemplateTraceWriter();
|
||||
|
||||
@@ -22,4 +22,13 @@ namespace GitHub.Runner.Worker.Expressions
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NewAlwaysFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||
{
|
||||
protected override Object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext context, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,18 @@ namespace GitHub.Runner.Worker.Expressions
|
||||
return jobStatus == ActionResult.Cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NewCancelledFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||
{
|
||||
protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||
var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext;
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
|
||||
return jobStatus == ActionResult.Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,29 @@ namespace GitHub.Runner.Worker.Expressions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NewFailureFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||
{
|
||||
protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||
var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext;
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
|
||||
// Decide based on 'action_status' for composite MAIN steps and 'job.status' for pre, post and job-level steps
|
||||
var isCompositeMainStep = executionContext.IsEmbedded && executionContext.Stage == ActionRunStage.Main;
|
||||
if (isCompositeMainStep)
|
||||
{
|
||||
ActionResult actionStatus = EnumUtil.TryParse<ActionResult>(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success;
|
||||
return actionStatus == ActionResult.Failure;
|
||||
}
|
||||
else
|
||||
{
|
||||
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
|
||||
return jobStatus == ActionResult.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +143,137 @@ namespace GitHub.Runner.Worker.Expressions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NewHashFilesFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||
{
|
||||
private const int _hashFileTimeoutSeconds = 120;
|
||||
|
||||
protected sealed override Object EvaluateCore(
|
||||
GitHub.Actions.Expressions.Sdk.EvaluationContext context,
|
||||
out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var templateContext = context.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||
templateContext.ExpressionValues.TryGetValue(PipelineTemplateConstants.GitHub, out var githubContextData);
|
||||
ArgUtil.NotNull(githubContextData, nameof(githubContextData));
|
||||
var githubContext = githubContextData as GitHub.Actions.Expressions.Data.DictionaryExpressionData;
|
||||
ArgUtil.NotNull(githubContext, nameof(githubContext));
|
||||
|
||||
if (!githubContext.TryGetValue(PipelineTemplateConstants.HostWorkspace, out var workspace))
|
||||
{
|
||||
githubContext.TryGetValue(PipelineTemplateConstants.Workspace, out workspace);
|
||||
}
|
||||
ArgUtil.NotNull(workspace, nameof(workspace));
|
||||
|
||||
var workspaceData = workspace as GitHub.Actions.Expressions.Data.StringExpressionData;
|
||||
ArgUtil.NotNull(workspaceData, nameof(workspaceData));
|
||||
|
||||
string githubWorkspace = workspaceData.Value;
|
||||
|
||||
bool followSymlink = false;
|
||||
List<string> patterns = new();
|
||||
var firstParameter = true;
|
||||
foreach (var parameter in Parameters)
|
||||
{
|
||||
var parameterString = parameter.Evaluate(context).ConvertToString();
|
||||
if (firstParameter)
|
||||
{
|
||||
firstParameter = false;
|
||||
if (parameterString.StartsWith("--"))
|
||||
{
|
||||
if (string.Equals(parameterString, "--follow-symbolic-links", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
followSymlink = true;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentOutOfRangeException($"Invalid glob option {parameterString}, avaliable option: '--follow-symbolic-links'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
patterns.Add(parameterString);
|
||||
}
|
||||
|
||||
context.Trace.Info($"Search root directory: '{githubWorkspace}'");
|
||||
context.Trace.Info($"Search pattern: '{string.Join(", ", patterns)}'");
|
||||
|
||||
string binDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
|
||||
string runnerRoot = new DirectoryInfo(binDir).Parent.FullName;
|
||||
|
||||
string node = Path.Combine(runnerRoot, "externals", NodeUtil.GetInternalNodeVersion(), "bin", $"node{IOUtil.ExeExtension}");
|
||||
string hashFilesScript = Path.Combine(binDir, "hashFiles");
|
||||
var hashResult = string.Empty;
|
||||
var p = new ProcessInvoker(new NewHashFilesTrace(context.Trace));
|
||||
p.ErrorDataReceived += ((_, data) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(data.Data) && data.Data.StartsWith("__OUTPUT__") && data.Data.EndsWith("__OUTPUT__"))
|
||||
{
|
||||
hashResult = data.Data.Substring(10, data.Data.Length - 20);
|
||||
context.Trace.Info($"Hash result: '{hashResult}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Trace.Info(data.Data);
|
||||
}
|
||||
});
|
||||
|
||||
p.OutputDataReceived += ((_, data) =>
|
||||
{
|
||||
context.Trace.Info(data.Data);
|
||||
});
|
||||
|
||||
var env = new Dictionary<string, string>();
|
||||
if (followSymlink)
|
||||
{
|
||||
env["followSymbolicLinks"] = "true";
|
||||
}
|
||||
env["patterns"] = string.Join(Environment.NewLine, patterns);
|
||||
|
||||
using (var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(_hashFileTimeoutSeconds)))
|
||||
{
|
||||
try
|
||||
{
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: githubWorkspace,
|
||||
fileName: node,
|
||||
arguments: $"\"{hashFilesScript.Replace("\"", "\\\"")}\"",
|
||||
environment: env,
|
||||
requireExitCodeZero: false,
|
||||
cancellationToken: tokenSource.Token).GetAwaiter().GetResult();
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"hashFiles('{ExpressionUtility.StringEscape(string.Join(", ", patterns))}') failed. Fail to hash files under directory '{githubWorkspace}'");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (tokenSource.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"hashFiles('{ExpressionUtility.StringEscape(string.Join(", ", patterns))}') couldn't finish within {_hashFileTimeoutSeconds} seconds.");
|
||||
}
|
||||
|
||||
return hashResult;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NewHashFilesTrace : ITraceWriter
|
||||
{
|
||||
private GitHub.Actions.Expressions.ITraceWriter _trace;
|
||||
|
||||
public NewHashFilesTrace(GitHub.Actions.Expressions.ITraceWriter trace)
|
||||
{
|
||||
_trace = trace;
|
||||
}
|
||||
public void Info(string message)
|
||||
{
|
||||
_trace.Info(message);
|
||||
}
|
||||
|
||||
public void Verbose(string message)
|
||||
{
|
||||
_trace.Info(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,29 @@ namespace GitHub.Runner.Worker.Expressions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NewSuccessFunction : GitHub.Actions.Expressions.Sdk.Function
|
||||
{
|
||||
protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext;
|
||||
ArgUtil.NotNull(templateContext, nameof(templateContext));
|
||||
var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext;
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
|
||||
// Decide based on 'action_status' for composite MAIN steps and 'job.status' for pre, post and job-level steps
|
||||
var isCompositeMainStep = executionContext.IsEmbedded && executionContext.Stage == ActionRunStage.Main;
|
||||
if (isCompositeMainStep)
|
||||
{
|
||||
ActionResult actionStatus = EnumUtil.TryParse<ActionResult>(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success;
|
||||
return actionStatus == ActionResult.Success;
|
||||
}
|
||||
else
|
||||
{
|
||||
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
|
||||
return jobStatus == ActionResult.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,10 @@ namespace GitHub.Runner.Worker
|
||||
var isContainerHooksPathSet = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(Constants.Hooks.ContainerHooksPath));
|
||||
return isContainerHookFeatureFlagSet && isContainerHooksPathSet;
|
||||
}
|
||||
|
||||
public static bool IsContainerActionRunnerTempEnabled(Variables variables)
|
||||
{
|
||||
return variables?.GetBoolean(Constants.Runner.Features.ContainerActionRunnerTemp) ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ namespace GitHub.Runner.Worker
|
||||
public StepsContext StepsContext { get; set; }
|
||||
public Variables Variables { get; set; }
|
||||
public bool WriteDebug { get; set; }
|
||||
public string InfrastructureFailureCategory { get; set; }
|
||||
public JObject ContainerHookState { get; set; }
|
||||
public bool HasTemplateEvaluatorMismatch { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,11 +191,19 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
ArgUtil.Directory(tempWorkflowDirectory, nameof(tempWorkflowDirectory));
|
||||
|
||||
container.MountVolumes.Add(new MountVolume("/var/run/docker.sock", "/var/run/docker.sock"));
|
||||
if (FeatureManager.IsContainerActionRunnerTempEnabled(ExecutionContext.Global.Variables))
|
||||
{
|
||||
container.MountVolumes.Add(new MountVolume(tempDirectory, "/github/runner_temp"));
|
||||
}
|
||||
container.MountVolumes.Add(new MountVolume(tempHomeDirectory, "/github/home"));
|
||||
container.MountVolumes.Add(new MountVolume(tempWorkflowDirectory, "/github/workflow"));
|
||||
container.MountVolumes.Add(new MountVolume(tempFileCommandDirectory, "/github/file_commands"));
|
||||
container.MountVolumes.Add(new MountVolume(defaultWorkingDirectory, "/github/workspace"));
|
||||
|
||||
if (FeatureManager.IsContainerActionRunnerTempEnabled(ExecutionContext.Global.Variables))
|
||||
{
|
||||
container.AddPathTranslateMapping(tempDirectory, "/github/runner_temp");
|
||||
}
|
||||
container.AddPathTranslateMapping(tempHomeDirectory, "/github/home");
|
||||
container.AddPathTranslateMapping(tempWorkflowDirectory, "/github/workflow");
|
||||
container.AddPathTranslateMapping(tempFileCommandDirectory, "/github/file_commands");
|
||||
|
||||
@@ -400,6 +400,10 @@ namespace GitHub.Runner.Worker
|
||||
if (snapshotRequest != null)
|
||||
{
|
||||
var snapshotOperationProvider = HostContext.GetService<ISnapshotOperationProvider>();
|
||||
// Check that that runner is capable of taking a snapshot
|
||||
snapshotOperationProvider.RunSnapshotPreflightChecks(context);
|
||||
|
||||
// Add postjob step to write snapshot file
|
||||
jobContext.RegisterPostJobStep(new JobExtensionRunner(
|
||||
runAsync: (executionContext, _) => snapshotOperationProvider.CreateSnapshotRequestAsync(executionContext, snapshotRequest),
|
||||
condition: snapshotRequest.Condition,
|
||||
|
||||
@@ -321,7 +321,7 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
try
|
||||
{
|
||||
await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, environmentUrl, telemetry, billingOwnerId: message.BillingOwnerId, default);
|
||||
await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, environmentUrl, telemetry, billingOwnerId: message.BillingOwnerId, infrastructureFailureCategory: jobContext.Global.InfrastructureFailureCategory, default);
|
||||
return result;
|
||||
}
|
||||
catch (VssUnauthorizedException ex)
|
||||
|
||||
679
src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs
Normal file
679
src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs
Normal file
@@ -0,0 +1,679 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Actions.WorkflowParser;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using ObjectTemplating = GitHub.DistributedTask.ObjectTemplating;
|
||||
|
||||
namespace GitHub.Runner.Worker
|
||||
{
|
||||
internal sealed class PipelineTemplateEvaluatorWrapper : IPipelineTemplateEvaluator
|
||||
{
|
||||
private PipelineTemplateEvaluator _legacyEvaluator;
|
||||
private WorkflowTemplateEvaluator _newEvaluator;
|
||||
private IExecutionContext _context;
|
||||
private Tracing _trace;
|
||||
|
||||
public PipelineTemplateEvaluatorWrapper(
|
||||
IHostContext hostContext,
|
||||
IExecutionContext context,
|
||||
ObjectTemplating.ITraceWriter traceWriter = null)
|
||||
{
|
||||
ArgUtil.NotNull(hostContext, nameof(hostContext));
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
_context = context;
|
||||
_trace = hostContext.GetTrace(nameof(PipelineTemplateEvaluatorWrapper));
|
||||
|
||||
if (traceWriter == null)
|
||||
{
|
||||
traceWriter = context.ToTemplateTraceWriter();
|
||||
}
|
||||
|
||||
// Legacy evaluator
|
||||
var schema = PipelineTemplateSchemaFactory.GetSchema();
|
||||
_legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
};
|
||||
|
||||
// New evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
_newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null)
|
||||
{
|
||||
MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly
|
||||
};
|
||||
}
|
||||
|
||||
public bool EvaluateStepContinueOnError(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateStepContinueOnError",
|
||||
() => _legacyEvaluator.EvaluateStepContinueOnError(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateStepContinueOnError(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
(legacyResult, newResult) => legacyResult == newResult);
|
||||
}
|
||||
|
||||
public string EvaluateStepDisplayName(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateStepDisplayName",
|
||||
() => _legacyEvaluator.EvaluateStepDisplayName(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateStepName(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
(legacyResult, newResult) => string.Equals(legacyResult, newResult, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public Dictionary<string, string> EvaluateStepEnvironment(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions,
|
||||
StringComparer keyComparer)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateStepEnvironment",
|
||||
() => _legacyEvaluator.EvaluateStepEnvironment(token, contextData, expressionFunctions, keyComparer),
|
||||
() => _newEvaluator.EvaluateStepEnvironment(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), keyComparer),
|
||||
CompareStepEnvironment);
|
||||
}
|
||||
|
||||
public bool EvaluateStepIf(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions,
|
||||
IEnumerable<KeyValuePair<string, object>> expressionState)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateStepIf",
|
||||
() => _legacyEvaluator.EvaluateStepIf(token, contextData, expressionFunctions, expressionState),
|
||||
() => _newEvaluator.EvaluateStepIf(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), expressionState),
|
||||
(legacyResult, newResult) => legacyResult == newResult);
|
||||
}
|
||||
|
||||
public Dictionary<string, string> EvaluateStepInputs(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateStepInputs",
|
||||
() => _legacyEvaluator.EvaluateStepInputs(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateStepInputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
(legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "StepInputs"));
|
||||
}
|
||||
|
||||
public int EvaluateStepTimeout(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateStepTimeout",
|
||||
() => _legacyEvaluator.EvaluateStepTimeout(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateStepTimeout(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
(legacyResult, newResult) => legacyResult == newResult);
|
||||
}
|
||||
|
||||
public GitHub.DistributedTask.Pipelines.JobContainer EvaluateJobContainer(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateJobContainer",
|
||||
() => _legacyEvaluator.EvaluateJobContainer(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateJobContainer(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
CompareJobContainer);
|
||||
}
|
||||
|
||||
public Dictionary<string, string> EvaluateJobOutput(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateJobOutput",
|
||||
() => _legacyEvaluator.EvaluateJobOutput(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateJobOutputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
(legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "JobOutput"));
|
||||
}
|
||||
|
||||
public TemplateToken EvaluateEnvironmentUrl(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateEnvironmentUrl",
|
||||
() => _legacyEvaluator.EvaluateEnvironmentUrl(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateJobEnvironmentUrl(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
CompareEnvironmentUrl);
|
||||
}
|
||||
|
||||
public Dictionary<string, string> EvaluateJobDefaultsRun(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateJobDefaultsRun",
|
||||
() => _legacyEvaluator.EvaluateJobDefaultsRun(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateJobDefaultsRun(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
(legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "JobDefaultsRun"));
|
||||
}
|
||||
|
||||
public IList<KeyValuePair<string, GitHub.DistributedTask.Pipelines.JobContainer>> EvaluateJobServiceContainers(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateJobServiceContainers",
|
||||
() => _legacyEvaluator.EvaluateJobServiceContainers(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateJobServiceContainers(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
(legacyResult, newResult) => CompareJobServiceContainers(legacyResult, newResult));
|
||||
}
|
||||
|
||||
public GitHub.DistributedTask.Pipelines.Snapshot EvaluateJobSnapshotRequest(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
return EvaluateAndCompare(
|
||||
"EvaluateJobSnapshotRequest",
|
||||
() => _legacyEvaluator.EvaluateJobSnapshotRequest(token, contextData, expressionFunctions),
|
||||
() => _newEvaluator.EvaluateSnapshot(string.Empty, ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)),
|
||||
CompareSnapshot);
|
||||
}
|
||||
|
||||
private void RecordMismatch(string methodName)
|
||||
{
|
||||
if (!_context.Global.HasTemplateEvaluatorMismatch)
|
||||
{
|
||||
_context.Global.HasTemplateEvaluatorMismatch = true;
|
||||
var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorMismatch: {methodName}" };
|
||||
_context.Global.JobTelemetry.Add(telemetry);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordComparisonError(string errorDetails)
|
||||
{
|
||||
if (!_context.Global.HasTemplateEvaluatorMismatch)
|
||||
{
|
||||
_context.Global.HasTemplateEvaluatorMismatch = true;
|
||||
var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorComparisonError: {errorDetails}" };
|
||||
_context.Global.JobTelemetry.Add(telemetry);
|
||||
}
|
||||
}
|
||||
|
||||
private TLegacy EvaluateAndCompare<TLegacy, TNew>(
|
||||
string methodName,
|
||||
Func<TLegacy> legacyEvaluator,
|
||||
Func<TNew> newEvaluator,
|
||||
Func<TLegacy, TNew, bool> resultComparer)
|
||||
{
|
||||
// Legacy evaluator
|
||||
var legacyException = default(Exception);
|
||||
var legacyResult = default(TLegacy);
|
||||
try
|
||||
{
|
||||
legacyResult = legacyEvaluator();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
legacyException = ex;
|
||||
}
|
||||
|
||||
// Compare with new evaluator
|
||||
try
|
||||
{
|
||||
ArgUtil.NotNull(_context, nameof(_context));
|
||||
ArgUtil.NotNull(_newEvaluator, nameof(_newEvaluator));
|
||||
_trace.Info(methodName);
|
||||
|
||||
// New evaluator
|
||||
var newException = default(Exception);
|
||||
var newResult = default(TNew);
|
||||
try
|
||||
{
|
||||
newResult = newEvaluator();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
newException = ex;
|
||||
}
|
||||
|
||||
// Compare results or exceptions
|
||||
if (legacyException != null || newException != null)
|
||||
{
|
||||
// Either one or both threw exceptions - compare them
|
||||
if (!CompareExceptions(legacyException, newException))
|
||||
{
|
||||
_trace.Info($"{methodName} exception mismatch");
|
||||
RecordMismatch($"{methodName}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Both succeeded - compare results
|
||||
if (!resultComparer(legacyResult, newResult))
|
||||
{
|
||||
_trace.Info($"{methodName} mismatch");
|
||||
RecordMismatch($"{methodName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Info($"Comparison failed: {ex.Message}");
|
||||
RecordComparisonError($"{methodName}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Re-throw legacy exception if any
|
||||
if (legacyException != null)
|
||||
{
|
||||
throw legacyException;
|
||||
}
|
||||
|
||||
return legacyResult;
|
||||
}
|
||||
|
||||
private GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken ConvertToken(
|
||||
GitHub.DistributedTask.ObjectTemplating.Tokens.TemplateToken token)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = StringUtil.ConvertToJson(token, Newtonsoft.Json.Formatting.None);
|
||||
return StringUtil.ConvertFromJson<GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken>(json);
|
||||
}
|
||||
|
||||
private GitHub.Actions.Expressions.Data.DictionaryExpressionData ConvertData(
|
||||
GitHub.DistributedTask.Pipelines.ContextData.DictionaryContextData contextData)
|
||||
{
|
||||
if (contextData == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = StringUtil.ConvertToJson(contextData, Newtonsoft.Json.Formatting.None);
|
||||
return StringUtil.ConvertFromJson<GitHub.Actions.Expressions.Data.DictionaryExpressionData>(json);
|
||||
}
|
||||
|
||||
private IList<GitHub.Actions.Expressions.IFunctionInfo> ConvertFunctions(
|
||||
IList<GitHub.DistributedTask.Expressions2.IFunctionInfo> expressionFunctions)
|
||||
{
|
||||
if (expressionFunctions == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<GitHub.Actions.Expressions.IFunctionInfo>();
|
||||
foreach (var func in expressionFunctions)
|
||||
{
|
||||
GitHub.Actions.Expressions.IFunctionInfo newFunc = func.Name switch
|
||||
{
|
||||
"always" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewAlwaysFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||
"cancelled" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewCancelledFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||
"failure" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewFailureFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||
"success" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewSuccessFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||
"hashFiles" => new GitHub.Actions.Expressions.FunctionInfo<Expressions.NewHashFilesFunction>(func.Name, func.MinParameters, func.MaxParameters),
|
||||
_ => throw new NotSupportedException($"Expression function '{func.Name}' is not supported for conversion")
|
||||
};
|
||||
result.Add(newFunc);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool CompareStepEnvironment(
|
||||
Dictionary<string, string> legacyResult,
|
||||
Dictionary<string, string> newResult)
|
||||
{
|
||||
return CompareDictionaries(legacyResult, newResult, "StepEnvironment");
|
||||
}
|
||||
|
||||
private bool CompareEnvironmentUrl(
|
||||
TemplateToken legacyResult,
|
||||
GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newResult)
|
||||
{
|
||||
var legacyJson = legacyResult != null ? Newtonsoft.Json.JsonConvert.SerializeObject(legacyResult, Newtonsoft.Json.Formatting.None) : null;
|
||||
var newJson = newResult != null ? Newtonsoft.Json.JsonConvert.SerializeObject(newResult, Newtonsoft.Json.Formatting.None) : null;
|
||||
return legacyJson == newJson;
|
||||
}
|
||||
|
||||
private bool CompareJobContainer(
|
||||
GitHub.DistributedTask.Pipelines.JobContainer legacyResult,
|
||||
GitHub.Actions.WorkflowParser.JobContainer newResult)
|
||||
{
|
||||
if (legacyResult == null && newResult == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (legacyResult == null || newResult == null)
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - one result is null (legacy={legacyResult == null}, new={newResult == null})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Image, newResult.Image, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Image differs (legacy='{legacyResult.Image}', new='{newResult.Image}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Options, newResult.Options, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobContainer mismatch - Options differs (legacy='{legacyResult.Options}', new='{newResult.Options}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareLists(legacyResult.Volumes, newResult.Volumes, "Volumes"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareLists(legacyResult.Ports, newResult.Ports, "Ports"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareCredentials(legacyResult.Credentials, newResult.Credentials))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareCredentials(
|
||||
GitHub.DistributedTask.Pipelines.ContainerRegistryCredentials legacyCreds,
|
||||
GitHub.Actions.WorkflowParser.ContainerRegistryCredentials newCreds)
|
||||
{
|
||||
if (legacyCreds == null && newCreds == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (legacyCreds == null || newCreds == null)
|
||||
{
|
||||
_trace.Info($"CompareCredentials mismatch - one is null (legacy={legacyCreds == null}, new={newCreds == null})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyCreds.Username, newCreds.Username, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareCredentials mismatch - Credentials.Username differs (legacy='{legacyCreds.Username}', new='{newCreds.Username}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyCreds.Password, newCreds.Password, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareCredentials mismatch - Credentials.Password differs");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareLists(IList<string> legacyList, IList<string> newList, string fieldName)
|
||||
{
|
||||
if (legacyList == null && newList == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (legacyList == null || newList == null)
|
||||
{
|
||||
_trace.Info($"CompareLists mismatch - {fieldName} - one is null (legacy={legacyList == null}, new={newList == null})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (legacyList.Count != newList.Count)
|
||||
{
|
||||
_trace.Info($"CompareLists mismatch - {fieldName}.Count differs (legacy={legacyList.Count}, new={newList.Count})");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < legacyList.Count; i++)
|
||||
{
|
||||
if (!string.Equals(legacyList[i], newList[i], StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareLists mismatch - {fieldName}[{i}] differs (legacy='{legacyList[i]}', new='{newList[i]}')");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareDictionaries(IDictionary<string, string> legacyDict, IDictionary<string, string> newDict, string fieldName)
|
||||
{
|
||||
if (legacyDict == null && newDict == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (legacyDict == null || newDict == null)
|
||||
{
|
||||
_trace.Info($"CompareDictionaries mismatch - {fieldName} - one is null (legacy={legacyDict == null}, new={newDict == null})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (legacyDict is Dictionary<String, String> legacyTypedDict && newDict is Dictionary<String, String> newTypedDict)
|
||||
{
|
||||
if (!object.Equals(legacyTypedDict.Comparer, newTypedDict.Comparer))
|
||||
{
|
||||
_trace.Info($"CompareDictionaries mismatch - {fieldName} - different comparers (legacy={legacyTypedDict.Comparer.GetType().Name}, new={newTypedDict.Comparer.GetType().Name})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyDict.Count != newDict.Count)
|
||||
{
|
||||
_trace.Info($"CompareDictionaries mismatch - {fieldName}.Count differs (legacy={legacyDict.Count}, new={newDict.Count})");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var kvp in legacyDict)
|
||||
{
|
||||
if (!newDict.TryGetValue(kvp.Key, out var newValue))
|
||||
{
|
||||
_trace.Info($"CompareDictionaries mismatch - {fieldName} - key '{kvp.Key}' missing in new result");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(kvp.Value, newValue, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareDictionaries mismatch - {fieldName}['{kvp.Key}'] differs (legacy='{kvp.Value}', new='{newValue}')");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareJobServiceContainers(
|
||||
IList<KeyValuePair<string, GitHub.DistributedTask.Pipelines.JobContainer>> legacyResult,
|
||||
IList<KeyValuePair<string, GitHub.Actions.WorkflowParser.JobContainer>> newResult)
|
||||
{
|
||||
if (legacyResult == null && newResult == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (legacyResult == null || newResult == null)
|
||||
{
|
||||
_trace.Info($"CompareJobServiceContainers mismatch - one result is null (legacy={legacyResult == null}, new={newResult == null})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (legacyResult.Count != newResult.Count)
|
||||
{
|
||||
_trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers.Count differs (legacy={legacyResult.Count}, new={newResult.Count})");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < legacyResult.Count; i++)
|
||||
{
|
||||
var legacyKvp = legacyResult[i];
|
||||
var newKvp = newResult[i];
|
||||
|
||||
if (!string.Equals(legacyKvp.Key, newKvp.Key, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers[{i}].Key differs (legacy='{legacyKvp.Key}', new='{newKvp.Key}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CompareJobContainer(legacyKvp.Value, newKvp.Value))
|
||||
{
|
||||
_trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers['{legacyKvp.Key}']");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareSnapshot(
|
||||
GitHub.DistributedTask.Pipelines.Snapshot legacyResult,
|
||||
GitHub.Actions.WorkflowParser.Snapshot newResult)
|
||||
{
|
||||
if (legacyResult == null && newResult == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (legacyResult == null || newResult == null)
|
||||
{
|
||||
_trace.Info($"CompareSnapshot mismatch - one is null (legacy={legacyResult == null}, new={newResult == null})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.ImageName, newResult.ImageName, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareSnapshot mismatch - Snapshot.ImageName differs (legacy='{legacyResult.ImageName}', new='{newResult.ImageName}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(legacyResult.Version, newResult.Version, StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareSnapshot mismatch - Snapshot.Version differs (legacy='{legacyResult.Version}', new='{newResult.Version}')");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare Condition (legacy) vs If (new)
|
||||
// Legacy has Condition as string, new has If as BasicExpressionToken
|
||||
// For comparison, we'll serialize the If token and compare with Condition
|
||||
var newIfValue = newResult.If != null ? Newtonsoft.Json.JsonConvert.SerializeObject(newResult.If, Newtonsoft.Json.Formatting.None) : null;
|
||||
|
||||
// Legacy Condition is a string expression like "success()"
|
||||
// New If is a BasicExpressionToken that needs to be serialized
|
||||
// We'll do a basic comparison - if both are null/empty or both exist
|
||||
var legacyHasCondition = !string.IsNullOrEmpty(legacyResult.Condition);
|
||||
var newHasIf = newResult.If != null;
|
||||
|
||||
if (legacyHasCondition != newHasIf)
|
||||
{
|
||||
_trace.Info($"CompareSnapshot mismatch - condition/if presence differs (legacy has condition={legacyHasCondition}, new has if={newHasIf})");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareExceptions(Exception legacyException, Exception newException)
|
||||
{
|
||||
if (legacyException == null && newException == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (legacyException == null || newException == null)
|
||||
{
|
||||
_trace.Info($"CompareExceptions mismatch - one exception is null (legacy={legacyException == null}, new={newException == null})");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare exception messages recursively (including inner exceptions)
|
||||
var legacyMessages = GetExceptionMessages(legacyException);
|
||||
var newMessages = GetExceptionMessages(newException);
|
||||
|
||||
if (legacyMessages.Count != newMessages.Count)
|
||||
{
|
||||
_trace.Info($"CompareExceptions mismatch - different number of exception messages (legacy={legacyMessages.Count}, new={newMessages.Count})");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < legacyMessages.Count; i++)
|
||||
{
|
||||
if (!string.Equals(legacyMessages[i], newMessages[i], StringComparison.Ordinal))
|
||||
{
|
||||
_trace.Info($"CompareExceptions mismatch - exception messages differ at level {i} (legacy='{legacyMessages[i]}', new='{newMessages[i]}')");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private IList<string> GetExceptionMessages(Exception ex)
|
||||
{
|
||||
var messages = new List<string>();
|
||||
var toProcess = new Queue<Exception>();
|
||||
toProcess.Enqueue(ex);
|
||||
int count = 0;
|
||||
|
||||
while (toProcess.Count > 0 && count < 50)
|
||||
{
|
||||
var current = toProcess.Dequeue();
|
||||
if (current == null) continue;
|
||||
|
||||
messages.Add(current.Message);
|
||||
count++;
|
||||
|
||||
// Special handling for AggregateException - enqueue all inner exceptions
|
||||
if (current is AggregateException aggregateEx)
|
||||
{
|
||||
foreach (var innerEx in aggregateEx.InnerExceptions)
|
||||
{
|
||||
if (innerEx != null && count < 50)
|
||||
{
|
||||
toProcess.Enqueue(innerEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (current.InnerException != null)
|
||||
{
|
||||
toProcess.Enqueue(current.InnerException);
|
||||
}
|
||||
|
||||
// Failsafe: if we have too many exceptions, stop and return what we have
|
||||
if (count >= 50)
|
||||
{
|
||||
_trace.Info("CompareExceptions failsafe triggered - too many exceptions (50+)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
namespace GitHub.Runner.Worker;
|
||||
|
||||
[ServiceLocator(Default = typeof(SnapshotOperationProvider))]
|
||||
public interface ISnapshotOperationProvider : IRunnerService
|
||||
{
|
||||
Task CreateSnapshotRequestAsync(IExecutionContext executionContext, Snapshot snapshotRequest);
|
||||
void RunSnapshotPreflightChecks(IExecutionContext jobContext);
|
||||
}
|
||||
|
||||
public class SnapshotOperationProvider : RunnerService, ISnapshotOperationProvider
|
||||
@@ -24,9 +28,32 @@ public class SnapshotOperationProvider : RunnerService, ISnapshotOperationProvid
|
||||
}
|
||||
|
||||
IOUtil.SaveObject(snapshotRequest, snapshotRequestFilePath);
|
||||
executionContext.Output($"Image Name: {snapshotRequest.ImageName} Version: {snapshotRequest.Version}");
|
||||
executionContext.Output($"Request written to: {snapshotRequestFilePath}");
|
||||
executionContext.Output("This request will be processed after the job completes. You will not receive any feedback on the snapshot process within the workflow logs of this job.");
|
||||
executionContext.Output("If the snapshot process is successful, you should see a new image with the requested name in the list of available custom images when creating a new GitHub-hosted Runner.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RunSnapshotPreflightChecks(IExecutionContext context)
|
||||
{
|
||||
var shouldCheckRunnerEnvironment = context.Global.Variables.GetBoolean(Constants.Runner.Features.SnapshotPreflightHostedRunnerCheck) ?? false;
|
||||
if (shouldCheckRunnerEnvironment &&
|
||||
context.Global.Variables.TryGetValue(WellKnownDistributedTaskVariables.RunnerEnvironment, out var runnerEnvironment) &&
|
||||
!string.IsNullOrEmpty(runnerEnvironment))
|
||||
{
|
||||
context.Debug($"Snapshot: RUNNER_ENVIRONMENT={runnerEnvironment}");
|
||||
if (!string.Equals(runnerEnvironment, "github-hosted", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Snapshot workflows must be run on a GitHub Hosted Runner");
|
||||
}
|
||||
}
|
||||
var imageGenEnabled = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED"));
|
||||
context.Debug($"Snapshot: GITHUB_ACTIONS_IMAGE_GEN_ENABLED={imageGenEnabled}");
|
||||
var shouldCheckImageGenPool = context.Global.Variables.GetBoolean(Constants.Runner.Features.SnapshotPreflightImageGenPoolCheck) ?? false;
|
||||
if (shouldCheckImageGenPool && !imageGenEnabled)
|
||||
{
|
||||
throw new ArgumentException("Snapshot workflows must be run a hosted runner with Image Generation enabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
|
||||
namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs.
|
||||
/// </summary>
|
||||
public interface IPipelineTemplateEvaluator
|
||||
{
|
||||
Boolean EvaluateStepContinueOnError(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
String EvaluateStepDisplayName(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
Dictionary<String, String> EvaluateStepEnvironment(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions,
|
||||
StringComparer keyComparer);
|
||||
|
||||
Boolean EvaluateStepIf(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions,
|
||||
IEnumerable<KeyValuePair<String, Object>> expressionState);
|
||||
|
||||
Dictionary<String, String> EvaluateStepInputs(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
Int32 EvaluateStepTimeout(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
JobContainer EvaluateJobContainer(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
Dictionary<String, String> EvaluateJobOutput(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
TemplateToken EvaluateEnvironmentUrl(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
Dictionary<String, String> EvaluateJobDefaultsRun(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
IList<KeyValuePair<String, JobContainer>> EvaluateJobServiceContainers(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
|
||||
Snapshot EvaluateJobSnapshotRequest(
|
||||
TemplateToken token,
|
||||
DictionaryContextData contextData,
|
||||
IList<IFunctionInfo> expressionFunctions);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
/// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs.
|
||||
/// </summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public class PipelineTemplateEvaluator
|
||||
public class PipelineTemplateEvaluator : IPipelineTemplateEvaluator
|
||||
{
|
||||
public PipelineTemplateEvaluator(
|
||||
ITraceWriter trace,
|
||||
|
||||
@@ -18,6 +18,16 @@ namespace GitHub.DistributedTask.WebApi
|
||||
internal set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The url to refresh tokens with legacy service
|
||||
/// </summary>
|
||||
[JsonProperty("legacy_authorization_url")]
|
||||
public Uri LegacyAuthorizationUrl
|
||||
{
|
||||
get;
|
||||
internal set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The url to connect to poll for messages
|
||||
/// </summary>
|
||||
|
||||
111
src/Sdk/Expressions/Data/ArrayExpressionData.cs
Normal file
111
src/Sdk/Expressions/Data/ArrayExpressionData.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
[DataContract]
|
||||
[JsonObject]
|
||||
public sealed class ArrayExpressionData : ExpressionData, IEnumerable<ExpressionData>, IReadOnlyArray
|
||||
{
|
||||
public ArrayExpressionData()
|
||||
: base(ExpressionDataType.Array)
|
||||
{
|
||||
}
|
||||
|
||||
[IgnoreDataMember]
|
||||
public Int32 Count => m_items?.Count ?? 0;
|
||||
|
||||
public ExpressionData this[Int32 index] => m_items[index];
|
||||
|
||||
Object IReadOnlyArray.this[Int32 index] => m_items[index];
|
||||
|
||||
public void Add(ExpressionData item)
|
||||
{
|
||||
if (m_items == null)
|
||||
{
|
||||
m_items = new List<ExpressionData>();
|
||||
}
|
||||
|
||||
m_items.Add(item);
|
||||
}
|
||||
|
||||
public override ExpressionData Clone()
|
||||
{
|
||||
var result = new ArrayExpressionData();
|
||||
if (m_items?.Count > 0)
|
||||
{
|
||||
result.m_items = new List<ExpressionData>(m_items.Count);
|
||||
foreach (var item in m_items)
|
||||
{
|
||||
result.m_items.Add(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override JToken ToJToken()
|
||||
{
|
||||
var result = new JArray();
|
||||
if (m_items?.Count > 0)
|
||||
{
|
||||
foreach (var item in m_items)
|
||||
{
|
||||
result.Add(item?.ToJToken() ?? JValue.CreateNull());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public IEnumerator<ExpressionData> GetEnumerator()
|
||||
{
|
||||
if (m_items?.Count > 0)
|
||||
{
|
||||
foreach (var item in m_items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
if (m_items?.Count > 0)
|
||||
{
|
||||
foreach (var item in m_items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IReadOnlyArray.GetEnumerator()
|
||||
{
|
||||
if (m_items?.Count > 0)
|
||||
{
|
||||
foreach (var item in m_items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[OnSerializing]
|
||||
private void OnSerializing(StreamingContext context)
|
||||
{
|
||||
if (m_items?.Count == 0)
|
||||
{
|
||||
m_items = null;
|
||||
}
|
||||
}
|
||||
|
||||
[DataMember(Name = "a", EmitDefaultValue = false)]
|
||||
private List<ExpressionData> m_items;
|
||||
}
|
||||
}
|
||||
58
src/Sdk/Expressions/Data/BooleanExpressionData.cs
Normal file
58
src/Sdk/Expressions/Data/BooleanExpressionData.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
[DataContract]
|
||||
public sealed class BooleanExpressionData : ExpressionData, IBoolean
|
||||
{
|
||||
public BooleanExpressionData(Boolean value)
|
||||
: base(ExpressionDataType.Boolean)
|
||||
{
|
||||
m_value = value;
|
||||
}
|
||||
|
||||
public Boolean Value
|
||||
{
|
||||
get
|
||||
{
|
||||
return m_value;
|
||||
}
|
||||
}
|
||||
|
||||
public override ExpressionData Clone()
|
||||
{
|
||||
return new BooleanExpressionData(m_value);
|
||||
}
|
||||
|
||||
public override JToken ToJToken()
|
||||
{
|
||||
return (JToken)m_value;
|
||||
}
|
||||
|
||||
public override String ToString()
|
||||
{
|
||||
return m_value ? "true" : "false";
|
||||
}
|
||||
|
||||
Boolean IBoolean.GetBoolean()
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
public static implicit operator Boolean(BooleanExpressionData data)
|
||||
{
|
||||
return data.Value;
|
||||
}
|
||||
|
||||
public static implicit operator BooleanExpressionData(Boolean data)
|
||||
{
|
||||
return new BooleanExpressionData(data);
|
||||
}
|
||||
|
||||
[DataMember(Name = "b", EmitDefaultValue = false)]
|
||||
private Boolean m_value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
[DataContract]
|
||||
[JsonObject]
|
||||
public class CaseSensitiveDictionaryExpressionData : ExpressionData, IEnumerable<KeyValuePair<String, ExpressionData>>, IReadOnlyObject
|
||||
{
|
||||
public CaseSensitiveDictionaryExpressionData()
|
||||
: base(ExpressionDataType.CaseSensitiveDictionary)
|
||||
{
|
||||
}
|
||||
|
||||
[IgnoreDataMember]
|
||||
public Int32 Count => m_list?.Count ?? 0;
|
||||
|
||||
[IgnoreDataMember]
|
||||
public IEnumerable<String> Keys
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return pair.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[IgnoreDataMember]
|
||||
public IEnumerable<ExpressionData> Values
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<Object> IReadOnlyObject.Values
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<String, Int32> IndexLookup
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_indexLookup == null)
|
||||
{
|
||||
m_indexLookup = new Dictionary<String, Int32>(StringComparer.Ordinal);
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
for (var i = 0; i < m_list.Count; i++)
|
||||
{
|
||||
var pair = m_list[i];
|
||||
m_indexLookup.Add(pair.Key, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m_indexLookup;
|
||||
}
|
||||
}
|
||||
|
||||
private List<DictionaryExpressionDataPair> List
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list == null)
|
||||
{
|
||||
m_list = new List<DictionaryExpressionDataPair>();
|
||||
}
|
||||
|
||||
return m_list;
|
||||
}
|
||||
}
|
||||
|
||||
public ExpressionData this[String key]
|
||||
{
|
||||
get
|
||||
{
|
||||
var index = IndexLookup[key];
|
||||
return m_list[index].Value;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
// Existing
|
||||
if (IndexLookup.TryGetValue(key, out var index))
|
||||
{
|
||||
key = m_list[index].Key; // preserve casing
|
||||
m_list[index] = new DictionaryExpressionDataPair(key, value);
|
||||
}
|
||||
// New
|
||||
else
|
||||
{
|
||||
Add(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object IReadOnlyObject.this[String key]
|
||||
{
|
||||
get
|
||||
{
|
||||
var index = IndexLookup[key];
|
||||
return m_list[index].Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal KeyValuePair<String, ExpressionData> this[Int32 index]
|
||||
{
|
||||
get
|
||||
{
|
||||
var pair = m_list[index];
|
||||
return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(IEnumerable<KeyValuePair<String, ExpressionData>> pairs)
|
||||
{
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
Add(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(
|
||||
String key,
|
||||
ExpressionData value)
|
||||
{
|
||||
IndexLookup.Add(key, m_list?.Count ?? 0);
|
||||
List.Add(new DictionaryExpressionDataPair(key, value));
|
||||
}
|
||||
|
||||
public override ExpressionData Clone()
|
||||
{
|
||||
var result = new CaseSensitiveDictionaryExpressionData();
|
||||
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
result.m_list = new List<DictionaryExpressionDataPair>(m_list.Count);
|
||||
foreach (var item in m_list)
|
||||
{
|
||||
result.m_list.Add(new DictionaryExpressionDataPair(item.Key, item.Value?.Clone()));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override JToken ToJToken()
|
||||
{
|
||||
var json = new JObject();
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var item in m_list)
|
||||
{
|
||||
json.Add(item.Key, item.Value?.ToJToken() ?? JValue.CreateNull());
|
||||
}
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
public Boolean ContainsKey(String key)
|
||||
{
|
||||
return TryGetValue(key, out _);
|
||||
}
|
||||
|
||||
public IEnumerator<KeyValuePair<String, ExpressionData>> GetEnumerator()
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IReadOnlyObject.GetEnumerator()
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return new KeyValuePair<String, Object>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Boolean TryGetValue(
|
||||
String key,
|
||||
out ExpressionData value)
|
||||
{
|
||||
if (m_list?.Count > 0 &&
|
||||
IndexLookup.TryGetValue(key, out var index))
|
||||
{
|
||||
value = m_list[index].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
Boolean IReadOnlyObject.TryGetValue(
|
||||
String key,
|
||||
out Object value)
|
||||
{
|
||||
if (TryGetValue(key, out ExpressionData data))
|
||||
{
|
||||
value = data;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
[OnSerializing]
|
||||
private void OnSerializing(StreamingContext context)
|
||||
{
|
||||
if (m_list?.Count == 0)
|
||||
{
|
||||
m_list = null;
|
||||
}
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
private sealed class DictionaryExpressionDataPair
|
||||
{
|
||||
public DictionaryExpressionDataPair(
|
||||
String key,
|
||||
ExpressionData value)
|
||||
{
|
||||
Key = key;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
[DataMember(Name = "k")]
|
||||
public readonly String Key;
|
||||
|
||||
[DataMember(Name = "v")]
|
||||
public readonly ExpressionData Value;
|
||||
}
|
||||
|
||||
private Dictionary<String, Int32> m_indexLookup;
|
||||
|
||||
[DataMember(Name = "d", EmitDefaultValue = false)]
|
||||
private List<DictionaryExpressionDataPair> m_list;
|
||||
}
|
||||
}
|
||||
289
src/Sdk/Expressions/Data/DictionaryExpressionData.cs
Normal file
289
src/Sdk/Expressions/Data/DictionaryExpressionData.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
[DataContract]
|
||||
[JsonObject]
|
||||
public class DictionaryExpressionData : ExpressionData, IEnumerable<KeyValuePair<String, ExpressionData>>, IReadOnlyObject
|
||||
{
|
||||
public DictionaryExpressionData()
|
||||
: base(ExpressionDataType.Dictionary)
|
||||
{
|
||||
}
|
||||
|
||||
[IgnoreDataMember]
|
||||
public Int32 Count => m_list?.Count ?? 0;
|
||||
|
||||
[IgnoreDataMember]
|
||||
public IEnumerable<String> Keys
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return pair.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[IgnoreDataMember]
|
||||
public IEnumerable<ExpressionData> Values
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<Object> IReadOnlyObject.Values
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return pair.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<String, Int32> IndexLookup
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_indexLookup == null)
|
||||
{
|
||||
m_indexLookup = new Dictionary<String, Int32>(StringComparer.OrdinalIgnoreCase);
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
for (var i = 0; i < m_list.Count; i++)
|
||||
{
|
||||
var pair = m_list[i];
|
||||
m_indexLookup.Add(pair.Key, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m_indexLookup;
|
||||
}
|
||||
}
|
||||
|
||||
private List<DictionaryExpressionDataPair> List
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_list == null)
|
||||
{
|
||||
m_list = new List<DictionaryExpressionDataPair>();
|
||||
}
|
||||
|
||||
return m_list;
|
||||
}
|
||||
}
|
||||
|
||||
public ExpressionData this[String key]
|
||||
{
|
||||
get
|
||||
{
|
||||
var index = IndexLookup[key];
|
||||
return m_list[index].Value;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
// Existing
|
||||
if (IndexLookup.TryGetValue(key, out var index))
|
||||
{
|
||||
key = m_list[index].Key; // preserve casing
|
||||
m_list[index] = new DictionaryExpressionDataPair(key, value);
|
||||
}
|
||||
// New
|
||||
else
|
||||
{
|
||||
Add(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object IReadOnlyObject.this[String key]
|
||||
{
|
||||
get
|
||||
{
|
||||
var index = IndexLookup[key];
|
||||
return m_list[index].Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal KeyValuePair<String, ExpressionData> this[Int32 index]
|
||||
{
|
||||
get
|
||||
{
|
||||
var pair = m_list[index];
|
||||
return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(IEnumerable<KeyValuePair<String, ExpressionData>> pairs)
|
||||
{
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
Add(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(
|
||||
String key,
|
||||
ExpressionData value)
|
||||
{
|
||||
IndexLookup.Add(key, m_list?.Count ?? 0);
|
||||
List.Add(new DictionaryExpressionDataPair(key, value));
|
||||
}
|
||||
|
||||
public override ExpressionData Clone()
|
||||
{
|
||||
var result = new DictionaryExpressionData();
|
||||
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
result.m_list = new List<DictionaryExpressionDataPair>(m_list.Count);
|
||||
foreach (var item in m_list)
|
||||
{
|
||||
result.m_list.Add(new DictionaryExpressionDataPair(item.Key, item.Value?.Clone()));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override JToken ToJToken()
|
||||
{
|
||||
var json = new JObject();
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var item in m_list)
|
||||
{
|
||||
json.Add(item.Key, item.Value?.ToJToken() ?? JValue.CreateNull());
|
||||
}
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
public Boolean ContainsKey(String key)
|
||||
{
|
||||
return TryGetValue(key, out _);
|
||||
}
|
||||
|
||||
public IEnumerator<KeyValuePair<String, ExpressionData>> GetEnumerator()
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return new KeyValuePair<String, ExpressionData>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IReadOnlyObject.GetEnumerator()
|
||||
{
|
||||
if (m_list?.Count > 0)
|
||||
{
|
||||
foreach (var pair in m_list)
|
||||
{
|
||||
yield return new KeyValuePair<String, Object>(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Boolean TryGetValue(
|
||||
String key,
|
||||
out ExpressionData value)
|
||||
{
|
||||
if (m_list?.Count > 0 &&
|
||||
IndexLookup.TryGetValue(key, out var index))
|
||||
{
|
||||
value = m_list[index].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
Boolean IReadOnlyObject.TryGetValue(
|
||||
String key,
|
||||
out Object value)
|
||||
{
|
||||
if (TryGetValue(key, out ExpressionData data))
|
||||
{
|
||||
value = data;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
[OnSerializing]
|
||||
private void OnSerializing(StreamingContext context)
|
||||
{
|
||||
if (m_list?.Count == 0)
|
||||
{
|
||||
m_list = null;
|
||||
}
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
private sealed class DictionaryExpressionDataPair
|
||||
{
|
||||
public DictionaryExpressionDataPair(
|
||||
String key,
|
||||
ExpressionData value)
|
||||
{
|
||||
Key = key;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
[DataMember(Name = "k")]
|
||||
public readonly String Key;
|
||||
|
||||
[DataMember(Name = "v")]
|
||||
public readonly ExpressionData Value;
|
||||
}
|
||||
|
||||
private Dictionary<String, Int32> m_indexLookup;
|
||||
|
||||
[DataMember(Name = "d", EmitDefaultValue = false)]
|
||||
private List<DictionaryExpressionDataPair> m_list;
|
||||
}
|
||||
}
|
||||
27
src/Sdk/Expressions/Data/ExpressionData.cs
Normal file
27
src/Sdk/Expressions/Data/ExpressionData.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for all template tokens
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
[JsonConverter(typeof(ExpressionDataJsonConverter))]
|
||||
public abstract class ExpressionData
|
||||
{
|
||||
protected ExpressionData(Int32 type)
|
||||
{
|
||||
Type = type;
|
||||
}
|
||||
|
||||
[DataMember(Name = "t", EmitDefaultValue = false)]
|
||||
internal Int32 Type { get; }
|
||||
|
||||
public abstract ExpressionData Clone();
|
||||
|
||||
public abstract JToken ToJToken();
|
||||
}
|
||||
}
|
||||
156
src/Sdk/Expressions/Data/ExpressionDataExtensions.cs
Normal file
156
src/Sdk/Expressions/Data/ExpressionDataExtensions.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
public static class ExpressionDataExtensions
|
||||
{
|
||||
public static ArrayExpressionData AssertArray(
|
||||
this ExpressionData value,
|
||||
String objectDescription)
|
||||
{
|
||||
if (value is ArrayExpressionData array)
|
||||
{
|
||||
return array;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(ArrayExpressionData)}' was expected.");
|
||||
}
|
||||
|
||||
public static DictionaryExpressionData AssertDictionary(
|
||||
this ExpressionData value,
|
||||
String objectDescription)
|
||||
{
|
||||
if (value is DictionaryExpressionData dictionary)
|
||||
{
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(DictionaryExpressionData)}' was expected.");
|
||||
}
|
||||
|
||||
public static StringExpressionData AssertString(
|
||||
this ExpressionData value,
|
||||
String objectDescription)
|
||||
{
|
||||
if (value is StringExpressionData str)
|
||||
{
|
||||
return str;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(StringExpressionData)}' was expected.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all context data objects (depth first)
|
||||
/// </summary>
|
||||
public static IEnumerable<ExpressionData> Traverse(this ExpressionData value)
|
||||
{
|
||||
return Traverse(value, omitKeys: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all context data objects (depth first)
|
||||
/// </summary>
|
||||
/// <param name="omitKeys">If true, dictionary keys are omitted</param>
|
||||
public static IEnumerable<ExpressionData> Traverse(
|
||||
this ExpressionData value,
|
||||
Boolean omitKeys)
|
||||
{
|
||||
yield return value;
|
||||
|
||||
if (value is ArrayExpressionData || value is DictionaryExpressionData)
|
||||
{
|
||||
var state = new TraversalState(null, value);
|
||||
while (state != null)
|
||||
{
|
||||
if (state.MoveNext(omitKeys))
|
||||
{
|
||||
value = state.Current;
|
||||
yield return value;
|
||||
|
||||
if (value is ArrayExpressionData || value is DictionaryExpressionData)
|
||||
{
|
||||
state = new TraversalState(state, value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
state = state.Parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TraversalState
|
||||
{
|
||||
public TraversalState(
|
||||
TraversalState parent,
|
||||
ExpressionData data)
|
||||
{
|
||||
Parent = parent;
|
||||
m_data = data;
|
||||
}
|
||||
|
||||
public Boolean MoveNext(Boolean omitKeys)
|
||||
{
|
||||
switch (m_data.Type)
|
||||
{
|
||||
case ExpressionDataType.Array:
|
||||
var array = m_data.AssertArray("array");
|
||||
if (++m_index < array.Count)
|
||||
{
|
||||
Current = array[m_index];
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
case ExpressionDataType.Dictionary:
|
||||
var dictionary = m_data.AssertDictionary("dictionary");
|
||||
|
||||
// Return the value
|
||||
if (m_isKey)
|
||||
{
|
||||
m_isKey = false;
|
||||
Current = dictionary[m_index].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (++m_index < dictionary.Count)
|
||||
{
|
||||
// Skip the key, return the value
|
||||
if (omitKeys)
|
||||
{
|
||||
m_isKey = false;
|
||||
Current = dictionary[m_index].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Return the key
|
||||
m_isKey = true;
|
||||
Current = new StringExpressionData(dictionary[m_index].Key);
|
||||
return true;
|
||||
}
|
||||
|
||||
Current = null;
|
||||
return false;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected {nameof(ExpressionData)} type '{m_data.Type}'");
|
||||
}
|
||||
}
|
||||
|
||||
private ExpressionData m_data;
|
||||
private Int32 m_index = -1;
|
||||
private Boolean m_isKey;
|
||||
public ExpressionData Current;
|
||||
public TraversalState Parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/Sdk/Expressions/Data/ExpressionDataJsonConverter.cs
Normal file
199
src/Sdk/Expressions/Data/ExpressionDataJsonConverter.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON serializer for ExpressionData objects
|
||||
/// </summary>
|
||||
internal sealed class ExpressionDataJsonConverter : JsonConverter
|
||||
{
|
||||
public override Boolean CanWrite
|
||||
{
|
||||
get
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public override Boolean CanConvert(Type objectType)
|
||||
{
|
||||
return typeof(ExpressionData).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
|
||||
}
|
||||
|
||||
public override Object ReadJson(
|
||||
JsonReader reader,
|
||||
Type objectType,
|
||||
Object existingValue,
|
||||
JsonSerializer serializer)
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonToken.String:
|
||||
return new StringExpressionData(reader.Value.ToString());
|
||||
|
||||
case JsonToken.Boolean:
|
||||
return new BooleanExpressionData((Boolean)reader.Value);
|
||||
|
||||
case JsonToken.Float:
|
||||
return new NumberExpressionData((Double)reader.Value);
|
||||
|
||||
case JsonToken.Integer:
|
||||
return new NumberExpressionData((Double)(Int64)reader.Value);
|
||||
|
||||
case JsonToken.StartObject:
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
Int32? type = null;
|
||||
JObject value = JObject.Load(reader);
|
||||
if (!value.TryGetValue("t", StringComparison.OrdinalIgnoreCase, out JToken typeValue))
|
||||
{
|
||||
type = ExpressionDataType.String;
|
||||
}
|
||||
else if (typeValue.Type == JTokenType.Integer)
|
||||
{
|
||||
type = (Int32)typeValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
return existingValue;
|
||||
}
|
||||
|
||||
Object newValue = null;
|
||||
switch (type)
|
||||
{
|
||||
case ExpressionDataType.String:
|
||||
newValue = new StringExpressionData(null);
|
||||
break;
|
||||
|
||||
case ExpressionDataType.Array:
|
||||
newValue = new ArrayExpressionData();
|
||||
break;
|
||||
|
||||
case ExpressionDataType.Dictionary:
|
||||
newValue = new DictionaryExpressionData();
|
||||
break;
|
||||
|
||||
case ExpressionDataType.Boolean:
|
||||
newValue = new BooleanExpressionData(false);
|
||||
break;
|
||||
|
||||
case ExpressionDataType.Number:
|
||||
newValue = new NumberExpressionData(0);
|
||||
break;
|
||||
|
||||
case ExpressionDataType.CaseSensitiveDictionary:
|
||||
newValue = new CaseSensitiveDictionaryExpressionData();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected {nameof(ExpressionDataType)} '{type}'");
|
||||
}
|
||||
|
||||
if (value != null)
|
||||
{
|
||||
using JsonReader objectReader = value.CreateReader();
|
||||
serializer.Populate(objectReader, newValue);
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
|
||||
public override void WriteJson(
|
||||
JsonWriter writer,
|
||||
Object value,
|
||||
JsonSerializer serializer)
|
||||
{
|
||||
if (Object.ReferenceEquals(value, null))
|
||||
{
|
||||
writer.WriteNull();
|
||||
}
|
||||
else if (value is StringExpressionData stringData)
|
||||
{
|
||||
writer.WriteValue(stringData.Value);
|
||||
}
|
||||
else if (value is BooleanExpressionData boolData)
|
||||
{
|
||||
writer.WriteValue(boolData.Value);
|
||||
}
|
||||
else if (value is NumberExpressionData numberData)
|
||||
{
|
||||
writer.WriteValue(numberData.Value);
|
||||
}
|
||||
else if (value is ArrayExpressionData arrayData)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("t");
|
||||
writer.WriteValue(ExpressionDataType.Array);
|
||||
if (arrayData.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("a");
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in arrayData)
|
||||
{
|
||||
serializer.Serialize(writer, item);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
else if (value is DictionaryExpressionData dictionaryData)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("t");
|
||||
writer.WriteValue(ExpressionDataType.Dictionary);
|
||||
if (dictionaryData.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("d");
|
||||
writer.WriteStartArray();
|
||||
foreach (var pair in dictionaryData)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("k");
|
||||
writer.WriteValue(pair.Key);
|
||||
writer.WritePropertyName("v");
|
||||
serializer.Serialize(writer, pair.Value);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
else if (value is CaseSensitiveDictionaryExpressionData caseSensitiveDictionaryData)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("t");
|
||||
writer.WriteValue(ExpressionDataType.CaseSensitiveDictionary);
|
||||
if (caseSensitiveDictionaryData.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("d");
|
||||
writer.WriteStartArray();
|
||||
foreach (var pair in caseSensitiveDictionaryData)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("k");
|
||||
writer.WriteValue(pair.Key);
|
||||
writer.WritePropertyName("v");
|
||||
serializer.Serialize(writer, pair.Value);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Unexpected type '{value.GetType().Name}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/Sdk/Expressions/Data/ExpressionDataType.cs
Normal file
19
src/Sdk/Expressions/Data/ExpressionDataType.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
internal static class ExpressionDataType
|
||||
{
|
||||
internal const Int32 String = 0;
|
||||
|
||||
internal const Int32 Array = 1;
|
||||
|
||||
internal const Int32 Dictionary = 2;
|
||||
|
||||
internal const Int32 Boolean = 3;
|
||||
|
||||
internal const Int32 Number = 4;
|
||||
|
||||
internal const Int32 CaseSensitiveDictionary = 5;
|
||||
}
|
||||
}
|
||||
64
src/Sdk/Expressions/Data/JTokenExtensions.cs
Normal file
64
src/Sdk/Expressions/Data/JTokenExtensions.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
public static class JTokenExtensions
|
||||
{
|
||||
public static ExpressionData ToExpressionData(this JToken value)
|
||||
{
|
||||
return value.ToExpressionData(1, 100);
|
||||
}
|
||||
|
||||
public static ExpressionData ToExpressionData(
|
||||
this JToken value,
|
||||
Int32 depth,
|
||||
Int32 maxDepth)
|
||||
{
|
||||
if (depth < maxDepth)
|
||||
{
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
return new StringExpressionData((String)value);
|
||||
}
|
||||
else if (value.Type == JTokenType.Boolean)
|
||||
{
|
||||
return new BooleanExpressionData((Boolean)value);
|
||||
}
|
||||
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
|
||||
{
|
||||
return new NumberExpressionData((Double)value);
|
||||
}
|
||||
else if (value.Type == JTokenType.Object)
|
||||
{
|
||||
var subContext = new DictionaryExpressionData();
|
||||
var obj = (JObject)value;
|
||||
foreach (var property in obj.Properties())
|
||||
{
|
||||
subContext[property.Name] = ToExpressionData(property.Value, depth + 1, maxDepth);
|
||||
}
|
||||
return subContext;
|
||||
}
|
||||
else if (value.Type == JTokenType.Array)
|
||||
{
|
||||
var arrayContext = new ArrayExpressionData();
|
||||
var arr = (JArray)value;
|
||||
foreach (var element in arr)
|
||||
{
|
||||
arrayContext.Add(ToExpressionData(element, depth + 1, maxDepth));
|
||||
}
|
||||
return arrayContext;
|
||||
}
|
||||
else if (value.Type == JTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// We don't understand the type or have reached our max, return as string
|
||||
return new StringExpressionData(value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/Sdk/Expressions/Data/NumberExpressionData.cs
Normal file
78
src/Sdk/Expressions/Data/NumberExpressionData.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
[DataContract]
|
||||
public sealed class NumberExpressionData : ExpressionData, INumber
|
||||
{
|
||||
public NumberExpressionData(Double value)
|
||||
: base(ExpressionDataType.Number)
|
||||
{
|
||||
m_value = value;
|
||||
}
|
||||
|
||||
public Double Value
|
||||
{
|
||||
get
|
||||
{
|
||||
return m_value;
|
||||
}
|
||||
}
|
||||
|
||||
public override ExpressionData Clone()
|
||||
{
|
||||
return new NumberExpressionData(m_value);
|
||||
}
|
||||
|
||||
public override JToken ToJToken()
|
||||
{
|
||||
if (Double.IsNaN(m_value) || m_value == Double.PositiveInfinity || m_value == Double.NegativeInfinity)
|
||||
{
|
||||
return (JToken)m_value;
|
||||
}
|
||||
|
||||
var floored = Math.Floor(m_value);
|
||||
if (m_value == floored && m_value <= (Double)Int32.MaxValue && m_value >= (Double)Int32.MinValue)
|
||||
{
|
||||
var flooredInt = (Int32)floored;
|
||||
return (JToken)flooredInt;
|
||||
}
|
||||
else if (m_value == floored && m_value <= (Double)Int64.MaxValue && m_value >= (Double)Int64.MinValue)
|
||||
{
|
||||
var flooredInt = (Int64)floored;
|
||||
return (JToken)flooredInt;
|
||||
}
|
||||
else
|
||||
{
|
||||
return (JToken)m_value;
|
||||
}
|
||||
}
|
||||
|
||||
public override String ToString()
|
||||
{
|
||||
return m_value.ToString("G15", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
Double INumber.GetNumber()
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
public static implicit operator Double(NumberExpressionData data)
|
||||
{
|
||||
return data.Value;
|
||||
}
|
||||
|
||||
public static implicit operator NumberExpressionData(Double data)
|
||||
{
|
||||
return new NumberExpressionData(data);
|
||||
}
|
||||
|
||||
[DataMember(Name = "n", EmitDefaultValue = false)]
|
||||
private Double m_value;
|
||||
}
|
||||
}
|
||||
74
src/Sdk/Expressions/Data/StringExpressionData.cs
Normal file
74
src/Sdk/Expressions/Data/StringExpressionData.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Data
|
||||
{
|
||||
[DataContract]
|
||||
public sealed class StringExpressionData : ExpressionData, IString
|
||||
{
|
||||
public StringExpressionData(String value)
|
||||
: base(ExpressionDataType.String)
|
||||
{
|
||||
m_value = value;
|
||||
}
|
||||
|
||||
public String Value
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_value == null)
|
||||
{
|
||||
m_value = String.Empty;
|
||||
}
|
||||
|
||||
return m_value;
|
||||
}
|
||||
}
|
||||
|
||||
public override ExpressionData Clone()
|
||||
{
|
||||
return new StringExpressionData(m_value);
|
||||
}
|
||||
|
||||
public override JToken ToJToken()
|
||||
{
|
||||
return (JToken)m_value;
|
||||
}
|
||||
|
||||
String IString.GetString()
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
public override String ToString()
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
public static implicit operator String(StringExpressionData data)
|
||||
{
|
||||
return data.Value;
|
||||
}
|
||||
|
||||
public static implicit operator StringExpressionData(String data)
|
||||
{
|
||||
return new StringExpressionData(data);
|
||||
}
|
||||
|
||||
[OnSerializing]
|
||||
private void OnSerializing(StreamingContext context)
|
||||
{
|
||||
if (m_value?.Length == 0)
|
||||
{
|
||||
m_value = null;
|
||||
}
|
||||
}
|
||||
|
||||
[DataMember(Name = "s", EmitDefaultValue = false)]
|
||||
private String m_value;
|
||||
}
|
||||
}
|
||||
50
src/Sdk/Expressions/EvaluationOptions.cs
Normal file
50
src/Sdk/Expressions/EvaluationOptions.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public sealed class EvaluationOptions
|
||||
{
|
||||
public EvaluationOptions()
|
||||
{
|
||||
}
|
||||
|
||||
public EvaluationOptions(EvaluationOptions copy)
|
||||
{
|
||||
if (copy != null)
|
||||
{
|
||||
MaxMemory = copy.MaxMemory;
|
||||
MaxCacheMemory = copy.MaxCacheMemory;
|
||||
StrictJsonParsing = copy.StrictJsonParsing;
|
||||
AlwaysTraceExpanded = copy.AlwaysTraceExpanded;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum memory (in bytes) allowed during expression evaluation.
|
||||
/// Memory is tracked across the entire expression tree evaluation to protect against DOS attacks.
|
||||
/// Default is 1 MB (1048576 bytes) if not specified.
|
||||
/// </summary>
|
||||
public Int32 MaxMemory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum memory (in bytes) allowed for caching expanded expression results during tracing.
|
||||
/// When exceeded, the cache is cleared and expressions may not be fully expanded in trace output.
|
||||
/// Default is 1 MB (1048576 bytes) if not specified.
|
||||
/// </summary>
|
||||
public Int32 MaxCacheMemory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce strict JSON parsing in the fromJson function.
|
||||
/// When true, rejects JSON with comments, trailing commas, single quotes, and other non-standard features.
|
||||
/// Default is false if not specified.
|
||||
/// </summary>
|
||||
public Boolean StrictJsonParsing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to always include the expanded expression in trace output.
|
||||
/// When true, the expanded expression is always traced even if it matches the original expression or result.
|
||||
/// Default is false if not specified.
|
||||
/// </summary>
|
||||
public Boolean AlwaysTraceExpanded { get; set; }
|
||||
}
|
||||
}
|
||||
459
src/Sdk/Expressions/EvaluationResult.cs
Normal file
459
src/Sdk/Expressions/EvaluationResult.cs
Normal file
@@ -0,0 +1,459 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public sealed class EvaluationResult
|
||||
{
|
||||
internal EvaluationResult(
|
||||
EvaluationContext context,
|
||||
Int32 level,
|
||||
Object val,
|
||||
ValueKind kind,
|
||||
Object raw)
|
||||
: this(context, level, val, kind, raw, false)
|
||||
{
|
||||
}
|
||||
|
||||
internal EvaluationResult(
|
||||
EvaluationContext context,
|
||||
Int32 level,
|
||||
Object val,
|
||||
ValueKind kind,
|
||||
Object raw,
|
||||
Boolean omitTracing)
|
||||
{
|
||||
m_level = level;
|
||||
Value = val;
|
||||
Kind = kind;
|
||||
Raw = raw;
|
||||
m_omitTracing = omitTracing;
|
||||
|
||||
if (!omitTracing)
|
||||
{
|
||||
TraceValue(context);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When an interface converter is applied to the node result, raw contains the original value
|
||||
/// </summary>
|
||||
public Object Raw { get; }
|
||||
|
||||
public Object Value { get; }
|
||||
|
||||
public Boolean IsFalsy
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Kind)
|
||||
{
|
||||
case ValueKind.Null:
|
||||
return true;
|
||||
case ValueKind.Boolean:
|
||||
var boolean = (Boolean)Value;
|
||||
return !boolean;
|
||||
case ValueKind.Number:
|
||||
var number = (Double)Value;
|
||||
return number == 0d || Double.IsNaN(number);
|
||||
case ValueKind.String:
|
||||
var str = (String)Value;
|
||||
return String.Equals(str, String.Empty, StringComparison.Ordinal);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Boolean IsPrimitive => ExpressionUtility.IsPrimitive(Kind);
|
||||
|
||||
public Boolean IsTruthy => !IsFalsy;
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
public Boolean AbstractEqual(EvaluationResult right)
|
||||
{
|
||||
return AbstractEqual(Value, right.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
public Boolean AbstractGreaterThan(EvaluationResult right)
|
||||
{
|
||||
return AbstractGreaterThan(Value, right.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
public Boolean AbstractGreaterThanOrEqual(EvaluationResult right)
|
||||
{
|
||||
return AbstractEqual(Value, right.Value) || AbstractGreaterThan(Value, right.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
public Boolean AbstractLessThan(EvaluationResult right)
|
||||
{
|
||||
return AbstractLessThan(Value, right.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
public Boolean AbstractLessThanOrEqual(EvaluationResult right)
|
||||
{
|
||||
return AbstractEqual(Value, right.Value) || AbstractLessThan(Value, right.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
public Boolean AbstractNotEqual(EvaluationResult right)
|
||||
{
|
||||
return !AbstractEqual(Value, right.Value);
|
||||
}
|
||||
|
||||
public Double ConvertToNumber()
|
||||
{
|
||||
return ConvertToNumber(Value);
|
||||
}
|
||||
|
||||
public String ConvertToString()
|
||||
{
|
||||
switch (Kind)
|
||||
{
|
||||
case ValueKind.Null:
|
||||
return String.Empty;
|
||||
|
||||
case ValueKind.Boolean:
|
||||
return ((Boolean)Value) ? ExpressionConstants.True : ExpressionConstants.False;
|
||||
|
||||
case ValueKind.Number:
|
||||
if ((Double)Value == -0)
|
||||
{
|
||||
// .NET Core 3.0 now prints negative zero as -0, so we need this to keep out behavior consistent
|
||||
return ((Double)0).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture);
|
||||
}
|
||||
return ((Double)Value).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
case ValueKind.String:
|
||||
return Value as String;
|
||||
|
||||
default:
|
||||
return Kind.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public Boolean TryGetCollectionInterface(out Object collection)
|
||||
{
|
||||
if ((Kind == ValueKind.Object || Kind == ValueKind.Array))
|
||||
{
|
||||
var obj = Value;
|
||||
if (obj is IReadOnlyObject)
|
||||
{
|
||||
collection = obj;
|
||||
return true;
|
||||
}
|
||||
else if (obj is IReadOnlyArray)
|
||||
{
|
||||
collection = obj;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
collection = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Useful for working with values that are not the direct evaluation result of a parameter.
|
||||
/// This allows ExpressionNode authors to leverage the coercion and comparison functions
|
||||
/// for any values.
|
||||
///
|
||||
/// Also note, the value will be canonicalized (for example numeric types converted to double) and any
|
||||
/// matching interfaces applied.
|
||||
/// </summary>
|
||||
public static EvaluationResult CreateIntermediateResult(
|
||||
EvaluationContext context,
|
||||
Object obj)
|
||||
{
|
||||
var val = ExpressionUtility.ConvertToCanonicalValue(obj, out ValueKind kind, out Object raw);
|
||||
return new EvaluationResult(context, 0, val, kind, raw, omitTracing: true);
|
||||
}
|
||||
|
||||
private void TraceValue(EvaluationContext context)
|
||||
{
|
||||
if (!m_omitTracing)
|
||||
{
|
||||
TraceValue(context, Value, Kind);
|
||||
}
|
||||
}
|
||||
|
||||
private void TraceValue(
|
||||
EvaluationContext context,
|
||||
Object val,
|
||||
ValueKind kind)
|
||||
{
|
||||
if (!m_omitTracing)
|
||||
{
|
||||
TraceVerbose(context, String.Concat("=> ", ExpressionUtility.FormatValue(context?.SecretMasker, val, kind)));
|
||||
}
|
||||
}
|
||||
|
||||
private void TraceVerbose(
|
||||
EvaluationContext context,
|
||||
String message)
|
||||
{
|
||||
if (!m_omitTracing)
|
||||
{
|
||||
context?.Trace.Verbose(String.Empty.PadLeft(m_level * 2, '.') + (message ?? String.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
private static Boolean AbstractEqual(
|
||||
Object canonicalLeftValue,
|
||||
Object canonicalRightValue)
|
||||
{
|
||||
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind);
|
||||
|
||||
// Same kind
|
||||
if (leftKind == rightKind)
|
||||
{
|
||||
switch (leftKind)
|
||||
{
|
||||
// Null, Null
|
||||
case ValueKind.Null:
|
||||
return true;
|
||||
|
||||
// Number, Number
|
||||
case ValueKind.Number:
|
||||
var leftDouble = (Double)canonicalLeftValue;
|
||||
var rightDouble = (Double)canonicalRightValue;
|
||||
if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return leftDouble == rightDouble;
|
||||
|
||||
// String, String
|
||||
case ValueKind.String:
|
||||
var leftString = (String)canonicalLeftValue;
|
||||
var rightString = (String)canonicalRightValue;
|
||||
return String.Equals(leftString, rightString, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Boolean, Boolean
|
||||
case ValueKind.Boolean:
|
||||
var leftBoolean = (Boolean)canonicalLeftValue;
|
||||
var rightBoolean = (Boolean)canonicalRightValue;
|
||||
return leftBoolean == rightBoolean;
|
||||
|
||||
// Object, Object
|
||||
case ValueKind.Object:
|
||||
case ValueKind.Array:
|
||||
return Object.ReferenceEquals(canonicalLeftValue, canonicalRightValue);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
private static Boolean AbstractGreaterThan(
|
||||
Object canonicalLeftValue,
|
||||
Object canonicalRightValue)
|
||||
{
|
||||
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind);
|
||||
|
||||
// Same kind
|
||||
if (leftKind == rightKind)
|
||||
{
|
||||
switch (leftKind)
|
||||
{
|
||||
// Number, Number
|
||||
case ValueKind.Number:
|
||||
var leftDouble = (Double)canonicalLeftValue;
|
||||
var rightDouble = (Double)canonicalRightValue;
|
||||
if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return leftDouble > rightDouble;
|
||||
|
||||
// String, String
|
||||
case ValueKind.String:
|
||||
var leftString = (String)canonicalLeftValue;
|
||||
var rightString = (String)canonicalRightValue;
|
||||
return String.Compare(leftString, rightString, StringComparison.OrdinalIgnoreCase) > 0;
|
||||
|
||||
// Boolean, Boolean
|
||||
case ValueKind.Boolean:
|
||||
var leftBoolean = (Boolean)canonicalLeftValue;
|
||||
var rightBoolean = (Boolean)canonicalRightValue;
|
||||
return leftBoolean && !rightBoolean;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
|
||||
/// </summary>
|
||||
private static Boolean AbstractLessThan(
|
||||
Object canonicalLeftValue,
|
||||
Object canonicalRightValue)
|
||||
{
|
||||
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind);
|
||||
|
||||
// Same kind
|
||||
if (leftKind == rightKind)
|
||||
{
|
||||
switch (leftKind)
|
||||
{
|
||||
// Number, Number
|
||||
case ValueKind.Number:
|
||||
var leftDouble = (Double)canonicalLeftValue;
|
||||
var rightDouble = (Double)canonicalRightValue;
|
||||
if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return leftDouble < rightDouble;
|
||||
|
||||
// String, String
|
||||
case ValueKind.String:
|
||||
var leftString = (String)canonicalLeftValue;
|
||||
var rightString = (String)canonicalRightValue;
|
||||
return String.Compare(leftString, rightString, StringComparison.OrdinalIgnoreCase) < 0;
|
||||
|
||||
// Boolean, Boolean
|
||||
case ValueKind.Boolean:
|
||||
var leftBoolean = (Boolean)canonicalLeftValue;
|
||||
var rightBoolean = (Boolean)canonicalRightValue;
|
||||
return !leftBoolean && rightBoolean;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
|
||||
/// Except objects are not coerced to primitives.
|
||||
private static void CoerceTypes(
|
||||
ref Object canonicalLeftValue,
|
||||
ref Object canonicalRightValue,
|
||||
out ValueKind leftKind,
|
||||
out ValueKind rightKind)
|
||||
{
|
||||
leftKind = GetKind(canonicalLeftValue);
|
||||
rightKind = GetKind(canonicalRightValue);
|
||||
|
||||
// Same kind
|
||||
if (leftKind == rightKind)
|
||||
{
|
||||
}
|
||||
// Number, String
|
||||
else if (leftKind == ValueKind.Number && rightKind == ValueKind.String)
|
||||
{
|
||||
canonicalRightValue = ConvertToNumber(canonicalRightValue);
|
||||
rightKind = ValueKind.Number;
|
||||
}
|
||||
// String, Number
|
||||
else if (leftKind == ValueKind.String && rightKind == ValueKind.Number)
|
||||
{
|
||||
canonicalLeftValue = ConvertToNumber(canonicalLeftValue);
|
||||
leftKind = ValueKind.Number;
|
||||
}
|
||||
// Boolean|Null, Any
|
||||
else if (leftKind == ValueKind.Boolean || leftKind == ValueKind.Null)
|
||||
{
|
||||
canonicalLeftValue = ConvertToNumber(canonicalLeftValue);
|
||||
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out leftKind, out rightKind);
|
||||
}
|
||||
// Any, Boolean|Null
|
||||
else if (rightKind == ValueKind.Boolean || rightKind == ValueKind.Null)
|
||||
{
|
||||
canonicalRightValue = ConvertToNumber(canonicalRightValue);
|
||||
CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out leftKind, out rightKind);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For primitives, follows the Javascript rules (the Number function in Javascript). Otherwise NaN.
|
||||
/// </summary>
|
||||
private static Double ConvertToNumber(Object canonicalValue)
|
||||
{
|
||||
var kind = GetKind(canonicalValue);
|
||||
switch (kind)
|
||||
{
|
||||
case ValueKind.Null:
|
||||
return 0d;
|
||||
case ValueKind.Boolean:
|
||||
return (Boolean)canonicalValue ? 1d : 0d;
|
||||
case ValueKind.Number:
|
||||
return (Double)canonicalValue;
|
||||
case ValueKind.String:
|
||||
return ExpressionUtility.ParseNumber(canonicalValue as String);
|
||||
}
|
||||
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
private static ValueKind GetKind(Object canonicalValue)
|
||||
{
|
||||
if (Object.ReferenceEquals(canonicalValue, null))
|
||||
{
|
||||
return ValueKind.Null;
|
||||
}
|
||||
else if (canonicalValue is Boolean)
|
||||
{
|
||||
return ValueKind.Boolean;
|
||||
}
|
||||
else if (canonicalValue is Double)
|
||||
{
|
||||
return ValueKind.Number;
|
||||
}
|
||||
else if (canonicalValue is String)
|
||||
{
|
||||
return ValueKind.String;
|
||||
}
|
||||
else if (canonicalValue is IReadOnlyObject)
|
||||
{
|
||||
return ValueKind.Object;
|
||||
}
|
||||
else if (canonicalValue is IReadOnlyArray)
|
||||
{
|
||||
return ValueKind.Array;
|
||||
}
|
||||
|
||||
return ValueKind.Object;
|
||||
}
|
||||
|
||||
private readonly Int32 m_level;
|
||||
private readonly Boolean m_omitTracing;
|
||||
}
|
||||
}
|
||||
62
src/Sdk/Expressions/ExpressionConstants.cs
Normal file
62
src/Sdk/Expressions/ExpressionConstants.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using GitHub.Actions.Expressions.Sdk.Functions;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public static class ExpressionConstants
|
||||
{
|
||||
static ExpressionConstants()
|
||||
{
|
||||
AddFunction<Contains>("contains", 2, 2);
|
||||
AddFunction<EndsWith>("endsWith", 2, 2);
|
||||
AddFunction<Format>("format", 1, Byte.MaxValue);
|
||||
AddFunction<Join>("join", 1, 2);
|
||||
AddFunction<StartsWith>("startsWith", 2, 2);
|
||||
AddFunction<ToJson>("toJson", 1, 1);
|
||||
AddFunction<FromJson>("fromJson", 1, 1);
|
||||
}
|
||||
|
||||
private static void AddFunction<T>(String name, Int32 minParameters, Int32 maxParameters)
|
||||
where T : Function, new()
|
||||
{
|
||||
s_wellKnownFunctions.Add(name, new FunctionInfo<T>(name, minParameters, maxParameters));
|
||||
}
|
||||
|
||||
internal static readonly String False = "false";
|
||||
internal static readonly String Infinity = "Infinity";
|
||||
internal static readonly Int32 MaxDepth = 50;
|
||||
internal static readonly Int32 MaxLength = 21000; // Under 85,000 large object heap threshold, even if .NET switches to UTF-32
|
||||
internal static readonly String NaN = "NaN";
|
||||
internal static readonly String NegativeInfinity = "-Infinity";
|
||||
public static readonly String Null = "null";
|
||||
internal static readonly String NumberFormat = "G15";
|
||||
internal static readonly String True = "true";
|
||||
private static readonly Dictionary<String, IFunctionInfo> s_wellKnownFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
public static readonly IReadOnlyDictionary<String, IFunctionInfo> WellKnownFunctions = new ReadOnlyDictionary<String, IFunctionInfo>(s_wellKnownFunctions);
|
||||
|
||||
// Punctuation
|
||||
internal const Char StartGroup = '('; // logical grouping
|
||||
internal const Char StartIndex = '[';
|
||||
public static readonly Char StartParameter = '('; // function call
|
||||
internal const Char EndGroup = ')'; // logical grouping
|
||||
internal const Char EndIndex = ']';
|
||||
public static readonly Char EndParameter = ')'; // function calll
|
||||
internal const Char Separator = ',';
|
||||
internal const Char Dereference = '.';
|
||||
internal const Char Wildcard = '*';
|
||||
|
||||
// Operators
|
||||
internal const String Not = "!";
|
||||
internal const String NotEqual = "!=";
|
||||
internal const String GreaterThan = ">";
|
||||
internal const String GreaterThanOrEqual = ">=";
|
||||
internal const String LessThan = "<";
|
||||
internal const String LessThanOrEqual = "<=";
|
||||
internal const String Equal = "==";
|
||||
internal const String And = "&&";
|
||||
internal const String Or = "||";
|
||||
}
|
||||
}
|
||||
21
src/Sdk/Expressions/ExpressionException.cs
Normal file
21
src/Sdk/Expressions/ExpressionException.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public class ExpressionException : Exception
|
||||
{
|
||||
internal ExpressionException(ISecretMasker secretMasker, String message)
|
||||
{
|
||||
if (secretMasker != null)
|
||||
{
|
||||
message = secretMasker.MaskSecrets(message);
|
||||
}
|
||||
|
||||
m_message = message;
|
||||
}
|
||||
|
||||
public override String Message => m_message;
|
||||
|
||||
private readonly String m_message;
|
||||
}
|
||||
}
|
||||
471
src/Sdk/Expressions/ExpressionParser.cs
Normal file
471
src/Sdk/Expressions/ExpressionParser.cs
Normal file
@@ -0,0 +1,471 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.Actions.Expressions.Sdk.Operators;
|
||||
using GitHub.Actions.Expressions.Tokens;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using GitHub.Actions.Expressions.Sdk.Functions;
|
||||
|
||||
public sealed class ExpressionParser
|
||||
{
|
||||
public IExpressionNode CreateTree(
|
||||
String expression,
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions)
|
||||
{
|
||||
var context = new ParseContext(expression, trace, namedValues, functions);
|
||||
context.Trace.Info($"Parsing expression: <{expression}>");
|
||||
return CreateTree(context);
|
||||
}
|
||||
|
||||
public IExpressionNode ValidateSyntax(
|
||||
String expression,
|
||||
ITraceWriter trace)
|
||||
{
|
||||
var context = new ParseContext(expression, trace, namedValues: null, functions: null, allowUnknownKeywords: true);
|
||||
context.Trace.Info($"Validating expression syntax: <{expression}>");
|
||||
return CreateTree(context);
|
||||
}
|
||||
|
||||
private static IExpressionNode CreateTree(ParseContext context)
|
||||
{
|
||||
// Push the tokens
|
||||
while (context.LexicalAnalyzer.TryGetNextToken(ref context.Token))
|
||||
{
|
||||
// Unexpected
|
||||
if (context.Token.Kind == TokenKind.Unexpected)
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.UnexpectedSymbol, context.Token, context.Expression);
|
||||
}
|
||||
// Operator
|
||||
else if (context.Token.IsOperator)
|
||||
{
|
||||
PushOperator(context);
|
||||
}
|
||||
// Operand
|
||||
else
|
||||
{
|
||||
PushOperand(context);
|
||||
}
|
||||
|
||||
context.LastToken = context.Token;
|
||||
}
|
||||
|
||||
// No tokens
|
||||
if (context.LastToken == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check unexpected end of expression
|
||||
if (context.Operators.Count > 0)
|
||||
{
|
||||
var unexpectedLastToken = false;
|
||||
switch (context.LastToken.Kind)
|
||||
{
|
||||
case TokenKind.EndGroup: // ")" logical grouping
|
||||
case TokenKind.EndIndex: // "]"
|
||||
case TokenKind.EndParameters: // ")" function call
|
||||
// Legal
|
||||
break;
|
||||
case TokenKind.Function:
|
||||
// Illegal
|
||||
unexpectedLastToken = true;
|
||||
break;
|
||||
default:
|
||||
unexpectedLastToken = context.LastToken.IsOperator;
|
||||
break;
|
||||
}
|
||||
|
||||
if (unexpectedLastToken || context.LexicalAnalyzer.UnclosedTokens.Any())
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.UnexpectedEndOfExpression, context.LastToken, context.Expression);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush operators
|
||||
while (context.Operators.Count > 0)
|
||||
{
|
||||
FlushTopOperator(context);
|
||||
}
|
||||
|
||||
// Check max depth
|
||||
var result = context.Operands.Single();
|
||||
CheckMaxDepth(context, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void PushOperand(ParseContext context)
|
||||
{
|
||||
// Create the node
|
||||
var node = default(ExpressionNode);
|
||||
switch (context.Token.Kind)
|
||||
{
|
||||
// Function
|
||||
case TokenKind.Function:
|
||||
var function = context.Token.RawValue;
|
||||
if (TryGetFunctionInfo(context, function, out var functionInfo))
|
||||
{
|
||||
node = functionInfo.CreateNode();
|
||||
node.Name = function;
|
||||
}
|
||||
else if (context.AllowUnknownKeywords)
|
||||
{
|
||||
node = new NoOperation();
|
||||
node.Name = function;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.UnrecognizedFunction, context.Token, context.Expression);
|
||||
}
|
||||
break;
|
||||
|
||||
// Named-value
|
||||
case TokenKind.NamedValue:
|
||||
var name = context.Token.RawValue;
|
||||
if (context.ExtensionNamedValues.TryGetValue(name, out var namedValueInfo))
|
||||
{
|
||||
node = namedValueInfo.CreateNode();
|
||||
node.Name = name;
|
||||
|
||||
}
|
||||
else if (context.AllowUnknownKeywords)
|
||||
{
|
||||
node = new NoOperationNamedValue();
|
||||
node.Name = name;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.UnrecognizedNamedValue, context.Token, context.Expression);
|
||||
}
|
||||
break;
|
||||
|
||||
// Otherwise simple
|
||||
default:
|
||||
node = context.Token.ToNode();
|
||||
break;
|
||||
}
|
||||
|
||||
// Push the operand
|
||||
context.Operands.Push(node);
|
||||
}
|
||||
|
||||
private static void PushOperator(ParseContext context)
|
||||
{
|
||||
// Flush higher or equal precedence
|
||||
if (context.Token.Associativity == Associativity.LeftToRight)
|
||||
{
|
||||
var precedence = context.Token.Precedence;
|
||||
while (context.Operators.Count > 0)
|
||||
{
|
||||
var topOperator = context.Operators.Peek();
|
||||
if (precedence <= topOperator.Precedence &&
|
||||
topOperator.Kind != TokenKind.StartGroup && // Unless top is "(" logical grouping
|
||||
topOperator.Kind != TokenKind.StartIndex && // or unless top is "["
|
||||
topOperator.Kind != TokenKind.StartParameters &&// or unless top is "(" function call
|
||||
topOperator.Kind != TokenKind.Separator) // or unless top is ","
|
||||
{
|
||||
FlushTopOperator(context);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Push the operator
|
||||
context.Operators.Push(context.Token);
|
||||
|
||||
// Process closing operators now, since context.LastToken is required
|
||||
// to accurately process TokenKind.EndParameters
|
||||
switch (context.Token.Kind)
|
||||
{
|
||||
case TokenKind.EndGroup: // ")" logical grouping
|
||||
case TokenKind.EndIndex: // "]"
|
||||
case TokenKind.EndParameters: // ")" function call
|
||||
FlushTopOperator(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlushTopOperator(ParseContext context)
|
||||
{
|
||||
// Special handling for closing operators
|
||||
switch (context.Operators.Peek().Kind)
|
||||
{
|
||||
case TokenKind.EndIndex: // "]"
|
||||
FlushTopEndIndex(context);
|
||||
return;
|
||||
|
||||
case TokenKind.EndGroup: // ")" logical grouping
|
||||
FlushTopEndGroup(context);
|
||||
return;
|
||||
|
||||
case TokenKind.EndParameters: // ")" function call
|
||||
FlushTopEndParameters(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pop the operator
|
||||
var @operator = context.Operators.Pop();
|
||||
|
||||
// Create the node
|
||||
var node = (Container)@operator.ToNode();
|
||||
|
||||
// Pop the operands, add to the node
|
||||
var operands = PopOperands(context, @operator.OperandCount);
|
||||
foreach (var operand in operands)
|
||||
{
|
||||
// Flatten nested And
|
||||
if (node is And)
|
||||
{
|
||||
if (operand is And nestedAnd)
|
||||
{
|
||||
foreach (var nestedParameter in nestedAnd.Parameters)
|
||||
{
|
||||
node.AddParameter(nestedParameter);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Flatten nested Or
|
||||
else if (node is Or)
|
||||
{
|
||||
if (operand is Or nestedOr)
|
||||
{
|
||||
foreach (var nestedParameter in nestedOr.Parameters)
|
||||
{
|
||||
node.AddParameter(nestedParameter);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
node.AddParameter(operand);
|
||||
}
|
||||
|
||||
// Push the node to the operand stack
|
||||
context.Operands.Push(node);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes the ")" logical grouping operator
|
||||
/// </summary>
|
||||
private static void FlushTopEndGroup(ParseContext context)
|
||||
{
|
||||
// Pop the operators
|
||||
PopOperator(context, TokenKind.EndGroup); // ")" logical grouping
|
||||
PopOperator(context, TokenKind.StartGroup); // "(" logical grouping
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes the "]" operator
|
||||
/// </summary>
|
||||
private static void FlushTopEndIndex(ParseContext context)
|
||||
{
|
||||
// Pop the operators
|
||||
PopOperator(context, TokenKind.EndIndex); // "]"
|
||||
var @operator = PopOperator(context, TokenKind.StartIndex); // "["
|
||||
|
||||
// Create the node
|
||||
var node = (Container)@operator.ToNode();
|
||||
|
||||
// Pop the operands, add to the node
|
||||
var operands = PopOperands(context, @operator.OperandCount);
|
||||
foreach (var operand in operands)
|
||||
{
|
||||
node.AddParameter(operand);
|
||||
}
|
||||
|
||||
// Push the node to the operand stack
|
||||
context.Operands.Push(node);
|
||||
}
|
||||
|
||||
// ")" function call
|
||||
private static void FlushTopEndParameters(ParseContext context)
|
||||
{
|
||||
// Pop the operator
|
||||
var @operator = PopOperator(context, TokenKind.EndParameters); // ")" function call
|
||||
|
||||
// Sanity check top operator is the current token
|
||||
if (!Object.ReferenceEquals(@operator, context.Token))
|
||||
{
|
||||
throw new InvalidOperationException("Expected the operator to be the current token");
|
||||
}
|
||||
|
||||
var function = default(Function);
|
||||
|
||||
// No parameters
|
||||
if (context.LastToken.Kind == TokenKind.StartParameters)
|
||||
{
|
||||
// Node already exists on the operand stack
|
||||
function = (Function)context.Operands.Peek();
|
||||
}
|
||||
// Has parameters
|
||||
else
|
||||
{
|
||||
// Pop the operands
|
||||
var parameterCount = 1;
|
||||
while (context.Operators.Peek().Kind == TokenKind.Separator)
|
||||
{
|
||||
parameterCount++;
|
||||
context.Operators.Pop();
|
||||
}
|
||||
var functionOperands = PopOperands(context, parameterCount);
|
||||
|
||||
// Node already exists on the operand stack
|
||||
function = (Function)context.Operands.Peek();
|
||||
|
||||
// Add the operands to the node
|
||||
foreach (var operand in functionOperands)
|
||||
{
|
||||
function.AddParameter(operand);
|
||||
}
|
||||
}
|
||||
|
||||
// Pop the "(" operator too
|
||||
@operator = PopOperator(context, TokenKind.StartParameters);
|
||||
|
||||
// Check min/max parameter count
|
||||
TryGetFunctionInfo(context, function.Name, out var functionInfo);
|
||||
if (functionInfo == null && context.AllowUnknownKeywords)
|
||||
{
|
||||
// Don't check min/max
|
||||
}
|
||||
else if (function.Parameters.Count < functionInfo.MinParameters)
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.TooFewParameters, token: @operator, expression: context.Expression);
|
||||
}
|
||||
else if (function.Parameters.Count > functionInfo.MaxParameters)
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.TooManyParameters, token: @operator, expression: context.Expression);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pops N operands from the operand stack. The operands are returned
|
||||
/// in their natural listed order, i.e. not last-in-first-out.
|
||||
/// </summary>
|
||||
private static List<ExpressionNode> PopOperands(
|
||||
ParseContext context,
|
||||
Int32 count)
|
||||
{
|
||||
var result = new List<ExpressionNode>();
|
||||
while (count-- > 0)
|
||||
{
|
||||
result.Add(context.Operands.Pop());
|
||||
}
|
||||
|
||||
result.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pops an operator and asserts it is the expected kind.
|
||||
/// </summary>
|
||||
private static Token PopOperator(
|
||||
ParseContext context,
|
||||
TokenKind expected)
|
||||
{
|
||||
var token = context.Operators.Pop();
|
||||
if (token.Kind != expected)
|
||||
{
|
||||
throw new NotSupportedException($"Expected operator '{expected}' to be popped. Actual '{token.Kind}'.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the max depth of the expression tree
|
||||
/// </summary>
|
||||
private static void CheckMaxDepth(
|
||||
ParseContext context,
|
||||
ExpressionNode node,
|
||||
Int32 depth = 1)
|
||||
{
|
||||
if (depth > ExpressionConstants.MaxDepth)
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.ExceededMaxDepth, token: null, expression: context.Expression);
|
||||
}
|
||||
|
||||
if (node is Container container)
|
||||
{
|
||||
foreach (var parameter in container.Parameters)
|
||||
{
|
||||
CheckMaxDepth(context, parameter, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Boolean TryGetFunctionInfo(
|
||||
ParseContext context,
|
||||
String name,
|
||||
out IFunctionInfo functionInfo)
|
||||
{
|
||||
return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) ||
|
||||
context.ExtensionFunctions.TryGetValue(name, out functionInfo);
|
||||
}
|
||||
|
||||
private sealed class ParseContext
|
||||
{
|
||||
public Boolean AllowUnknownKeywords;
|
||||
public readonly String Expression;
|
||||
public readonly Dictionary<String, IFunctionInfo> ExtensionFunctions = new Dictionary<String, IFunctionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly Dictionary<String, INamedValueInfo> ExtensionNamedValues = new Dictionary<String, INamedValueInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
public readonly LexicalAnalyzer LexicalAnalyzer;
|
||||
public readonly Stack<ExpressionNode> Operands = new Stack<ExpressionNode>();
|
||||
public readonly Stack<Token> Operators = new Stack<Token>();
|
||||
public readonly ITraceWriter Trace;
|
||||
public Token Token;
|
||||
public Token LastToken;
|
||||
|
||||
public ParseContext(
|
||||
String expression,
|
||||
ITraceWriter trace,
|
||||
IEnumerable<INamedValueInfo> namedValues,
|
||||
IEnumerable<IFunctionInfo> functions,
|
||||
Boolean allowUnknownKeywords = false)
|
||||
{
|
||||
Expression = expression ?? String.Empty;
|
||||
if (Expression.Length > ExpressionConstants.MaxLength)
|
||||
{
|
||||
throw new ParseException(ParseExceptionKind.ExceededMaxLength, token: null, expression: Expression);
|
||||
}
|
||||
|
||||
Trace = trace ?? new NoOperationTraceWriter();
|
||||
foreach (var namedValueInfo in (namedValues ?? new INamedValueInfo[0]))
|
||||
{
|
||||
ExtensionNamedValues.Add(namedValueInfo.Name, namedValueInfo);
|
||||
}
|
||||
|
||||
foreach (var functionInfo in (functions ?? new IFunctionInfo[0]))
|
||||
{
|
||||
ExtensionFunctions.Add(functionInfo.Name, functionInfo);
|
||||
}
|
||||
|
||||
LexicalAnalyzer = new LexicalAnalyzer(Expression);
|
||||
AllowUnknownKeywords = allowUnknownKeywords;
|
||||
}
|
||||
|
||||
private class NoOperationTraceWriter : ITraceWriter
|
||||
{
|
||||
public void Info(String message)
|
||||
{
|
||||
}
|
||||
|
||||
public void Verbose(String message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Sdk/Expressions/FunctionInfo.cs
Normal file
27
src/Sdk/Expressions/FunctionInfo.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public class FunctionInfo<T> : IFunctionInfo
|
||||
where T : Function, new()
|
||||
{
|
||||
public FunctionInfo(String name, Int32 minParameters, Int32 maxParameters)
|
||||
{
|
||||
Name = name;
|
||||
MinParameters = minParameters;
|
||||
MaxParameters = maxParameters;
|
||||
}
|
||||
|
||||
public String Name { get; }
|
||||
|
||||
public Int32 MinParameters { get; }
|
||||
|
||||
public Int32 MaxParameters { get; }
|
||||
|
||||
public Function CreateNode()
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/Sdk/Expressions/IExpressionNode.cs
Normal file
24
src/Sdk/Expressions/IExpressionNode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public interface IExpressionNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates the expression and returns the result, wrapped in a helper
|
||||
/// for converting, comparing, and traversing objects.
|
||||
/// </summary>
|
||||
/// <param name="trace">Optional trace writer</param>
|
||||
/// <param name="secretMasker">Optional secret masker</param>
|
||||
/// <param name="state">State object for custom evaluation function nodes and custom named-value nodes</param>
|
||||
/// <param name="options">Evaluation options</param>
|
||||
EvaluationResult Evaluate(
|
||||
ITraceWriter trace,
|
||||
ISecretMasker? secretMasker,
|
||||
Object state,
|
||||
EvaluationOptions options);
|
||||
}
|
||||
}
|
||||
320
src/Sdk/Expressions/IExpressionNodeExtensions.cs
Normal file
320
src/Sdk/Expressions/IExpressionNodeExtensions.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
using Index = GitHub.Actions.Expressions.Sdk.Operators.Index;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public static class IExpressionNodeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the node and all descendant nodes
|
||||
/// </summary>
|
||||
public static IEnumerable<IExpressionNode> Traverse(this IExpressionNode node)
|
||||
{
|
||||
yield return node;
|
||||
|
||||
if (node is Container container && container.Parameters.Count > 0)
|
||||
{
|
||||
foreach (var parameter in container.Parameters)
|
||||
{
|
||||
foreach (var descendant in parameter.Traverse())
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether specific contexts or sub-properties of contexts are referenced.
|
||||
/// If a conclusive determination cannot be made, then the pattern is considered matched.
|
||||
/// For example, the expression "toJson(github)" matches the pattern "github.event" because
|
||||
/// the value is passed to a function. Not enough information is known to determine whether
|
||||
/// the function requires the sub-property. Therefore, assume it is required.
|
||||
///
|
||||
/// Patterns may contain wildcards to match any literal. For example, the pattern
|
||||
/// "needs.*.outputs" will produce a match for the expression "needs.my-job.outputs.my-output".
|
||||
/// </summary>
|
||||
public static Boolean[] CheckReferencesContext(
|
||||
this IExpressionNode tree,
|
||||
params String[] patterns)
|
||||
{
|
||||
// The result is an array of booleans, one per pattern
|
||||
var result = new Boolean[patterns.Length];
|
||||
|
||||
// Stores the match segments for each pattern. For example
|
||||
// the patterns [ "github.event", "needs.*.outputs" ] would
|
||||
// be stored as:
|
||||
// [
|
||||
// [
|
||||
// NamedValue:github
|
||||
// Literal:"event"
|
||||
// ],
|
||||
// [
|
||||
// NamedValue:needs
|
||||
// Wildcard:*
|
||||
// Literal:"outputs"
|
||||
// ]
|
||||
// ]
|
||||
var segmentedPatterns = default(Stack<IExpressionNode>[]);
|
||||
|
||||
// Walk the expression tree
|
||||
var stack = new Stack<IExpressionNode>();
|
||||
stack.Push(tree);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var node = stack.Pop();
|
||||
|
||||
// Attempt to match a named-value or index operator.
|
||||
// Note, when entering this block, descendant nodes are only pushed
|
||||
// to the stack for further processing under special conditions.
|
||||
if (node is NamedValue || node is Index)
|
||||
{
|
||||
// Lazy initialize the pattern segments
|
||||
if (segmentedPatterns is null)
|
||||
{
|
||||
segmentedPatterns = new Stack<IExpressionNode>[patterns.Length];
|
||||
var parser = new ExpressionParser();
|
||||
for (var i = 0; i < patterns.Length; i++)
|
||||
{
|
||||
var pattern = patterns[i];
|
||||
var patternTree = parser.ValidateSyntax(pattern, null);
|
||||
var patternSegments = GetMatchSegments(patternTree, out _);
|
||||
if (patternSegments.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid context-match-pattern '{pattern}'");
|
||||
}
|
||||
segmentedPatterns[i] = patternSegments;
|
||||
}
|
||||
}
|
||||
|
||||
// Match
|
||||
Match(node, segmentedPatterns, result, out var needsFurtherAnalysis);
|
||||
|
||||
// Push nested nodes that need further analysis
|
||||
if (needsFurtherAnalysis?.Count > 0)
|
||||
{
|
||||
foreach (var nestedNode in needsFurtherAnalysis)
|
||||
{
|
||||
stack.Push(nestedNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Push children of any other container node.
|
||||
else if (node is Container container && container.Parameters.Count > 0)
|
||||
{
|
||||
foreach (var child in container.Parameters)
|
||||
{
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Attempts to match a node within a user-provided-expression against a set of patterns.
|
||||
//
|
||||
// For example consider the user-provided-expression "github.event.base_ref || github.event.before"
|
||||
// The Match method would be called twice, once for the sub-expression "github.event.base_ref" and
|
||||
// once for the sub-expression "github.event.before".
|
||||
private static void Match(
|
||||
IExpressionNode node,
|
||||
Stack<IExpressionNode>[] patterns,
|
||||
Boolean[] result,
|
||||
out List<ExpressionNode> needsFurtherAnalysis)
|
||||
{
|
||||
var nodeSegments = GetMatchSegments(node, out needsFurtherAnalysis);
|
||||
|
||||
if (nodeSegments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nodeNamedValue = nodeSegments.Peek() as NamedValue;
|
||||
var originalNodeSegments = nodeSegments;
|
||||
|
||||
for (var i = 0; i < patterns.Length; i++)
|
||||
{
|
||||
var patternSegments = patterns[i];
|
||||
var patternNamedValue = patternSegments.Peek() as NamedValue;
|
||||
|
||||
// Compare the named-value
|
||||
if (String.Equals(nodeNamedValue.Name, patternNamedValue.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Clone the stacks before mutating
|
||||
nodeSegments = new Stack<IExpressionNode>(originalNodeSegments.Reverse()); // Push reverse to preserve order
|
||||
nodeSegments.Pop();
|
||||
patternSegments = new Stack<IExpressionNode>(patternSegments.Reverse()); // Push reverse to preserve order
|
||||
patternSegments.Pop();
|
||||
|
||||
// Walk the stacks
|
||||
while (true)
|
||||
{
|
||||
// Every pattern segment was matched
|
||||
if (patternSegments.Count == 0)
|
||||
{
|
||||
result[i] = true;
|
||||
break;
|
||||
}
|
||||
// Every node segment was matched. Treat the pattern as matched. There is not
|
||||
// enough information to determine whether the property is required; assume it is.
|
||||
// For example, consider the pattern "github.event" and the expression "toJson(github)".
|
||||
// In this example the function requires the full structure of the named-value.
|
||||
else if (nodeSegments.Count == 0)
|
||||
{
|
||||
result[i] = true;
|
||||
break;
|
||||
}
|
||||
|
||||
var nodeSegment = nodeSegments.Pop();
|
||||
var patternSegment = patternSegments.Pop();
|
||||
|
||||
// The behavior of a wildcard varies depending on whether the left operand
|
||||
// is an array or an object. For simplicity, treat the pattern as matched.
|
||||
if (nodeSegment is Wildcard)
|
||||
{
|
||||
result[i] = true;
|
||||
break;
|
||||
}
|
||||
// Treat a wildcard pattern segment as matching any literal segment
|
||||
else if (patternSegment is Wildcard)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert literals to string and compare
|
||||
var nodeLiteral = nodeSegment as Literal;
|
||||
var nodeEvaluationResult = EvaluationResult.CreateIntermediateResult(null, nodeLiteral.Value);
|
||||
var nodeString = nodeEvaluationResult.ConvertToString();
|
||||
var patternLiteral = patternSegment as Literal;
|
||||
var patternEvaluationResult = EvaluationResult.CreateIntermediateResult(null, patternLiteral.Value);
|
||||
var patternString = patternEvaluationResult.ConvertToString();
|
||||
if (String.Equals(nodeString, patternString, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to number and compare
|
||||
var nodeNumber = nodeEvaluationResult.ConvertToNumber();
|
||||
if (!Double.IsNaN(nodeNumber) && nodeNumber >= 0d && nodeNumber <= (Double)Int32.MaxValue)
|
||||
{
|
||||
var patternNumber = patternEvaluationResult.ConvertToNumber();
|
||||
if (!Double.IsNaN(patternNumber) && patternNumber >= 0 && patternNumber <= (Double)Int32.MaxValue)
|
||||
{
|
||||
nodeNumber = Math.Floor(nodeNumber);
|
||||
patternNumber = Math.Floor(patternNumber);
|
||||
if (nodeNumber == patternNumber)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not matched
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This function is used to convert a pattern or a user-provided-expression into a
|
||||
// consistent structure for easy comparison. The result is a stack containing only
|
||||
// nodes of type NamedValue, Literal, or Wildcard. All Index nodes are discarded.
|
||||
//
|
||||
// For example, consider the pattern "needs.*.outputs". The expression tree looks like:
|
||||
// Index(
|
||||
// Index(
|
||||
// NamedValue:needs,
|
||||
// Wildcard:*
|
||||
// ),
|
||||
// Literal:"outputs"
|
||||
// )
|
||||
// The result would be:
|
||||
// [
|
||||
// NamedValue:needs
|
||||
// Wildcard:*
|
||||
// Literal:"outputs"
|
||||
// ]
|
||||
//
|
||||
// Any nested expression trees that require further analysis, are returned separately.
|
||||
// For example, consider the expression "needs.build.outputs[github.event.base_ref]"
|
||||
// The result would be:
|
||||
// [
|
||||
// NamedValue:needs
|
||||
// Literal:"build"
|
||||
// Literal:"outputs"
|
||||
// ]
|
||||
// And the nested expression tree "github.event.base_ref" would be tracked as needing
|
||||
// further analysis.
|
||||
private static Stack<IExpressionNode> GetMatchSegments(
|
||||
IExpressionNode node,
|
||||
out List<ExpressionNode> needsFurtherAnalysis)
|
||||
{
|
||||
var result = new Stack<IExpressionNode>();
|
||||
needsFurtherAnalysis = new List<ExpressionNode>();
|
||||
|
||||
// Node is a named-value
|
||||
if (node is NamedValue)
|
||||
{
|
||||
result.Push(node);
|
||||
}
|
||||
// Node is an index
|
||||
else if (node is Index index)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
//
|
||||
// Parameter 1
|
||||
//
|
||||
var parameter1 = index.Parameters[1];
|
||||
|
||||
// Treat anything other than literal as a wildcard
|
||||
result.Push(parameter1 is Literal ? parameter1 : new Wildcard());
|
||||
|
||||
// Further analysis required by the caller if parameter 1 is a Function/Operator/NamedValue
|
||||
if (parameter1 is Container || parameter1 is NamedValue)
|
||||
{
|
||||
needsFurtherAnalysis.Add(parameter1);
|
||||
}
|
||||
|
||||
//
|
||||
// Parameter 0
|
||||
//
|
||||
var parameter0 = index.Parameters[0];
|
||||
|
||||
// Parameter 0 is a named-value
|
||||
if (parameter0 is NamedValue)
|
||||
{
|
||||
result.Push(parameter0);
|
||||
break;
|
||||
}
|
||||
// Parameter 0 is an index
|
||||
else if (parameter0 is Index index2)
|
||||
{
|
||||
index = index2;
|
||||
}
|
||||
// Otherwise clear
|
||||
else
|
||||
{
|
||||
result.Clear();
|
||||
|
||||
// Further analysis required by the caller if parameter 0 is a Function/Operator
|
||||
if (parameter0 is Container)
|
||||
{
|
||||
needsFurtherAnalysis.Add(parameter0);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Sdk/Expressions/IFunctionInfo.cs
Normal file
13
src/Sdk/Expressions/IFunctionInfo.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public interface IFunctionInfo
|
||||
{
|
||||
String Name { get; }
|
||||
Int32 MinParameters { get; }
|
||||
Int32 MaxParameters { get; }
|
||||
Function CreateNode();
|
||||
}
|
||||
}
|
||||
11
src/Sdk/Expressions/INamedValueInfo.cs
Normal file
11
src/Sdk/Expressions/INamedValueInfo.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public interface INamedValueInfo
|
||||
{
|
||||
String Name { get; }
|
||||
NamedValue CreateNode();
|
||||
}
|
||||
}
|
||||
12
src/Sdk/Expressions/ISecretMasker.cs
Normal file
12
src/Sdk/Expressions/ISecretMasker.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
/// <summary>
|
||||
/// Used to mask secrets from trace messages and exception messages
|
||||
/// </summary>
|
||||
public interface ISecretMasker
|
||||
{
|
||||
String MaskSecrets(String input);
|
||||
}
|
||||
}
|
||||
10
src/Sdk/Expressions/ITraceWriter.cs
Normal file
10
src/Sdk/Expressions/ITraceWriter.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public interface ITraceWriter
|
||||
{
|
||||
void Info(String message);
|
||||
void Verbose(String message);
|
||||
}
|
||||
}
|
||||
21
src/Sdk/Expressions/NamedValueInfo.cs
Normal file
21
src/Sdk/Expressions/NamedValueInfo.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using GitHub.Actions.Expressions.Sdk;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public class NamedValueInfo<T> : INamedValueInfo
|
||||
where T : NamedValue, new()
|
||||
{
|
||||
public NamedValueInfo(String name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public String Name { get; }
|
||||
|
||||
public NamedValue CreateNode()
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/Sdk/Expressions/NoOpSecretMasker.cs
Normal file
12
src/Sdk/Expressions/NoOpSecretMasker.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
internal sealed class NoOpSecretMasker : ISecretMasker
|
||||
{
|
||||
public String MaskSecrets(String input)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/Sdk/Expressions/ParseException.cs
Normal file
68
src/Sdk/Expressions/ParseException.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using GitHub.Actions.Expressions.Tokens;
|
||||
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
public sealed class ParseException : ExpressionException
|
||||
{
|
||||
internal ParseException(ParseExceptionKind kind, Token token, String expression)
|
||||
: base(secretMasker: null, message: String.Empty)
|
||||
{
|
||||
Expression = expression;
|
||||
Kind = kind;
|
||||
RawToken = token?.RawValue;
|
||||
TokenIndex = token?.Index ?? 0;
|
||||
String description;
|
||||
switch (kind)
|
||||
{
|
||||
case ParseExceptionKind.ExceededMaxDepth:
|
||||
description = $"Exceeded max expression depth {ExpressionConstants.MaxDepth}";
|
||||
break;
|
||||
case ParseExceptionKind.ExceededMaxLength:
|
||||
description = $"Exceeded max expression length {ExpressionConstants.MaxLength}";
|
||||
break;
|
||||
case ParseExceptionKind.TooFewParameters:
|
||||
description = "Too few parameters supplied";
|
||||
break;
|
||||
case ParseExceptionKind.TooManyParameters:
|
||||
description = "Too many parameters supplied";
|
||||
break;
|
||||
case ParseExceptionKind.UnexpectedEndOfExpression:
|
||||
description = "Unexpected end of expression";
|
||||
break;
|
||||
case ParseExceptionKind.UnexpectedSymbol:
|
||||
description = "Unexpected symbol";
|
||||
break;
|
||||
case ParseExceptionKind.UnrecognizedFunction:
|
||||
description = "Unrecognized function";
|
||||
break;
|
||||
case ParseExceptionKind.UnrecognizedNamedValue:
|
||||
description = "Unrecognized named-value";
|
||||
break;
|
||||
default: // Should never reach here.
|
||||
throw new Exception($"Unexpected parse exception kind '{kind}'.");
|
||||
}
|
||||
|
||||
if (token == null)
|
||||
{
|
||||
Message = description;
|
||||
}
|
||||
else
|
||||
{
|
||||
Message = $"{description}: '{RawToken}'. Located at position {TokenIndex + 1} within expression: {Expression}";
|
||||
}
|
||||
}
|
||||
|
||||
internal String Expression { get; }
|
||||
|
||||
internal ParseExceptionKind Kind { get; }
|
||||
|
||||
internal String RawToken { get; }
|
||||
|
||||
internal Int32 TokenIndex { get; }
|
||||
|
||||
public sealed override String Message { get; }
|
||||
}
|
||||
}
|
||||
14
src/Sdk/Expressions/ParseExceptionKind.cs
Normal file
14
src/Sdk/Expressions/ParseExceptionKind.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace GitHub.Actions.Expressions
|
||||
{
|
||||
internal enum ParseExceptionKind
|
||||
{
|
||||
ExceededMaxDepth,
|
||||
ExceededMaxLength,
|
||||
TooFewParameters,
|
||||
TooManyParameters,
|
||||
UnexpectedEndOfExpression,
|
||||
UnexpectedSymbol,
|
||||
UnrecognizedFunction,
|
||||
UnrecognizedNamedValue,
|
||||
}
|
||||
}
|
||||
277
src/Sdk/Expressions/Resources/ExpressionResources.cs
Normal file
277
src/Sdk/Expressions/Resources/ExpressionResources.cs
Normal file
@@ -0,0 +1,277 @@
|
||||
// <auto-generated/>
|
||||
// *** AUTOMATICALLY GENERATED BY GenResourceClass -- DO NOT EDIT!!! ***
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
|
||||
namespace GitHub.Actions.Expressions {
|
||||
|
||||
|
||||
internal static class ExpressionResources
|
||||
{
|
||||
|
||||
|
||||
//********************************************************************************************
|
||||
/// Creates the resource manager instance.
|
||||
//********************************************************************************************
|
||||
static ExpressionResources()
|
||||
{
|
||||
s_resMgr = new ResourceManager("GitHub.Actions.Expressions.ExpressionResources", typeof(ExpressionResources).GetTypeInfo().Assembly);
|
||||
}
|
||||
|
||||
public static ResourceManager Manager
|
||||
{
|
||||
get
|
||||
{
|
||||
return s_resMgr;
|
||||
}
|
||||
}
|
||||
|
||||
//********************************************************************************************
|
||||
/// Returns a localized string given a resource string name.
|
||||
//********************************************************************************************
|
||||
public static String Get(
|
||||
String resourceName)
|
||||
{
|
||||
return s_resMgr.GetString(resourceName, CultureInfo.CurrentUICulture);
|
||||
}
|
||||
|
||||
//********************************************************************************************
|
||||
/// Returns a localized integer given a resource string name.
|
||||
//********************************************************************************************
|
||||
public static int GetInt(
|
||||
String resourceName)
|
||||
{
|
||||
return (int)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture);
|
||||
}
|
||||
|
||||
//********************************************************************************************
|
||||
/// Returns a localized string given a resource string name.
|
||||
//********************************************************************************************
|
||||
public static bool GetBool(
|
||||
String resourceName)
|
||||
{
|
||||
return (bool)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture);
|
||||
}
|
||||
|
||||
|
||||
//********************************************************************************************
|
||||
/// A little helper function to alleviate some typing associated with loading resources and
|
||||
/// formatting the strings. In DEBUG builds, it also asserts that the number of format
|
||||
/// arguments and the length of args match.
|
||||
//********************************************************************************************
|
||||
private static String Format( // The formatted resource string.
|
||||
String resourceName, // The name of the resource.
|
||||
params Object[] args) // Arguments to format.
|
||||
{
|
||||
String resource = Get(resourceName);
|
||||
|
||||
#if DEBUG
|
||||
// Check to make sure that the number of format string arguments matches the number of
|
||||
// arguments passed in.
|
||||
int formatArgCount = 0;
|
||||
bool[] argSeen = new bool[100];
|
||||
for (int i = 0; i < resource.Length; i++)
|
||||
{
|
||||
if (resource[i] == '{')
|
||||
{
|
||||
if (i + 1 < resource.Length &&
|
||||
resource[i + 1] == '{')
|
||||
{
|
||||
i++; // Skip the escaped curly braces.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Move past the curly brace and leading whitespace.
|
||||
i++;
|
||||
while (Char.IsWhiteSpace(resource[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
// Get the argument number.
|
||||
int length = 0;
|
||||
while (i + length < resource.Length && Char.IsDigit(resource[i + length]))
|
||||
{
|
||||
length++;
|
||||
}
|
||||
|
||||
// Record it if it hasn't already been seen.
|
||||
int argNumber = int.Parse(resource.Substring(i, length), CultureInfo.InvariantCulture);
|
||||
if (!argSeen[argNumber])
|
||||
{
|
||||
formatArgCount++; // Count it as a formatting argument.
|
||||
argSeen[argNumber] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Assert(args != null || formatArgCount == 0,
|
||||
String.Format(CultureInfo.InvariantCulture, "The number of format arguments is {0}, but the args parameter is null.", formatArgCount));
|
||||
Debug.Assert(args == null || formatArgCount == args.Length,
|
||||
String.Format(CultureInfo.InvariantCulture, "Coding error using resource \"{0}\": The number of format arguments {1} != number of args {2}",
|
||||
resourceName, formatArgCount, args != null ? args.Length : 0));
|
||||
#endif // DEBUG
|
||||
|
||||
|
||||
if (args == null)
|
||||
{
|
||||
return resource;
|
||||
}
|
||||
|
||||
// If there are any DateTime structs in the arguments, we need to bracket them
|
||||
// to make sure they are within the supported range of the current calendar.
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
// DateTime is a struct, we cannot use the as operator and null check.
|
||||
if (args[i] is DateTime)
|
||||
{
|
||||
DateTime dateTime = (DateTime)args[i];
|
||||
|
||||
// We need to fetch the calendar on each Format call since it may change.
|
||||
// Since we don't have more than one DateTime for resource, do not
|
||||
// bother to cache this for the duration of the for loop.
|
||||
Calendar calendar = DateTimeFormatInfo.CurrentInfo.Calendar;
|
||||
if (dateTime > calendar.MaxSupportedDateTime)
|
||||
{
|
||||
args[i] = calendar.MaxSupportedDateTime;
|
||||
}
|
||||
else if (dateTime < calendar.MinSupportedDateTime)
|
||||
{
|
||||
args[i] = calendar.MinSupportedDateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String.Format(CultureInfo.CurrentCulture, resource, args);
|
||||
}
|
||||
|
||||
// According to the documentation for the ResourceManager class, it should be sufficient to
|
||||
// create a single static instance. The following is an excerpt from the 1.1 documentation.
|
||||
// Using the methods of ResourceManager, a caller can access the resources for a particular
|
||||
// culture using the GetObject and GetString methods. By default, these methods return the
|
||||
// resource for the culture determined by the current cultural settings of the thread that made
|
||||
// the call.
|
||||
private static ResourceManager s_resMgr;
|
||||
|
||||
/// <summary>
|
||||
/// The maximum allowed memory size was exceeded while evaluating the following expression: {0}
|
||||
/// </summary>
|
||||
public static String ExceededAllowedMemory(object arg0) { return Format("ExceededAllowedMemory", arg0); }
|
||||
|
||||
/// <summary>
|
||||
/// 0 is replaced with a number.
|
||||
///
|
||||
/// Exceeded max expression depth {0}
|
||||
/// </summary>
|
||||
public static String ExceededMaxExpressionDepth(object arg0) { return Format("ExceededMaxExpressionDepth", arg0); }
|
||||
|
||||
/// <summary>
|
||||
/// 0 is replaced with a number.
|
||||
///
|
||||
/// Exceeded max expression length {0}
|
||||
/// </summary>
|
||||
public static String ExceededMaxExpressionLength(object arg0) { return Format("ExceededMaxExpressionLength", arg0); }
|
||||
|
||||
/// <summary>
|
||||
/// Expected a property name to follow the dereference operator '.'
|
||||
/// </summary>
|
||||
public static String ExpectedPropertyName() { return Get("ExpectedPropertyName"); }
|
||||
|
||||
/// <summary>
|
||||
/// Expected '(' to follow a function
|
||||
/// </summary>
|
||||
public static String ExpectedStartParameter() { return Get("ExpectedStartParameter"); }
|
||||
|
||||
/// <summary>
|
||||
/// The following format string references more arguments than were supplied: {0}
|
||||
/// </summary>
|
||||
public static String InvalidFormatArgIndex(object arg0) { return Format("InvalidFormatArgIndex", arg0); }
|
||||
|
||||
/// <summary>
|
||||
/// The format specifiers '{0}' are not valid for objects of type '{1}'
|
||||
/// </summary>
|
||||
public static String InvalidFormatSpecifiers(object arg0, object arg1) { return Format("InvalidFormatSpecifiers", arg0, arg1); }
|
||||
|
||||
/// <summary>
|
||||
/// The following format string is invalid: {0}
|
||||
/// </summary>
|
||||
public static String InvalidFormatString(object arg0) { return Format("InvalidFormatString", arg0); }
|
||||
|
||||
/// <summary>
|
||||
/// Key not found '{0}'
|
||||
/// </summary>
|
||||
public static String KeyNotFound(object arg0) { return Format("KeyNotFound", arg0); }
|
||||
|
||||
/// <summary>
|
||||
/// 0 is replaced with the error message
|
||||
///
|
||||
/// {0}.
|
||||
/// </summary>
|
||||
public static String ParseErrorWithFwlink(object arg0) { return Format("ParseErrorWithFwlink", arg0); }
|
||||
|
||||
/// <summary>
|
||||
/// 0 is replaced with the parse error message
|
||||
/// 1 is replaced with the token
|
||||
/// 2 is replaced with the character position within the string
|
||||
/// 3 is replaced with the full statement
|
||||
///
|
||||
/// {0}: '{1}'. Located at position {2} within expression: {3}.
|
||||
/// </summary>
|
||||
public static String ParseErrorWithTokenInfo(object arg0, object arg1, object arg2, object arg3) { return Format("ParseErrorWithTokenInfo", arg0, arg1, arg2, arg3); }
|
||||
|
||||
/// <summary>
|
||||
/// 0 is replaced with the from-type.
|
||||
/// 1 is replaced with the to-type.
|
||||
/// 2 is replaced with the value.
|
||||
///
|
||||
/// Unable to convert from {0} to {1}. Value: {2}
|
||||
/// </summary>
|
||||
public static String TypeCastError(object arg0, object arg1, object arg2) { return Format("TypeCastError", arg0, arg1, arg2); }
|
||||
|
||||
/// <summary>
|
||||
/// 0 is replaced with the from-type.
|
||||
/// 1 is replaced with the to-type.
|
||||
///
|
||||
/// Unable to convert from {0} to {1}.
|
||||
/// </summary>
|
||||
public static String TypeCastErrorNoValue(object arg0, object arg1) { return Format("TypeCastErrorNoValue", arg0, arg1); }
|
||||
|
||||
/// <summary>
|
||||
/// 0 is replaced with the from-type.
|
||||
/// 1 is replaced with the to-type.
|
||||
/// 2 is replaced with the value.
|
||||
/// 3 is replaced with the error message.
|
||||
///
|
||||
/// Unable to convert from {0} to {1}. Value: {2}. Error: {3}
|
||||
/// </summary>
|
||||
public static String TypeCastErrorWithError(object arg0, object arg1, object arg2, object arg3) { return Format("TypeCastErrorWithError", arg0, arg1, arg2, arg3); }
|
||||
|
||||
/// <summary>
|
||||
/// Unclosed function
|
||||
/// </summary>
|
||||
public static String UnclosedFunction() { return Get("UnclosedFunction"); }
|
||||
|
||||
/// <summary>
|
||||
/// Unclosed indexer
|
||||
/// </summary>
|
||||
public static String UnclosedIndexer() { return Get("UnclosedIndexer"); }
|
||||
|
||||
/// <summary>
|
||||
/// Unexpected symbol
|
||||
/// </summary>
|
||||
public static String UnexpectedSymbol() { return Get("UnexpectedSymbol"); }
|
||||
|
||||
/// <summary>
|
||||
/// Unrecognized value
|
||||
/// </summary>
|
||||
public static String UnrecognizedValue() { return Get("UnrecognizedValue"); }
|
||||
|
||||
|
||||
}
|
||||
|
||||
} // namespace
|
||||
190
src/Sdk/Expressions/Resources/ExpressionResources.resx
Normal file
190
src/Sdk/Expressions/Resources/ExpressionResources.resx
Normal file
@@ -0,0 +1,190 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ExceededAllowedMemory" xml:space="preserve">
|
||||
<value>The maximum allowed memory size was exceeded while evaluating the following expression: {0}</value>
|
||||
</data>
|
||||
<data name="ExceededMaxExpressionDepth" xml:space="preserve">
|
||||
<value>Exceeded max expression depth {0}</value>
|
||||
<comment>0 is replaced with a number.</comment>
|
||||
</data>
|
||||
<data name="ExceededMaxExpressionLength" xml:space="preserve">
|
||||
<value>Exceeded max expression length {0}</value>
|
||||
<comment>0 is replaced with a number.</comment>
|
||||
</data>
|
||||
<data name="ExpectedPropertyName" xml:space="preserve">
|
||||
<value>Expected a property name to follow the dereference operator '.'</value>
|
||||
</data>
|
||||
<data name="ExpectedStartParameter" xml:space="preserve">
|
||||
<value>Expected '(' to follow a function</value>
|
||||
</data>
|
||||
<data name="InvalidFormatArgIndex" xml:space="preserve">
|
||||
<value>The following format string references more arguments than were supplied: {0}</value>
|
||||
</data>
|
||||
<data name="InvalidFormatSpecifiers" xml:space="preserve">
|
||||
<value>The format specifiers '{0}' are not valid for objects of type '{1}'</value>
|
||||
</data>
|
||||
<data name="InvalidFormatString" xml:space="preserve">
|
||||
<value>The following format string is invalid: {0}</value>
|
||||
</data>
|
||||
<data name="KeyNotFound" xml:space="preserve">
|
||||
<value>Key not found '{0}'</value>
|
||||
</data>
|
||||
<data name="ParseErrorWithFwlink" xml:space="preserve">
|
||||
<value>{0}.</value>
|
||||
<comment>0 is replaced with the error message</comment>
|
||||
</data>
|
||||
<data name="ParseErrorWithTokenInfo" xml:space="preserve">
|
||||
<value>{0}: '{1}'. Located at position {2} within expression: {3}.</value>
|
||||
<comment>0 is replaced with the parse error message
|
||||
1 is replaced with the token
|
||||
2 is replaced with the character position within the string
|
||||
3 is replaced with the full statement</comment>
|
||||
</data>
|
||||
<data name="TypeCastError" xml:space="preserve">
|
||||
<value>Unable to convert from {0} to {1}. Value: {2}</value>
|
||||
<comment>0 is replaced with the from-type.
|
||||
1 is replaced with the to-type.
|
||||
2 is replaced with the value.</comment>
|
||||
</data>
|
||||
<data name="TypeCastErrorNoValue" xml:space="preserve">
|
||||
<value>Unable to convert from {0} to {1}.</value>
|
||||
<comment>0 is replaced with the from-type.
|
||||
1 is replaced with the to-type.</comment>
|
||||
</data>
|
||||
<data name="TypeCastErrorWithError" xml:space="preserve">
|
||||
<value>Unable to convert from {0} to {1}. Value: {2}. Error: {3}</value>
|
||||
<comment>0 is replaced with the from-type.
|
||||
1 is replaced with the to-type.
|
||||
2 is replaced with the value.
|
||||
3 is replaced with the error message.</comment>
|
||||
</data>
|
||||
<data name="UnclosedFunction" xml:space="preserve">
|
||||
<value>Unclosed function</value>
|
||||
</data>
|
||||
<data name="UnclosedIndexer" xml:space="preserve">
|
||||
<value>Unclosed indexer</value>
|
||||
</data>
|
||||
<data name="UnexpectedSymbol" xml:space="preserve">
|
||||
<value>Unexpected symbol</value>
|
||||
</data>
|
||||
<data name="UnrecognizedValue" xml:space="preserve">
|
||||
<value>Unrecognized value</value>
|
||||
</data>
|
||||
</root>
|
||||
17
src/Sdk/Expressions/Sdk/Container.cs
Normal file
17
src/Sdk/Expressions/Sdk/Container.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
public abstract class Container : ExpressionNode
|
||||
{
|
||||
public IReadOnlyList<ExpressionNode> Parameters => m_parameters.AsReadOnly();
|
||||
|
||||
public void AddParameter(ExpressionNode node)
|
||||
{
|
||||
m_parameters.Add(node);
|
||||
node.Container = this;
|
||||
}
|
||||
|
||||
private readonly List<ExpressionNode> m_parameters = new List<ExpressionNode>();
|
||||
}
|
||||
}
|
||||
79
src/Sdk/Expressions/Sdk/EvaluationContext.cs
Normal file
79
src/Sdk/Expressions/Sdk/EvaluationContext.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
public sealed class EvaluationContext
|
||||
{
|
||||
internal EvaluationContext(
|
||||
ITraceWriter trace,
|
||||
ISecretMasker secretMasker,
|
||||
Object state,
|
||||
EvaluationOptions options,
|
||||
ExpressionNode node)
|
||||
{
|
||||
Trace = trace ?? throw new ArgumentNullException(nameof(trace));
|
||||
SecretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker));
|
||||
State = state;
|
||||
|
||||
// Copy the options
|
||||
options = new EvaluationOptions(copy: options);
|
||||
if (options.MaxMemory == 0)
|
||||
{
|
||||
// Set a reasonable default max memory
|
||||
options.MaxMemory = 1048576; // 1 mb
|
||||
}
|
||||
if (options.MaxCacheMemory <= 0)
|
||||
{
|
||||
// Set a reasonable default max cache bytes
|
||||
options.MaxCacheMemory = 1048576; // 1 mb
|
||||
}
|
||||
Options = options;
|
||||
Memory = new EvaluationMemory(options.MaxMemory, node);
|
||||
|
||||
m_traceResults = new Dictionary<ExpressionNode, String>();
|
||||
m_traceMemory = new MemoryCounter(null, options.MaxCacheMemory);
|
||||
}
|
||||
|
||||
public ITraceWriter Trace { get; }
|
||||
|
||||
public ISecretMasker SecretMasker { get; }
|
||||
|
||||
public Object State { get; }
|
||||
|
||||
internal EvaluationMemory Memory { get; }
|
||||
|
||||
internal EvaluationOptions Options { get; }
|
||||
|
||||
internal void SetTraceResult(
|
||||
ExpressionNode node,
|
||||
EvaluationResult result)
|
||||
{
|
||||
// Remove if previously added. This typically should not happen. This could happen
|
||||
// due to a badly authored function. So we'll handle it and track memory correctly.
|
||||
if (m_traceResults.TryGetValue(node, out String oldValue))
|
||||
{
|
||||
m_traceMemory.Remove(oldValue);
|
||||
m_traceResults.Remove(node);
|
||||
}
|
||||
|
||||
// Check max memory
|
||||
String value = ExpressionUtility.FormatValue(SecretMasker, result);
|
||||
if (m_traceMemory.TryAdd(value))
|
||||
{
|
||||
// Store the result
|
||||
m_traceResults[node] = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal Boolean TryGetTraceResult(ExpressionNode node, out String value)
|
||||
{
|
||||
return m_traceResults.TryGetValue(node, out value);
|
||||
}
|
||||
|
||||
private readonly Dictionary<ExpressionNode, String> m_traceResults = new Dictionary<ExpressionNode, String>();
|
||||
private readonly MemoryCounter m_traceMemory;
|
||||
}
|
||||
}
|
||||
111
src/Sdk/Expressions/Sdk/EvaluationMemory.cs
Normal file
111
src/Sdk/Expressions/Sdk/EvaluationMemory.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
/// <summary>
|
||||
/// This is an internal class only.
|
||||
///
|
||||
/// This class is used to track current memory consumption
|
||||
/// across the entire expression evaluation.
|
||||
/// </summary>
|
||||
internal sealed class EvaluationMemory
|
||||
{
|
||||
internal EvaluationMemory(
|
||||
Int32 maxBytes,
|
||||
ExpressionNode node)
|
||||
{
|
||||
m_maxAmount = maxBytes;
|
||||
m_node = node;
|
||||
}
|
||||
|
||||
internal void AddAmount(
|
||||
Int32 depth,
|
||||
Int32 bytes,
|
||||
Boolean trimDepth = false)
|
||||
{
|
||||
// Trim deeper depths
|
||||
if (trimDepth)
|
||||
{
|
||||
while (m_maxActiveDepth > depth)
|
||||
{
|
||||
var amount = m_depths[m_maxActiveDepth];
|
||||
|
||||
if (amount > 0)
|
||||
{
|
||||
// Sanity check
|
||||
if (amount > m_totalAmount)
|
||||
{
|
||||
throw new InvalidOperationException("Bytes to subtract exceeds total bytes");
|
||||
}
|
||||
|
||||
// Subtract from the total
|
||||
checked
|
||||
{
|
||||
m_totalAmount -= amount;
|
||||
}
|
||||
|
||||
// Reset the amount
|
||||
m_depths[m_maxActiveDepth] = 0;
|
||||
}
|
||||
|
||||
m_maxActiveDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
// Grow the depths
|
||||
if (depth > m_maxActiveDepth)
|
||||
{
|
||||
// Grow the list
|
||||
while (m_depths.Count <= depth)
|
||||
{
|
||||
m_depths.Add(0);
|
||||
}
|
||||
|
||||
// Adjust the max active depth
|
||||
m_maxActiveDepth = depth;
|
||||
}
|
||||
|
||||
checked
|
||||
{
|
||||
// Add to the depth
|
||||
m_depths[depth] += bytes;
|
||||
|
||||
// Add to the total
|
||||
m_totalAmount += bytes;
|
||||
}
|
||||
|
||||
// Check max
|
||||
if (m_totalAmount > m_maxAmount)
|
||||
{
|
||||
throw new InvalidOperationException(ExpressionResources.ExceededAllowedMemory(m_node?.ConvertToExpression()));
|
||||
}
|
||||
}
|
||||
|
||||
internal static Int32 CalculateBytes(Object obj)
|
||||
{
|
||||
if (obj is String str)
|
||||
{
|
||||
// This measurement doesn't have to be perfect
|
||||
// https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/
|
||||
|
||||
checked
|
||||
{
|
||||
return c_stringBaseOverhead + ((str?.Length ?? 0) * sizeof(Char));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return c_minObjectSize;
|
||||
}
|
||||
}
|
||||
|
||||
private const Int32 c_minObjectSize = 24;
|
||||
private const Int32 c_stringBaseOverhead = 26;
|
||||
private readonly List<Int32> m_depths = new List<Int32>();
|
||||
private readonly Int32 m_maxAmount;
|
||||
private readonly ExpressionNode m_node;
|
||||
private Int32 m_maxActiveDepth = -1;
|
||||
private Int32 m_totalAmount;
|
||||
}
|
||||
}
|
||||
34
src/Sdk/Expressions/Sdk/EvaluationTraceWriter.cs
Normal file
34
src/Sdk/Expressions/Sdk/EvaluationTraceWriter.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
internal sealed class EvaluationTraceWriter : ITraceWriter
|
||||
{
|
||||
public EvaluationTraceWriter(ITraceWriter trace, ISecretMasker secretMasker)
|
||||
{
|
||||
m_trace = trace;
|
||||
m_secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker));
|
||||
}
|
||||
|
||||
public void Info(String message)
|
||||
{
|
||||
if (m_trace != null)
|
||||
{
|
||||
message = m_secretMasker.MaskSecrets(message);
|
||||
m_trace.Info(message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Verbose(String message)
|
||||
{
|
||||
if (m_trace != null)
|
||||
{
|
||||
message = m_secretMasker.MaskSecrets(message);
|
||||
m_trace.Verbose(message);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ISecretMasker m_secretMasker;
|
||||
private readonly ITraceWriter m_trace;
|
||||
}
|
||||
}
|
||||
187
src/Sdk/Expressions/Sdk/ExpressionNode.cs
Normal file
187
src/Sdk/Expressions/Sdk/ExpressionNode.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
public abstract class ExpressionNode : IExpressionNode
|
||||
{
|
||||
internal Container Container { get; set; }
|
||||
|
||||
internal Int32 Level { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name is used for tracing. Normally the parser will set the name. However if a node
|
||||
/// is added manually, then the name may not be set and will fallback to the type name.
|
||||
/// </summary>
|
||||
public String Name
|
||||
{
|
||||
get
|
||||
{
|
||||
return !String.IsNullOrEmpty(m_name) ? m_name : this.GetType().Name;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
m_name = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the evalation result should be stored on the context and used
|
||||
/// when the expanded result is traced.
|
||||
/// </summary>
|
||||
protected abstract Boolean TraceFullyExpanded { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IExpressionNode entry point.
|
||||
/// </summary>
|
||||
EvaluationResult IExpressionNode.Evaluate(
|
||||
ITraceWriter trace,
|
||||
ISecretMasker secretMasker,
|
||||
Object state,
|
||||
EvaluationOptions options)
|
||||
{
|
||||
if (Container != null)
|
||||
{
|
||||
// Do not localize. This is an SDK consumer error.
|
||||
throw new NotSupportedException($"Expected {nameof(IExpressionNode)}.{nameof(Evaluate)} to be called on root node only.");
|
||||
}
|
||||
|
||||
|
||||
var originalSecretMasker = secretMasker;
|
||||
try
|
||||
{
|
||||
// Evaluate
|
||||
secretMasker = secretMasker ?? new NoOpSecretMasker();
|
||||
trace = new EvaluationTraceWriter(trace, secretMasker);
|
||||
var context = new EvaluationContext(trace, secretMasker, state, options, this);
|
||||
var originalExpression = ConvertToExpression();
|
||||
trace.Info($"Evaluating: {originalExpression}");
|
||||
var result = Evaluate(context);
|
||||
|
||||
// Trace the result
|
||||
TraceTreeResult(context, originalExpression, result.Value, result.Kind);
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (secretMasker != null && secretMasker != originalSecretMasker)
|
||||
{
|
||||
(secretMasker as IDisposable)?.Dispose();
|
||||
secretMasker = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This function is intended only for ExpressionNode authors to call. The EvaluationContext
|
||||
/// caches result-state specific to the evaluation instance.
|
||||
/// </summary>
|
||||
public EvaluationResult Evaluate(EvaluationContext context)
|
||||
{
|
||||
// Evaluate
|
||||
Level = Container == null ? 0 : Container.Level + 1;
|
||||
TraceVerbose(context, Level, $"Evaluating {Name}:");
|
||||
var coreResult = EvaluateCore(context, out ResultMemory coreMemory);
|
||||
|
||||
if (coreMemory == null)
|
||||
{
|
||||
coreMemory = new ResultMemory();
|
||||
}
|
||||
|
||||
// Convert to canonical value
|
||||
var val = ExpressionUtility.ConvertToCanonicalValue(coreResult, out ValueKind kind, out Object raw);
|
||||
|
||||
// The depth can be safely trimmed when the total size of the core result is known,
|
||||
// or when the total size of the core result can easily be determined.
|
||||
var trimDepth = coreMemory.IsTotal || (Object.ReferenceEquals(raw, null) && ExpressionUtility.IsPrimitive(kind));
|
||||
|
||||
// Account for the memory overhead of the core result
|
||||
var coreBytes = coreMemory.Bytes ?? EvaluationMemory.CalculateBytes(raw ?? val);
|
||||
context.Memory.AddAmount(Level, coreBytes, trimDepth);
|
||||
|
||||
// Account for the memory overhead of the conversion result
|
||||
if (!Object.ReferenceEquals(raw, null))
|
||||
{
|
||||
var conversionBytes = EvaluationMemory.CalculateBytes(val);
|
||||
context.Memory.AddAmount(Level, conversionBytes);
|
||||
}
|
||||
|
||||
var result = new EvaluationResult(context, Level, val, kind, raw);
|
||||
|
||||
// Store the trace result
|
||||
if (this.TraceFullyExpanded)
|
||||
{
|
||||
context.SetTraceResult(this, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public abstract String ConvertToExpression();
|
||||
|
||||
internal abstract String ConvertToExpandedExpression(EvaluationContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the node
|
||||
/// </summary>
|
||||
/// <param name="context">The current expression context</param>
|
||||
/// <param name="resultMemory">
|
||||
/// Helps determine how much memory is being consumed across the evaluation of the expression.
|
||||
/// </param>
|
||||
protected abstract Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory);
|
||||
|
||||
protected MemoryCounter CreateMemoryCounter(EvaluationContext context)
|
||||
{
|
||||
return new MemoryCounter(this, context.Options.MaxMemory);
|
||||
}
|
||||
|
||||
private void TraceTreeResult(
|
||||
EvaluationContext context,
|
||||
String originalExpression,
|
||||
Object result,
|
||||
ValueKind kind)
|
||||
{
|
||||
// Get the expanded expression
|
||||
String expandedExpression = ConvertToExpandedExpression(context);
|
||||
|
||||
// Format the result
|
||||
String traceValue = ExpressionUtility.FormatValue(context.SecretMasker, result, kind);
|
||||
|
||||
// Only trace the expanded expression if it is meaningfully different (or if always showing)
|
||||
if (context.Options.AlwaysTraceExpanded ||
|
||||
(!String.Equals(expandedExpression, originalExpression, StringComparison.Ordinal) &&
|
||||
!String.Equals(expandedExpression, traceValue, StringComparison.Ordinal)))
|
||||
{
|
||||
if (!context.Options.AlwaysTraceExpanded &&
|
||||
kind == ValueKind.Number &&
|
||||
String.Equals(expandedExpression, $"'{traceValue}'", StringComparison.Ordinal))
|
||||
{
|
||||
// Don't bother tracing the expanded expression when the result is a number and the
|
||||
// expanded expresion is a precisely matching string.
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Trace.Info($"Expanded: {expandedExpression}");
|
||||
}
|
||||
}
|
||||
|
||||
// Always trace the result
|
||||
context.Trace.Info($"Result: {traceValue}");
|
||||
}
|
||||
|
||||
private static void TraceVerbose(
|
||||
EvaluationContext context,
|
||||
Int32 level,
|
||||
String message)
|
||||
{
|
||||
context.Trace.Verbose(String.Empty.PadLeft(level * 2, '.') + (message ?? String.Empty));
|
||||
}
|
||||
|
||||
private String m_name;
|
||||
}
|
||||
}
|
||||
295
src/Sdk/Expressions/Sdk/ExpressionUtility.cs
Normal file
295
src/Sdk/Expressions/Sdk/ExpressionUtility.cs
Normal file
@@ -0,0 +1,295 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
public static class ExpressionUtility
|
||||
{
|
||||
internal static Object ConvertToCanonicalValue(
|
||||
Object val,
|
||||
out ValueKind kind,
|
||||
out Object raw)
|
||||
{
|
||||
raw = null;
|
||||
|
||||
if (Object.ReferenceEquals(val, null))
|
||||
{
|
||||
kind = ValueKind.Null;
|
||||
return null;
|
||||
}
|
||||
else if (val is Boolean)
|
||||
{
|
||||
kind = ValueKind.Boolean;
|
||||
return val;
|
||||
}
|
||||
else if (val is Double)
|
||||
{
|
||||
kind = ValueKind.Number;
|
||||
return val;
|
||||
}
|
||||
else if (val is String)
|
||||
{
|
||||
kind = ValueKind.String;
|
||||
return val;
|
||||
}
|
||||
else if (val is INull n)
|
||||
{
|
||||
kind = ValueKind.Null;
|
||||
raw = val;
|
||||
return null;
|
||||
}
|
||||
else if (val is IBoolean boolean)
|
||||
{
|
||||
kind = ValueKind.Boolean;
|
||||
raw = val;
|
||||
return boolean.GetBoolean();
|
||||
}
|
||||
else if (val is INumber number)
|
||||
{
|
||||
kind = ValueKind.Number;
|
||||
raw = val;
|
||||
return number.GetNumber();
|
||||
}
|
||||
else if (val is IString str)
|
||||
{
|
||||
kind = ValueKind.String;
|
||||
raw = val;
|
||||
return str.GetString();
|
||||
}
|
||||
else if (val is IReadOnlyObject)
|
||||
{
|
||||
kind = ValueKind.Object;
|
||||
return val;
|
||||
}
|
||||
else if (val is IReadOnlyArray)
|
||||
{
|
||||
kind = ValueKind.Array;
|
||||
return val;
|
||||
}
|
||||
else if (!val.GetType().GetTypeInfo().IsClass)
|
||||
{
|
||||
if (val is Decimal || val is Byte || val is SByte || val is Int16 || val is UInt16 || val is Int32 || val is UInt32 || val is Int64 || val is UInt64 || val is Single)
|
||||
{
|
||||
kind = ValueKind.Number;
|
||||
return Convert.ToDouble(val);
|
||||
}
|
||||
else if (val is Enum)
|
||||
{
|
||||
var strVal = String.Format(CultureInfo.InvariantCulture, "{0:G}", val);
|
||||
if (Double.TryParse(strVal, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out Double doubleValue))
|
||||
{
|
||||
kind = ValueKind.Number;
|
||||
return doubleValue;
|
||||
}
|
||||
|
||||
kind = ValueKind.String;
|
||||
return strVal;
|
||||
}
|
||||
}
|
||||
|
||||
kind = ValueKind.Object;
|
||||
return val;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string into it's parse token representation. Useful when programmatically constructing an expression.
|
||||
/// For example the string "hello world" returns 'hello world'. Note, null will return the null token; pass empty string
|
||||
/// if you want the empty string token instead.
|
||||
/// </summary>
|
||||
public static String ConvertToParseToken(String str)
|
||||
{
|
||||
if (str == null)
|
||||
{
|
||||
return FormatValue(null, null, ValueKind.Null);
|
||||
}
|
||||
|
||||
return FormatValue(null, str, ValueKind.String);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string into it's parse token representation. Useful when programmatically constructing an expression.
|
||||
/// </summary>
|
||||
public static String ConvertToParseToken(Double d)
|
||||
{
|
||||
return FormatValue(null, d, ValueKind.Number);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string into it's parse token representation. Useful when programmatically constructing an expression.
|
||||
/// </summary>
|
||||
public static String ConvertToParseToken(Boolean b)
|
||||
{
|
||||
return FormatValue(null, b, ValueKind.Boolean);
|
||||
}
|
||||
|
||||
internal static String FormatValue(
|
||||
ISecretMasker secretMasker,
|
||||
EvaluationResult evaluationResult)
|
||||
{
|
||||
return FormatValue(secretMasker, evaluationResult.Value, evaluationResult.Kind);
|
||||
}
|
||||
|
||||
internal static String FormatValue(
|
||||
ISecretMasker secretMasker,
|
||||
Object value,
|
||||
ValueKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case ValueKind.Null:
|
||||
return ExpressionConstants.Null;
|
||||
|
||||
case ValueKind.Boolean:
|
||||
return ((Boolean)value) ? ExpressionConstants.True : ExpressionConstants.False;
|
||||
|
||||
case ValueKind.Number:
|
||||
var strNumber = ((Double)value).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture);
|
||||
return secretMasker != null ? secretMasker.MaskSecrets(strNumber) : strNumber;
|
||||
|
||||
case ValueKind.String:
|
||||
// Mask secrets before string-escaping.
|
||||
var strValue = secretMasker != null ? secretMasker.MaskSecrets(value as String) : value as String;
|
||||
return $"'{StringEscape(strValue)}'";
|
||||
|
||||
case ValueKind.Array:
|
||||
case ValueKind.Object:
|
||||
return kind.ToString();
|
||||
|
||||
default: // Should never reach here.
|
||||
throw new NotSupportedException($"Unable to convert to expanded expression. Unexpected value kind: {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsLegalKeyword(String str)
|
||||
{
|
||||
if (String.IsNullOrEmpty(str))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = str[0];
|
||||
if ((first >= 'a' && first <= 'z') ||
|
||||
(first >= 'A' && first <= 'Z') ||
|
||||
first == '_')
|
||||
{
|
||||
for (var i = 1; i < str.Length; i++)
|
||||
{
|
||||
var c = str[i];
|
||||
if ((c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '_' ||
|
||||
c == '-')
|
||||
{
|
||||
// OK
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal static Boolean IsPrimitive(ValueKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case ValueKind.Null:
|
||||
case ValueKind.Boolean:
|
||||
case ValueKind.Number:
|
||||
case ValueKind.String:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rules here attempt to follow Javascript rules for coercing a string into a number
|
||||
/// for comparison. That is, the Number() function in Javascript.
|
||||
/// </summary>
|
||||
internal static Double ParseNumber(String str)
|
||||
{
|
||||
// Trim
|
||||
str = str?.Trim() ?? String.Empty;
|
||||
|
||||
// Empty
|
||||
if (String.IsNullOrEmpty(str))
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
// Try parse
|
||||
else if (Double.TryParse(str, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
// Check for 0x[0-9a-fA-F]+
|
||||
else if (str[0] == '0' &&
|
||||
str.Length > 2 &&
|
||||
str[1] == 'x' &&
|
||||
str.Skip(2).All(x => (x >= '0' && x <= '9') || (x >= 'a' && x <= 'f') || (x >= 'A' && x <= 'F')))
|
||||
{
|
||||
// Try parse
|
||||
if (Int32.TryParse(str.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var integer))
|
||||
{
|
||||
return (Double)integer;
|
||||
}
|
||||
|
||||
// Otherwise exceeds range
|
||||
}
|
||||
// Check for 0o[0-9]+
|
||||
else if (str[0] == '0' &&
|
||||
str.Length > 2 &&
|
||||
str[1] == 'o' &&
|
||||
str.Skip(2).All(x => x >= '0' && x <= '7'))
|
||||
{
|
||||
// Try parse
|
||||
var integer = default(Int32?);
|
||||
try
|
||||
{
|
||||
integer = Convert.ToInt32(str.Substring(2), 8);
|
||||
}
|
||||
// Otherwise exceeds range
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
|
||||
// Success
|
||||
if (integer != null)
|
||||
{
|
||||
return (Double)integer.Value;
|
||||
}
|
||||
}
|
||||
// Infinity
|
||||
else if (String.Equals(str, ExpressionConstants.Infinity, StringComparison.Ordinal))
|
||||
{
|
||||
return Double.PositiveInfinity;
|
||||
}
|
||||
// -Infinity
|
||||
else if (String.Equals(str, ExpressionConstants.NegativeInfinity, StringComparison.Ordinal))
|
||||
{
|
||||
return Double.NegativeInfinity;
|
||||
}
|
||||
|
||||
// Otherwise NaN
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
public static String StringEscape(String value)
|
||||
{
|
||||
return String.IsNullOrEmpty(value) ? String.Empty : value.Replace("'", "''");
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/Sdk/Expressions/Sdk/Function.cs
Normal file
43
src/Sdk/Expressions/Sdk/Function.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
public abstract class Function : Container
|
||||
{
|
||||
/// <summary>
|
||||
/// Generally this should not be overridden. True indicates the result of the node is traced as part of the
|
||||
/// "expanded" trace information. Otherwise the node expression is printed, and parameters to the node may or
|
||||
/// may not be fully expanded - depending on each respective parameter's trace-fully-expanded setting.
|
||||
///
|
||||
/// The purpose is so the end user can understand how their expression expanded at run time. For example, consider
|
||||
/// the expression: eq(variables.publish, 'true'). The runtime-expanded expression may be: eq('true', 'true')
|
||||
/// </summary>
|
||||
protected override Boolean TraceFullyExpanded => true;
|
||||
|
||||
public sealed override String ConvertToExpression()
|
||||
{
|
||||
return String.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}({1})",
|
||||
Name,
|
||||
String.Join(", ", Parameters.Select(x => x.ConvertToExpression())));
|
||||
}
|
||||
|
||||
internal sealed override String ConvertToExpandedExpression(EvaluationContext context)
|
||||
{
|
||||
// Check if the result was stored
|
||||
if (context.TryGetTraceResult(this, out String result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return String.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}({1})",
|
||||
Name,
|
||||
String.Join(", ", Parameters.Select(x => x.ConvertToExpandedExpression(context))));
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/Sdk/Expressions/Sdk/Functions/Contains.cs
Normal file
46
src/Sdk/Expressions/Sdk/Functions/Contains.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
internal sealed class Contains : Function
|
||||
{
|
||||
protected sealed override Boolean TraceFullyExpanded => false;
|
||||
|
||||
protected sealed override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var left = Parameters[0].Evaluate(context);
|
||||
if (left.IsPrimitive)
|
||||
{
|
||||
var leftString = left.ConvertToString();
|
||||
|
||||
var right = Parameters[1].Evaluate(context);
|
||||
if (right.IsPrimitive)
|
||||
{
|
||||
var rightString = right.ConvertToString();
|
||||
return leftString.IndexOf(rightString, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
else if (left.TryGetCollectionInterface(out var collection) &&
|
||||
collection is IReadOnlyArray array &&
|
||||
array.Count > 0)
|
||||
{
|
||||
var right = Parameters[1].Evaluate(context);
|
||||
foreach (var item in array)
|
||||
{
|
||||
var itemResult = EvaluationResult.CreateIntermediateResult(context, item);
|
||||
if (right.AbstractEqual(itemResult))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Sdk/Expressions/Sdk/Functions/EndsWith.cs
Normal file
32
src/Sdk/Expressions/Sdk/Functions/EndsWith.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
internal sealed class EndsWith : Function
|
||||
{
|
||||
protected sealed override Boolean TraceFullyExpanded => false;
|
||||
|
||||
protected sealed override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var left = Parameters[0].Evaluate(context);
|
||||
if (left.IsPrimitive)
|
||||
{
|
||||
var leftString = left.ConvertToString();
|
||||
|
||||
var right = Parameters[1].Evaluate(context);
|
||||
if (right.IsPrimitive)
|
||||
{
|
||||
var rightString = right.ConvertToString();
|
||||
return leftString.EndsWith(rightString, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
299
src/Sdk/Expressions/Sdk/Functions/Format.cs
Normal file
299
src/Sdk/Expressions/Sdk/Functions/Format.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
public sealed class Format : Function
|
||||
{
|
||||
protected sealed override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var format = Parameters[0].Evaluate(context).ConvertToString();
|
||||
var index = 0;
|
||||
var result = new FormatResultBuilder(this, context, CreateMemoryCounter(context));
|
||||
while (index < format.Length)
|
||||
{
|
||||
var lbrace = format.IndexOf('{', index);
|
||||
var rbrace = format.IndexOf('}', index);
|
||||
|
||||
// Left brace
|
||||
if (lbrace >= 0 && (rbrace < 0 || rbrace > lbrace))
|
||||
{
|
||||
// Escaped left brace
|
||||
if (SafeCharAt(format, lbrace + 1) == '{')
|
||||
{
|
||||
result.Append(format.Substring(index, lbrace - index + 1));
|
||||
index = lbrace + 2;
|
||||
}
|
||||
// Left brace, number, optional format specifiers, right brace
|
||||
else if (rbrace > lbrace + 1 &&
|
||||
ReadArgIndex(format, lbrace + 1, out Byte argIndex, out Int32 endArgIndex) &&
|
||||
ReadFormatSpecifiers(format, endArgIndex + 1, out String formatSpecifiers, out rbrace))
|
||||
{
|
||||
// Check parameter count
|
||||
if (argIndex > Parameters.Count - 2)
|
||||
{
|
||||
throw new FormatException(ExpressionResources.InvalidFormatArgIndex(format));
|
||||
}
|
||||
|
||||
// Append the portion before the left brace
|
||||
if (lbrace > index)
|
||||
{
|
||||
result.Append(format.Substring(index, lbrace - index));
|
||||
}
|
||||
|
||||
// Append the arg
|
||||
result.Append(argIndex, formatSpecifiers);
|
||||
index = rbrace + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new FormatException(ExpressionResources.InvalidFormatString(format));
|
||||
}
|
||||
}
|
||||
// Right brace
|
||||
else if (rbrace >= 0)
|
||||
{
|
||||
// Escaped right brace
|
||||
if (SafeCharAt(format, rbrace + 1) == '}')
|
||||
{
|
||||
result.Append(format.Substring(index, rbrace - index + 1));
|
||||
index = rbrace + 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new FormatException(ExpressionResources.InvalidFormatString(format));
|
||||
}
|
||||
}
|
||||
// Last segment
|
||||
else
|
||||
{
|
||||
result.Append(format.Substring(index));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private Boolean ReadArgIndex(
|
||||
String str,
|
||||
Int32 startIndex,
|
||||
out Byte result,
|
||||
out Int32 endIndex)
|
||||
{
|
||||
// Count the number of digits
|
||||
var length = 0;
|
||||
while (Char.IsDigit(SafeCharAt(str, startIndex + length)))
|
||||
{
|
||||
length++;
|
||||
}
|
||||
|
||||
// Validate at least one digit
|
||||
if (length < 1)
|
||||
{
|
||||
result = default;
|
||||
endIndex = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the number
|
||||
endIndex = startIndex + length - 1;
|
||||
return Byte.TryParse(str.Substring(startIndex, length), NumberStyles.None, CultureInfo.InvariantCulture, out result);
|
||||
}
|
||||
|
||||
private Boolean ReadFormatSpecifiers(
|
||||
String str,
|
||||
Int32 startIndex,
|
||||
out String result,
|
||||
out Int32 rbrace)
|
||||
{
|
||||
// No format specifiers
|
||||
var c = SafeCharAt(str, startIndex);
|
||||
if (c == '}')
|
||||
{
|
||||
result = String.Empty;
|
||||
rbrace = startIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate starts with ":"
|
||||
if (c != ':')
|
||||
{
|
||||
result = default;
|
||||
rbrace = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read the specifiers
|
||||
var specifiers = new StringBuilder();
|
||||
var index = startIndex + 1;
|
||||
while (true)
|
||||
{
|
||||
// Validate not the end of the string
|
||||
if (index >= str.Length)
|
||||
{
|
||||
result = default;
|
||||
rbrace = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
c = str[index];
|
||||
|
||||
// Not right-brace
|
||||
if (c != '}')
|
||||
{
|
||||
specifiers.Append(c);
|
||||
index++;
|
||||
}
|
||||
// Escaped right-brace
|
||||
else if (SafeCharAt(str, index + 1) == '}')
|
||||
{
|
||||
specifiers.Append('}');
|
||||
index += 2;
|
||||
}
|
||||
// Closing right-brace
|
||||
else
|
||||
{
|
||||
result = specifiers.ToString();
|
||||
rbrace = index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Char SafeCharAt(
|
||||
String str,
|
||||
Int32 index)
|
||||
{
|
||||
if (str.Length > index)
|
||||
{
|
||||
return str[index];
|
||||
}
|
||||
|
||||
return '\0';
|
||||
}
|
||||
|
||||
private sealed class FormatResultBuilder
|
||||
{
|
||||
internal FormatResultBuilder(
|
||||
Format node,
|
||||
EvaluationContext context,
|
||||
MemoryCounter counter)
|
||||
{
|
||||
m_node = node;
|
||||
m_context = context;
|
||||
m_counter = counter;
|
||||
m_cache = new ArgValue[node.Parameters.Count - 1];
|
||||
}
|
||||
|
||||
// Build the final string. This is when lazy segments are evaluated.
|
||||
public override String ToString()
|
||||
{
|
||||
return String.Join(
|
||||
String.Empty,
|
||||
m_segments.Select(obj =>
|
||||
{
|
||||
if (obj is Lazy<String> lazy)
|
||||
{
|
||||
return lazy.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
return obj as String;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Append a static value
|
||||
internal void Append(String value)
|
||||
{
|
||||
if (value?.Length > 0)
|
||||
{
|
||||
// Track memory
|
||||
m_counter.Add(value);
|
||||
|
||||
// Append the segment
|
||||
m_segments.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Append an argument
|
||||
internal void Append(
|
||||
Int32 argIndex,
|
||||
String formatSpecifiers)
|
||||
{
|
||||
// Delay execution until the final ToString
|
||||
m_segments.Add(new Lazy<String>(() =>
|
||||
{
|
||||
String result;
|
||||
|
||||
// Get the arg from the cache
|
||||
var argValue = m_cache[argIndex];
|
||||
|
||||
// Evaluate the arg and cache the result
|
||||
if (argValue == null)
|
||||
{
|
||||
// The evaluation result is required when format specifiers are used. Otherwise the string
|
||||
// result is required. Go ahead and store both values. Since ConvertToString produces tracing,
|
||||
// we need to run that now so the tracing appears in order in the log.
|
||||
var evaluationResult = m_node.Parameters[argIndex + 1].Evaluate(m_context);
|
||||
var stringResult = evaluationResult.ConvertToString();
|
||||
argValue = new ArgValue(evaluationResult, stringResult);
|
||||
m_cache[argIndex] = argValue;
|
||||
}
|
||||
|
||||
// No format specifiers
|
||||
if (String.IsNullOrEmpty(formatSpecifiers))
|
||||
{
|
||||
result = argValue.StringResult;
|
||||
}
|
||||
// Invalid
|
||||
else
|
||||
{
|
||||
throw new FormatException(ExpressionResources.InvalidFormatSpecifiers(formatSpecifiers, argValue.EvaluationResult.Kind));
|
||||
}
|
||||
|
||||
// Track memory
|
||||
if (!String.IsNullOrEmpty(result))
|
||||
{
|
||||
m_counter.Add(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
|
||||
private readonly ArgValue[] m_cache;
|
||||
private readonly EvaluationContext m_context;
|
||||
private readonly MemoryCounter m_counter;
|
||||
private readonly Format m_node;
|
||||
private readonly List<Object> m_segments = new List<Object>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores an EvaluateResult and the value converted to a String.
|
||||
/// </summary>
|
||||
private sealed class ArgValue
|
||||
{
|
||||
public ArgValue(
|
||||
EvaluationResult evaluationResult,
|
||||
String stringResult)
|
||||
{
|
||||
EvaluationResult = evaluationResult;
|
||||
StringResult = stringResult;
|
||||
}
|
||||
|
||||
public EvaluationResult EvaluationResult { get; }
|
||||
|
||||
public String StringResult { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/Sdk/Expressions/Sdk/Functions/FromJson.cs
Normal file
49
src/Sdk/Expressions/Sdk/Functions/FromJson.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using GitHub.Actions.Expressions.Data;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
internal sealed class FromJson : Function
|
||||
{
|
||||
protected sealed override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var json = Parameters[0].Evaluate(context).ConvertToString();
|
||||
|
||||
if (context.Options.StrictJsonParsing)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonParser.Parse(json);
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
throw new System.Text.Json.JsonException($"Error parsing fromJson: {ex.Message}", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new System.Text.Json.JsonException($"Unexpected error parsing fromJson: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stringReader = new StringReader(json);
|
||||
using var jsonReader = new JsonTextReader(stringReader) { DateParseHandling = DateParseHandling.None, FloatParseHandling = FloatParseHandling.Double };
|
||||
var token = JToken.ReadFrom(jsonReader);
|
||||
return token.ToExpressionData();
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
throw new JsonReaderException("Error parsing fromJson", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/Sdk/Expressions/Sdk/Functions/Join.cs
Normal file
76
src/Sdk/Expressions/Sdk/Functions/Join.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
internal sealed class Join : Function
|
||||
{
|
||||
protected sealed override Boolean TraceFullyExpanded => true;
|
||||
|
||||
protected sealed override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var items = Parameters[0].Evaluate(context);
|
||||
|
||||
// Array
|
||||
if (items.TryGetCollectionInterface(out var collection) &&
|
||||
collection is IReadOnlyArray array &&
|
||||
array.Count > 0)
|
||||
{
|
||||
var result = new StringBuilder();
|
||||
var memory = new MemoryCounter(this, context.Options.MaxMemory);
|
||||
|
||||
// Append the first item
|
||||
var item = array[0];
|
||||
var itemResult = EvaluationResult.CreateIntermediateResult(context, item);
|
||||
var itemString = itemResult.ConvertToString();
|
||||
memory.Add(itemString);
|
||||
result.Append(itemString);
|
||||
|
||||
// More items?
|
||||
if (array.Count > 1)
|
||||
{
|
||||
var separator = ",";
|
||||
if (Parameters.Count > 1)
|
||||
{
|
||||
var separatorResult = Parameters[1].Evaluate(context);
|
||||
if (separatorResult.IsPrimitive)
|
||||
{
|
||||
separator = separatorResult.ConvertToString();
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 1; i < array.Count; i++)
|
||||
{
|
||||
// Append the separator
|
||||
memory.Add(separator);
|
||||
result.Append(separator);
|
||||
|
||||
// Append the next item
|
||||
var nextItem = array[i];
|
||||
var nextItemResult = EvaluationResult.CreateIntermediateResult(context, nextItem);
|
||||
var nextItemString = nextItemResult.ConvertToString();
|
||||
memory.Add(nextItemString);
|
||||
result.Append(nextItemString);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
// Primitive
|
||||
else if (items.IsPrimitive)
|
||||
{
|
||||
return items.ConvertToString();
|
||||
}
|
||||
// Otherwise return empty string
|
||||
else
|
||||
{
|
||||
return String.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/Sdk/Expressions/Sdk/Functions/JsonParser.cs
Normal file
125
src/Sdk/Expressions/Sdk/Functions/JsonParser.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GitHub.Actions.Expressions.Data;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
internal sealed class JsonParser
|
||||
{
|
||||
public static ExpressionData Parse(string json)
|
||||
{
|
||||
var reader = new Utf8JsonReader(
|
||||
Encoding.UTF8.GetBytes(json),
|
||||
new JsonReaderOptions{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow,
|
||||
MaxDepth = 100,
|
||||
});
|
||||
|
||||
// EOF?
|
||||
if (!reader.Read())
|
||||
{
|
||||
throw new Exception("Expected at least one JSON token");
|
||||
}
|
||||
|
||||
// Read
|
||||
var result = ReadRecursive(ref reader);
|
||||
|
||||
// Not EOF?
|
||||
if (reader.Read())
|
||||
{
|
||||
throw new Exception($"Expected end of JSON but encountered '{reader.TokenType}'");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ExpressionData ReadRecursive(ref Utf8JsonReader reader)
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.StartArray:
|
||||
return ReadArray(ref reader);
|
||||
case JsonTokenType.StartObject:
|
||||
return ReadObject(ref reader);
|
||||
case JsonTokenType.Null:
|
||||
return null;
|
||||
case JsonTokenType.False:
|
||||
return new BooleanExpressionData(false);
|
||||
case JsonTokenType.True:
|
||||
return new BooleanExpressionData(true);
|
||||
case JsonTokenType.Number:
|
||||
return new NumberExpressionData(reader.GetDouble());
|
||||
case JsonTokenType.String:
|
||||
return new StringExpressionData(reader.GetString());
|
||||
default:
|
||||
throw new Exception($"Unexpected token type '{reader.TokenType}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static ArrayExpressionData ReadArray(ref Utf8JsonReader reader)
|
||||
{
|
||||
var result = new ArrayExpressionData();
|
||||
while (reader.Read())
|
||||
{
|
||||
// End array
|
||||
if (reader.TokenType == JsonTokenType.EndArray)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Item
|
||||
result.Add(ReadRecursive(ref reader));
|
||||
}
|
||||
|
||||
// EOF
|
||||
throw new Exception($"Unexpected end of JSON while reading array");
|
||||
}
|
||||
|
||||
private static DictionaryExpressionData ReadObject(ref Utf8JsonReader reader)
|
||||
{
|
||||
var result = new DictionaryExpressionData();
|
||||
while (reader.Read())
|
||||
{
|
||||
var key = null as string;
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
// End object
|
||||
case JsonTokenType.EndObject:
|
||||
return result;
|
||||
|
||||
// Property name
|
||||
case JsonTokenType.PropertyName:
|
||||
key = reader.GetString();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception($"Unexpected token type '{reader.TokenType}' while reading object");
|
||||
}
|
||||
|
||||
// Value
|
||||
var value = null as ExpressionData;
|
||||
if (reader.Read())
|
||||
{
|
||||
value = ReadRecursive(ref reader);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Unexpected end of JSON when reading object-pair value");
|
||||
}
|
||||
|
||||
// Add
|
||||
if (!result.ContainsKey(key))
|
||||
{
|
||||
result.Add(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// EOF
|
||||
throw new Exception($"Unexpected end of JSON while reading object");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Sdk/Expressions/Sdk/Functions/NoOperation.cs
Normal file
20
src/Sdk/Expressions/Sdk/Functions/NoOperation.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
/// <summary>
|
||||
/// Useful when validating an expression
|
||||
/// </summary>
|
||||
public sealed class NoOperation : Function
|
||||
{
|
||||
protected override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Sdk/Expressions/Sdk/Functions/StartsWith.cs
Normal file
32
src/Sdk/Expressions/Sdk/Functions/StartsWith.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
internal sealed class StartsWith : Function
|
||||
{
|
||||
protected sealed override Boolean TraceFullyExpanded => false;
|
||||
|
||||
protected sealed override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var left = Parameters[0].Evaluate(context);
|
||||
if (left.IsPrimitive)
|
||||
{
|
||||
var leftString = left.ConvertToString();
|
||||
|
||||
var right = Parameters[1].Evaluate(context);
|
||||
if (right.IsPrimitive)
|
||||
{
|
||||
var rightString = right.ConvertToString();
|
||||
return leftString.StartsWith(rightString, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
392
src/Sdk/Expressions/Sdk/Functions/ToJson.cs
Normal file
392
src/Sdk/Expressions/Sdk/Functions/ToJson.cs
Normal file
@@ -0,0 +1,392 @@
|
||||
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk.Functions
|
||||
{
|
||||
internal sealed class ToJson : Function
|
||||
{
|
||||
protected sealed override Object EvaluateCore(
|
||||
EvaluationContext context,
|
||||
out ResultMemory resultMemory)
|
||||
{
|
||||
resultMemory = null;
|
||||
var result = new StringBuilder();
|
||||
var memory = new MemoryCounter(this, context.Options.MaxMemory);
|
||||
var current = Parameters[0].Evaluate(context);
|
||||
var ancestors = new Stack<ICollectionEnumerator>();
|
||||
|
||||
do
|
||||
{
|
||||
// Descend as much as possible
|
||||
while (true)
|
||||
{
|
||||
// Collection
|
||||
if (current.TryGetCollectionInterface(out Object collection))
|
||||
{
|
||||
// Array
|
||||
if (collection is IReadOnlyArray array)
|
||||
{
|
||||
if (array.Count > 0)
|
||||
{
|
||||
// Write array start
|
||||
WriteArrayStart(result, memory, ancestors);
|
||||
|
||||
// Move to first item
|
||||
var enumerator = new ArrayEnumerator(context, current, array);
|
||||
enumerator.MoveNext();
|
||||
ancestors.Push(enumerator);
|
||||
current = enumerator.Current;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Write empty array
|
||||
WriteEmptyArray(result, memory, ancestors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Mapping
|
||||
else if (collection is IReadOnlyObject obj)
|
||||
{
|
||||
if (obj.Count > 0)
|
||||
{
|
||||
// Write mapping start
|
||||
WriteMappingStart(result, memory, ancestors);
|
||||
|
||||
// Move to first pair
|
||||
var enumerator = new ObjectEnumerator(context, current, obj);
|
||||
enumerator.MoveNext();
|
||||
ancestors.Push(enumerator);
|
||||
|
||||
// Write mapping key
|
||||
WriteMappingKey(context, result, memory, enumerator.Current.Key, ancestors);
|
||||
|
||||
// Move to mapping value
|
||||
current = enumerator.Current.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Write empty mapping
|
||||
WriteEmptyMapping(result, memory, ancestors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Unexpected type '{collection?.GetType().FullName}'");
|
||||
}
|
||||
}
|
||||
// Not a collection
|
||||
else
|
||||
{
|
||||
// Write value
|
||||
WriteValue(context, result, memory, current, ancestors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Next sibling or ancestor sibling
|
||||
do
|
||||
{
|
||||
if (ancestors.Count > 0)
|
||||
{
|
||||
var parent = ancestors.Peek();
|
||||
|
||||
// Parent array
|
||||
if (parent is ArrayEnumerator arrayEnumerator)
|
||||
{
|
||||
// Move to next item
|
||||
if (arrayEnumerator.MoveNext())
|
||||
{
|
||||
current = arrayEnumerator.Current;
|
||||
|
||||
break;
|
||||
}
|
||||
// Move to parent
|
||||
else
|
||||
{
|
||||
ancestors.Pop();
|
||||
current = arrayEnumerator.Array;
|
||||
|
||||
// Write array end
|
||||
WriteArrayEnd(result, memory, ancestors);
|
||||
}
|
||||
}
|
||||
// Parent mapping
|
||||
else if (parent is ObjectEnumerator objectEnumerator)
|
||||
{
|
||||
// Move to next pair
|
||||
if (objectEnumerator.MoveNext())
|
||||
{
|
||||
// Write mapping key
|
||||
WriteMappingKey(context, result, memory, objectEnumerator.Current.Key, ancestors);
|
||||
|
||||
// Move to mapping value
|
||||
current = objectEnumerator.Current.Value;
|
||||
|
||||
break;
|
||||
}
|
||||
// Move to parent
|
||||
else
|
||||
{
|
||||
ancestors.Pop();
|
||||
current = objectEnumerator.Object;
|
||||
|
||||
// Write mapping end
|
||||
WriteMappingEnd(result, memory, ancestors);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Unexpected type '{parent?.GetType().FullName}'");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current = null;
|
||||
}
|
||||
|
||||
} while (current != null);
|
||||
|
||||
} while (current != null);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private void WriteArrayStart(
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
var str = PrefixValue("[", ancestors);
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private void WriteMappingStart(
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
var str = PrefixValue("{", ancestors);
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private void WriteArrayEnd(
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
var str = $"\n{new String(' ', ancestors.Count * 2)}]";
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private void WriteMappingEnd(
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
var str = $"\n{new String(' ', ancestors.Count * 2)}}}";
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private void WriteEmptyArray(
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
var str = PrefixValue("[]", ancestors);
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private void WriteEmptyMapping(
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
var str = PrefixValue("{}", ancestors);
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private void WriteMappingKey(
|
||||
EvaluationContext context,
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
EvaluationResult key,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
var str = PrefixValue(JsonConvert.ToString(key.ConvertToString()), ancestors, isMappingKey: true);
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private void WriteValue(
|
||||
EvaluationContext context,
|
||||
StringBuilder writer,
|
||||
MemoryCounter memory,
|
||||
EvaluationResult value,
|
||||
Stack<ICollectionEnumerator> ancestors)
|
||||
{
|
||||
String str;
|
||||
switch (value.Kind)
|
||||
{
|
||||
case ValueKind.Null:
|
||||
str = "null";
|
||||
break;
|
||||
|
||||
case ValueKind.Boolean:
|
||||
str = (Boolean)value.Value ? "true" : "false";
|
||||
break;
|
||||
|
||||
case ValueKind.Number:
|
||||
str = value.ConvertToString();
|
||||
break;
|
||||
|
||||
case ValueKind.String:
|
||||
str = JsonConvert.ToString(value.Value);
|
||||
break;
|
||||
|
||||
default:
|
||||
str = "{}"; // The value is an object we don't know how to traverse
|
||||
break;
|
||||
}
|
||||
|
||||
str = PrefixValue(str, ancestors);
|
||||
memory.Add(str);
|
||||
writer.Append(str);
|
||||
}
|
||||
|
||||
private String PrefixValue(
|
||||
String value,
|
||||
Stack<ICollectionEnumerator> ancestors,
|
||||
Boolean isMappingKey = false)
|
||||
{
|
||||
var level = ancestors.Count;
|
||||
var parent = level > 0 ? ancestors.Peek() : null;
|
||||
|
||||
if (!isMappingKey && parent is ObjectEnumerator)
|
||||
{
|
||||
return $": {value}";
|
||||
}
|
||||
else if (level > 0)
|
||||
{
|
||||
return $"{(parent.IsFirst ? String.Empty : ",")}\n{new String(' ', level * 2)}{value}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private interface ICollectionEnumerator : IEnumerator
|
||||
{
|
||||
Boolean IsFirst { get; }
|
||||
}
|
||||
|
||||
private sealed class ArrayEnumerator : ICollectionEnumerator
|
||||
{
|
||||
public ArrayEnumerator(
|
||||
EvaluationContext context,
|
||||
EvaluationResult result,
|
||||
IReadOnlyArray array)
|
||||
{
|
||||
m_context = context;
|
||||
m_result = result;
|
||||
m_enumerator = array.GetEnumerator();
|
||||
}
|
||||
|
||||
public EvaluationResult Array => m_result;
|
||||
|
||||
public EvaluationResult Current => m_current;
|
||||
|
||||
Object IEnumerator.Current => m_current;
|
||||
|
||||
public Boolean IsFirst => m_index == 0;
|
||||
|
||||
public Boolean MoveNext()
|
||||
{
|
||||
if (m_enumerator.MoveNext())
|
||||
{
|
||||
m_current = EvaluationResult.CreateIntermediateResult(m_context, m_enumerator.Current);
|
||||
m_index++;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_current = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
throw new NotSupportedException(nameof(Reset));
|
||||
}
|
||||
|
||||
private readonly EvaluationContext m_context;
|
||||
private readonly IEnumerator m_enumerator;
|
||||
private readonly EvaluationResult m_result;
|
||||
private EvaluationResult m_current;
|
||||
private Int32 m_index = -1;
|
||||
}
|
||||
|
||||
private sealed class ObjectEnumerator : ICollectionEnumerator
|
||||
{
|
||||
public ObjectEnumerator(
|
||||
EvaluationContext context,
|
||||
EvaluationResult result,
|
||||
IReadOnlyObject obj)
|
||||
{
|
||||
m_context = context;
|
||||
m_result = result;
|
||||
m_enumerator = obj.GetEnumerator();
|
||||
}
|
||||
|
||||
public KeyValuePair<EvaluationResult, EvaluationResult> Current => m_current;
|
||||
|
||||
Object IEnumerator.Current => m_current;
|
||||
|
||||
public Boolean IsFirst => m_index == 0;
|
||||
|
||||
public EvaluationResult Object => m_result;
|
||||
|
||||
public Boolean MoveNext()
|
||||
{
|
||||
if (m_enumerator.MoveNext())
|
||||
{
|
||||
var current = (KeyValuePair<String, Object>)m_enumerator.Current;
|
||||
var key = EvaluationResult.CreateIntermediateResult(m_context, current.Key);
|
||||
var value = EvaluationResult.CreateIntermediateResult(m_context, current.Value);
|
||||
m_current = new KeyValuePair<EvaluationResult, EvaluationResult>(key, value);
|
||||
m_index++;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_current = default(KeyValuePair<EvaluationResult, EvaluationResult>);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
throw new NotSupportedException(nameof(Reset));
|
||||
}
|
||||
|
||||
private readonly EvaluationContext m_context;
|
||||
private readonly IEnumerator m_enumerator;
|
||||
private readonly EvaluationResult m_result;
|
||||
private KeyValuePair<EvaluationResult, EvaluationResult> m_current;
|
||||
private Int32 m_index = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/Sdk/Expressions/Sdk/IBoolean.cs
Normal file
9
src/Sdk/Expressions/Sdk/IBoolean.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Actions.Expressions.Sdk
|
||||
{
|
||||
public interface IBoolean
|
||||
{
|
||||
Boolean GetBoolean();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user