diff --git a/.github/actionlint-version.json b/.github/actionlint-version.json new file mode 100644 index 00000000000..d552f1df24a --- /dev/null +++ b/.github/actionlint-version.json @@ -0,0 +1,4 @@ +{ + "version": "1.7.12", + "linuxAmd64Sha256": "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" +} diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index adeac22a189..8480c643823 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,3 +1,7 @@ +self-hosted-runner: + labels: + - 8-core-ubuntu-latest + paths: .github/workflows/{extension-changelog,pr-docs-check,release-notes-generate,release-update-support-mdx}.lock.yml: ignore: diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml index d58dd17aeb8..2259ea494ad 100644 --- a/.github/actions/create-pull-request/action.yml +++ b/.github/actions/create-pull-request/action.yml @@ -33,6 +33,10 @@ inputs: description: 'Create the pull request as a draft' required: false default: 'false' + update-existing-pr-metadata: + description: 'Update the title and body when the pull request already exists' + required: false + default: 'false' outputs: pull-request-number: description: 'The pull request number' @@ -96,6 +100,7 @@ runs: PR_BODY: ${{ inputs.body }} LABELS: ${{ inputs.labels }} DRAFT: ${{ inputs.draft }} + UPDATE_EXISTING_PR_METADATA: ${{ inputs.update-existing-pr-metadata }} run: | # Check if a PR already exists for this branch EXISTING_PR=$(gh pr list --head "$BRANCH" --base "$BASE" --json number,url --jq '.[0] // empty') @@ -104,9 +109,14 @@ runs: PR_NUMBER=$(echo "$EXISTING_PR" | jq -r '.number') PR_URL=$(echo "$EXISTING_PR" | jq -r '.url') echo "Pull request #$PR_NUMBER already exists: $PR_URL" - echo "pull-request-number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "pull-request-url=$PR_URL" >> $GITHUB_OUTPUT - echo "pull-request-operation=none" >> $GITHUB_OUTPUT + echo "pull-request-number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "pull-request-url=$PR_URL" >> "$GITHUB_OUTPUT" + if [ "$UPDATE_EXISTING_PR_METADATA" = "true" ]; then + gh pr edit "$PR_NUMBER" --title "$PR_TITLE" --body-file - <<< "$PR_BODY" + echo "pull-request-operation=updated" >> "$GITHUB_OUTPUT" + else + echo "pull-request-operation=none" >> "$GITHUB_OUTPUT" + fi else # Build label arguments as a bash array (avoids eval/injection). # Filter out any labels that don't exist in the repo — `gh pr create` @@ -157,7 +167,7 @@ runs: PR_NUMBER=$(gh pr view "$BRANCH" --json number --jq '.number') echo "Created pull request #$PR_NUMBER: $PR_URL" - echo "pull-request-number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "pull-request-url=$PR_URL" >> $GITHUB_OUTPUT - echo "pull-request-operation=created" >> $GITHUB_OUTPUT + echo "pull-request-number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "pull-request-url=$PR_URL" >> "$GITHUB_OUTPUT" + echo "pull-request-operation=created" >> "$GITHUB_OUTPUT" fi diff --git a/.github/actions/enumerate-tests/action.yml b/.github/actions/enumerate-tests/action.yml index cf566fed220..769a31733a8 100644 --- a/.github/actions/enumerate-tests/action.yml +++ b/.github/actions/enumerate-tests/action.yml @@ -3,7 +3,6 @@ description: 'Enumerate list of test projects' inputs: buildArgs: required: false - type: string default: '' description: 'Additional MSBuild arguments passed to the test matrix generation step (e.g., /p:IncludeTemplateTests=true /p:OnlyDeploymentTests=true)' checkout: diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 3f7aa5005b0..5ebeac87a29 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -39,8 +39,22 @@ workflow sources still trigger it even before their generated locks exist. Compile-time lint diagnostics alone are not a blocking gate. The lint command owns the actionlint image and compatibility exceptions; `.github/actionlint.yaml` -only scopes the known stale-check output workaround to the affected generated -workflows. +is shared by both lint paths: it configures the runner label for handwritten +workflow linting and scopes the known stale-check output workaround to the +affected generated workflows. Main CI separately runs pinned core actionlint +over handwritten workflows and their local actions through +`lint-handwritten-workflows.sh`, which selects the files and excludes gh-aw output. +The version and linux_amd64 archive SHA-256 are pinned in `.github/actionlint-version.json`, +outside `.github/workflows`, so the Aspire bot can update them without workflow-write access. +The weekly `update-actionlint.yml` workflow opens a draft PR when a newer release exists. It +verifies the downloaded archive against the release API `sha256:` digest and lints the +handwritten workflows with the candidate before proposing the bump. That digest comes from the +same upstream release, so review the release notes; the committed hash then makes CI detect any +later substitution of the archive. Manual dispatch defaults to `validate`, which does not change +the updater branch or open a PR and is safe to run from a fork; `propose` runs only from the +upstream repository's default branch and follows the scheduled PR-update path when a newer release +is available. Scheduled and `propose` runs are serialized end to end by a shared concurrency +group, so each run picks its release only after the previous run has pushed. Explicit action versions in Markdown survive recompilation, so update deprecated inputs and action runtimes in the sources, not just the generated YAML. The diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e64e5d5008..4c71aab8963 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,37 @@ concurrency: jobs: + actionlint: + name: Lint handwritten workflows + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The pin lives outside .github/workflows so the scheduled updater + # (update-actionlint.yml) can propose bumps without workflow-write access. + - name: Install actionlint + run: | + ACTIONLINT_VERSION=$(jq -er '.version' .github/actionlint-version.json) + ACTIONLINT_LINUX_AMD64_SHA256=$(jq -er '.linuxAmd64Sha256' .github/actionlint-version.json) + if [[ ! "${ACTIONLINT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid actionlint version '${ACTIONLINT_VERSION}' in .github/actionlint-version.json" + exit 1 + fi + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl --fail --location --retry 3 --retry-all-errors \ + --output "${archive}" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" + echo "${ACTIONLINT_LINUX_AMD64_SHA256} ${archive}" | sha256sum --check - + tar -xzf "${archive}" -C "${RUNNER_TEMP}" actionlint + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + + - name: Lint handwritten workflow files + run: bash .github/workflows/lint-handwritten-workflows.sh + prepare_for_ci: runs-on: ubuntu-latest name: Prepare for CI @@ -177,20 +208,21 @@ jobs: if: ${{ always() && github.repository_owner == 'microsoft' }} runs-on: ubuntu-latest name: Final Results - needs: [prepare_for_ci, tests, stabilization_check] + needs: [actionlint, prepare_for_ci, tests, stabilization_check] steps: - name: Fail if any of the dependent jobs failed - # Don't fail if the workflow is being skipped. + # Build and test jobs may be skipped, but handwritten workflow lint remains required. if: >- ${{ always() && - needs.prepare_for_ci.outputs.skip_workflow != 'true' && - (contains(needs.*.result, 'failure') || - contains(needs.*.result, 'cancelled') || - needs.tests.result != 'success' || - (needs.prepare_for_ci.outputs.STABILIZATION_ENABLED == 'true' && - github.event_name == 'pull_request' && - needs.stabilization_check.result != 'success')) }} + (needs.actionlint.result != 'success' || + (needs.prepare_for_ci.outputs.skip_workflow != 'true' && + (contains(needs.*.result, 'failure') || + contains(needs.*.result, 'cancelled') || + needs.tests.result != 'success' || + (needs.prepare_for_ci.outputs.STABILIZATION_ENABLED == 'true' && + github.event_name == 'pull_request' && + needs.stabilization_check.result != 'success')))) }} run: | echo "One or more dependent jobs failed." exit 1 @@ -205,13 +237,13 @@ jobs: # CI_RED / CI_GREEN are derived from explicit needs.*.result checks rather than # failure()/success() because tests are *skipped* on the # no-relevant-changes path (skip_workflow): a skipped run is neither red nor green, - # so the script leaves any open issue untouched (no CI signal). Requiring both + # so the script leaves any open issue untouched (no CI signal). Requiring actionlint, # prepare_for_ci and tests to have *succeeded* for CI_GREEN means a docs-only push # never closes a still-red issue without actually re-running CI. Stabilization is # PR-only and cannot contribute a success result to this push-only tracker. ci_failure_tracker: name: Track CI Failure - needs: [prepare_for_ci, tests, stabilization_check] + needs: [actionlint, prepare_for_ci, tests, stabilization_check] if: ${{ always() && github.event_name == 'push' && github.repository_owner == 'microsoft' }} runs-on: ubuntu-latest permissions: @@ -228,7 +260,8 @@ jobs: REF: ${{ github.ref_name }} CI_RED: ${{ contains(needs.*.result, 'failure') }} CI_GREEN: >- - ${{ needs.prepare_for_ci.result == 'success' && + ${{ needs.actionlint.result == 'success' && + needs.prepare_for_ci.result == 'success' && needs.tests.result == 'success' }} with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint-handwritten-workflows.sh b/.github/workflows/lint-handwritten-workflows.sh new file mode 100644 index 00000000000..5a2762609cd --- /dev/null +++ b/.github/workflows/lint-handwritten-workflows.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Lints the handwritten GitHub Actions workflows with the `actionlint` on PATH. +# +# Both ci.yml ("Lint handwritten workflows") and update-actionlint.yml (which +# proves a candidate release before proposing a bump) call this script, so the +# blocking gate and the updater always lint the same file set. +# +# Run from the repository root. Lints top-level .github/workflows/*.yml and +# *.yaml files and excludes gh-aw generated output: +# - *.lock.yml compiled agentic workflows +# - agentics-maintenance*.yml gh-aw side-repo maintenance workflows +# Generated workflows are validated by `gh aw compile` instead. +# +# The shellcheck and pyflakes integrations are disabled so that results do not +# depend on whichever versions the runner image happens to ship. +# +# Exits non-zero when actionlint reports errors or when no files match, so a +# moved or renamed workflow directory cannot silently pass the gate. +set -euo pipefail + +files=() +while IFS= read -r file; do + files+=("$file") +done < <( + find .github/workflows -maxdepth 1 -type f \ + \( -name '*.yml' -o -name '*.yaml' \) \ + ! -name '*.lock.yml' \ + ! -name 'agentics-maintenance*.yml' | + LC_ALL=C sort +) + +if [[ ${#files[@]} -eq 0 ]]; then + echo "::error::No handwritten workflow files found under .github/workflows." >&2 + exit 1 +fi + +echo "Linting ${#files[@]} handwritten workflow files." +actionlint -shellcheck= -pyflakes= "${files[@]}" diff --git a/.github/workflows/monitor-scheduled-workflows.config.json b/.github/workflows/monitor-scheduled-workflows.config.json index 34dd142d965..452eba97c18 100644 --- a/.github/workflows/monitor-scheduled-workflows.config.json +++ b/.github/workflows/monitor-scheduled-workflows.config.json @@ -17,6 +17,7 @@ { "file": "tests-outerloop.yml", "name": "Outerloop Tests", "selfReports": true }, { "file": "tests-quarantine.yml", "name": "Quarantined Tests", "selfReports": true }, { "file": "tests-daily-smoke.yml", "name": "Daily CLI Smoke Tests", "selfReports": true, "labels": ["area-cli"] }, - { "file": "deployment-tests.yml", "name": "Deployment E2E Tests", "selfReports": true, "labels": ["area-testing", "deployment-e2e"] } + { "file": "deployment-tests.yml", "name": "Deployment E2E Tests", "selfReports": true, "labels": ["area-testing", "deployment-e2e"] }, + { "file": "update-actionlint.yml", "name": "Update actionlint", "selfReports": true, "labels": ["area-engineering-systems"] } ] } diff --git a/.github/workflows/report-pipeline-failure.js b/.github/workflows/report-pipeline-failure.js index 54b5506a429..e6e09ca4b88 100644 --- a/.github/workflows/report-pipeline-failure.js +++ b/.github/workflows/report-pipeline-failure.js @@ -1,5 +1,5 @@ -// Nightly-pipeline failure reporter, used by deployment-tests.yml and -// tests-daily-smoke.yml. Invoked from an actions/github-script step via report(). +// Scheduled-pipeline failure reporter, used by deployment-tests.yml, +// tests-daily-smoke.yml, and the weekly update-actionlint.yml. Invoked from an actions/github-script step via report(). // // These scheduled pipelines otherwise fail silently — GitHub only emails whoever // last edited the workflow file. This reporter files a single deduplicated issue @@ -42,13 +42,13 @@ function buildMarker(workflowFile) { } function buildIssueTitle(displayName) { - return `Nightly run failing: ${displayName}`; + return `Scheduled run failing: ${displayName}`; } // Builds the static issue body. Each failed run is recorded as a comment (see // report()), so the body is a fixed description written once at filing. function buildIssueBody({ marker, displayName, workflowFile, cc = '' }) { - const lead = `The scheduled workflow [\`${workflowFile}\`](../../actions/workflows/${workflowFile}) (**${displayName}**) is failing on its nightly run.`; + const lead = `The scheduled workflow [\`${workflowFile}\`](../../actions/workflows/${workflowFile}) (**${displayName}**) is failing on its scheduled run.`; return tracking.buildBody({ marker, diff --git a/.github/workflows/specialized-test-runner.yml b/.github/workflows/specialized-test-runner.yml index 6571562fcc8..921dabf8e68 100644 --- a/.github/workflows/specialized-test-runner.yml +++ b/.github/workflows/specialized-test-runner.yml @@ -22,7 +22,6 @@ on: enablePlaywrightInstall: required: true type: boolean - default: false # When true, ignores test failures and checks if .trx files have nonzero test count ignoreTestFailures: required: false @@ -39,7 +38,6 @@ jobs: runsheet: ${{ steps.inject_properties.outputs.runsheet }} requiresNugets: ${{ steps.check_nugets.outputs.requiresNugets }} requiresCliArchive: ${{ steps.check_cli_archive.outputs.requiresCliArchive }} - requiresGitHubToken: ${{ steps.check_github_token.outputs.requiresGitHubToken }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -106,16 +104,6 @@ jobs: echo "requiresCliArchive=false" >> $GITHUB_OUTPUT fi - - name: Check if any test requires GitHub token - id: check_github_token - run: | - RUNSHEET='${{ steps.generate_tests_matrix.outputs.runsheet }}' - if echo "$RUNSHEET" | jq -e 'any(.[]; .requiresGitHubToken == true)' > /dev/null 2>&1; then - echo "requiresGitHubToken=true" >> $GITHUB_OUTPUT - else - echo "requiresGitHubToken=false" >> $GITHUB_OUTPUT - fi - - name: Upload logs, and test results if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/update-actionlint.js b/.github/workflows/update-actionlint.js new file mode 100644 index 00000000000..e9602d12382 --- /dev/null +++ b/.github/workflows/update-actionlint.js @@ -0,0 +1,230 @@ +'use strict'; + +// Keeps the actionlint pin in .github/actionlint-version.json current. +// +// node update-actionlint.js check +// Compares the latest published rhysd/actionlint release with the pin. When +// newer, downloads the linux_amd64 archive into RUNNER_TEMP, verifies it +// against the release API asset digest, and emits step outputs. Never edits +// the repository. +// +// node update-actionlint.js apply +// Rewrites the pin file with the verified version and SHA-256. +// +// The release API digest comes from the same upstream as the archive, so it is +// not an independent trust anchor. Its purpose is to make the reviewed, +// committed hash match the bytes the release served, so CI detects any later +// substitution of the archive. + +const crypto = require('node:crypto'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const REPOSITORY = 'rhysd/actionlint'; +const LATEST_RELEASE_URL = `https://api.github.com/repos/${REPOSITORY}/releases/latest`; +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA256 = /^[0-9a-f]{64}$/; + +function parseVersion(value, description) { + const match = typeof value === 'string' ? SEMVER.exec(value) : null; + if (!match) { + throw new Error(`${description} '${value}' is not a MAJOR.MINOR.PATCH version.`); + } + + return match.slice(1).map(Number); +} + +function compareVersions(left, right) { + const a = parseVersion(left, 'Version'); + const b = parseVersion(right, 'Version'); + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { + return a[i] < b[i] ? -1 : 1; + } + } + + return 0; +} + +function parseReleaseTag(tag) { + if (typeof tag !== 'string' || !tag.startsWith('v') || !SEMVER.test(tag.slice(1))) { + throw new Error(`Release tag '${tag}' is not of the form vMAJOR.MINOR.PATCH.`); + } + + return tag.slice(1); +} + +// Pin file shape (.github/actionlint-version.json): +// { "version": "1.7.12", "linuxAmd64Sha256": "8aca8db9..." } +function readPin(content) { + let pin; + try { + pin = JSON.parse(content); + } catch (error) { + throw new Error(`Pin file is not valid JSON: ${error.message}`); + } + + const keys = Object.keys(pin ?? {}).sort().join(','); + if (keys !== 'linuxAmd64Sha256,version') { + throw new Error(`Pin file must contain exactly 'version' and 'linuxAmd64Sha256' but has '${keys}'.`); + } + + parseVersion(pin.version, 'Pinned version'); + if (!SHA256.test(pin.linuxAmd64Sha256)) { + throw new Error(`Pinned linuxAmd64Sha256 '${pin.linuxAmd64Sha256}' is not a lowercase SHA-256 hex digest.`); + } + + return { version: pin.version, sha256: pin.linuxAmd64Sha256 }; +} + +function applyPin(content, version, sha256) { + const current = readPin(content); + if (compareVersions(version, current.version) <= 0) { + throw new Error(`Refusing to change actionlint from ${current.version} to ${version}; only upgrades are allowed.`); + } + + if (!SHA256.test(sha256)) { + throw new Error(`SHA-256 '${sha256}' is not a lowercase SHA-256 hex digest.`); + } + + return `${JSON.stringify({ version, linuxAmd64Sha256: sha256 }, null, 2)}\n`; +} + +// Release API asset shape (https://docs.github.com/rest/releases/releases#get-the-latest-release): +// { "name": "actionlint_1.7.12_linux_amd64.tar.gz", +// "browser_download_url": "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz", +// "digest": "sha256:8aca8db9..." } +// Older releases can have a null digest; those are rejected rather than trusted. +function selectLinuxAmd64Asset(release, version) { + if (release.draft || release.prerelease) { + throw new Error(`Release ${release.tag_name} is a draft or prerelease.`); + } + + const name = `actionlint_${version}_linux_amd64.tar.gz`; + const assets = (release.assets ?? []).filter(asset => asset.name === name); + if (assets.length !== 1) { + throw new Error(`Expected exactly one '${name}' asset in release ${release.tag_name} but found ${assets.length}.`); + } + + const [asset] = assets; + const url = `https://github.com/${REPOSITORY}/releases/download/v${version}/${name}`; + if (asset.browser_download_url !== url) { + throw new Error(`Asset '${name}' has unexpected download URL '${asset.browser_download_url}'.`); + } + + const digest = /^sha256:([0-9a-f]{64})$/.exec(asset.digest ?? ''); + if (!digest) { + throw new Error(`Asset '${name}' has no sha256 digest (got '${asset.digest}').`); + } + + return { name, url, sha256: digest[1] }; +} + +function verifySha256(bytes, expected, name) { + const actual = crypto.createHash('sha256').update(bytes).digest('hex'); + if (actual !== expected) { + throw new Error(`Downloaded '${name}' has SHA-256 ${actual} but the release API digest is ${expected}.`); + } +} + +async function check({ pinContent, outputDir, fetchJson, fetchBytes, writeFile, log }) { + const current = readPin(pinContent); + const release = await fetchJson(LATEST_RELEASE_URL); + const latest = parseReleaseTag(release.tag_name); + const comparison = compareVersions(latest, current.version); + + if (comparison < 0) { + throw new Error(`Latest actionlint release ${latest} is older than the pinned ${current.version}; refusing to downgrade.`); + } + + if (comparison === 0) { + log(`actionlint ${current.version} is the latest release; nothing to update.`); + return { updated: false, previousVersion: current.version }; + } + + const asset = selectLinuxAmd64Asset(release, latest); + const bytes = await fetchBytes(asset.url); + verifySha256(bytes, asset.sha256, asset.name); + + const archivePath = path.join(outputDir, asset.name); + await writeFile(archivePath, bytes); + log(`actionlint ${latest} is available (pinned ${current.version}); verified ${asset.name} SHA-256 ${asset.sha256}.`); + + return { updated: true, previousVersion: current.version, version: latest, sha256: asset.sha256, archivePath }; +} + +// Formats check() results as GITHUB_OUTPUT lines, e.g.: +// updated=true +// previous-version=1.7.12 +// version=1.7.13 +// sha256=<64 hex chars> +// archive-path=/home/runner/work/_temp/actionlint_1.7.13_linux_amd64.tar.gz +// update-actionlint.yml consumes these names, so keep them in sync. +function formatStepOutputs(result) { + const lines = [`updated=${result.updated}`, `previous-version=${result.previousVersion}`]; + if (result.updated) { + lines.push(`version=${result.version}`, `sha256=${result.sha256}`, `archive-path=${result.archivePath}`); + } + + return `${lines.join('\n')}\n`; +} + +async function main(argv, env) { + const pinPath = path.resolve('.github/actionlint-version.json'); + const pinContent = await fs.readFile(pinPath, 'utf8'); + const [command, ...args] = argv; + + if (command === 'apply') { + const [version, sha256] = args; + await fs.writeFile(pinPath, applyPin(pinContent, version, sha256)); + console.log(`Updated ${pinPath} to actionlint ${version}.`); + return; + } + + if (command !== 'check') { + throw new Error(`Unknown command '${command}'. Expected 'check' or 'apply '.`); + } + + const apiHeaders = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}), + }; + + const result = await check({ + pinContent, + outputDir: env.RUNNER_TEMP ?? process.cwd(), + fetchJson: async url => { + const response = await fetch(url, { headers: apiHeaders }); + if (!response.ok) { + throw new Error(`GET ${url} failed with HTTP ${response.status}.`); + } + + return response.json(); + }, + // The download redirects to a release-asset CDN, so it is fetched without the API token. + fetchBytes: async url => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`GET ${url} failed with HTTP ${response.status}.`); + } + + return Buffer.from(await response.arrayBuffer()); + }, + writeFile: fs.writeFile, + log: message => console.log(message), + }); + + if (env.GITHUB_OUTPUT) { + await fs.appendFile(env.GITHUB_OUTPUT, formatStepOutputs(result)); + } +} + +module.exports = { applyPin, check, compareVersions, formatStepOutputs, parseReleaseTag, readPin, selectLinuxAmd64Asset, verifySha256 }; + +if (require.main === module) { + main(process.argv.slice(2), process.env).catch(error => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} diff --git a/.github/workflows/update-actionlint.yml b/.github/workflows/update-actionlint.yml new file mode 100644 index 00000000000..5090e410b70 --- /dev/null +++ b/.github/workflows/update-actionlint.yml @@ -0,0 +1,143 @@ +name: Update actionlint + +# Proposes a draft PR when rhysd/actionlint publishes a newer release than the +# pin in .github/actionlint-version.json (consumed by ci.yml). Manual dispatch +# defaults to validating without changing the updater branch or opening a PR. +on: + schedule: + - cron: '0 16 * * 1' # Mondays 16:00 UTC + workflow_dispatch: + inputs: + mode: + description: Validate the candidate or propose the pin update + type: choice + options: + - validate + - propose + default: validate + +permissions: + contents: read + +# Scheduled and propose runs force-push the same fixed branch. Serialize whole runs, +# not just the update job, so each run selects its release only after the previous +# run finished; otherwise a slower run could push an older candidate over a newer one. +# Validate runs use their own group so a queued validate never replaces a pending +# propose run (GitHub keeps only the newest pending run per group). +concurrency: + group: update-actionlint-${{ github.event_name == 'schedule' && 'propose' || inputs.mode }} + cancel-in-progress: false + +jobs: + # Downloads and runs the candidate binary without secrets or write credentials. + check: + name: Check for a newer actionlint + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'validate') }} + outputs: + updated: ${{ steps.check.outputs.updated }} + previous-version: ${{ steps.check.outputs.previous-version }} + version: ${{ steps.check.outputs.version }} + sha256: ${{ steps.check.outputs.sha256 }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check latest actionlint release + id: check + env: + GITHUB_TOKEN: ${{ github.token }} + run: node .github/workflows/update-actionlint.js check + + - name: Install candidate actionlint + if: ${{ steps.check.outputs.updated == 'true' }} + env: + ARCHIVE_PATH: ${{ steps.check.outputs.archive-path }} + run: | + tar -xzf "${ARCHIVE_PATH}" -C "${RUNNER_TEMP}" actionlint + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + + # Shared with ci.yml so the candidate is proven against the gated file set. + - name: Lint handwritten workflow files + if: ${{ steps.check.outputs.updated == 'true' }} + run: bash .github/workflows/lint-handwritten-workflows.sh + + update: + name: Propose actionlint update + needs: check + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' && github.ref_name == github.event.repository.default_branch && needs.check.outputs.updated == 'true' && (github.event_name == 'schedule' || inputs.mode == 'propose') }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Update actionlint pin + env: + ACTIONLINT_VERSION: ${{ needs.check.outputs.version }} + ACTIONLINT_SHA256: ${{ needs.check.outputs.sha256 }} + run: node .github/workflows/update-actionlint.js apply "${ACTIONLINT_VERSION}" "${ACTIONLINT_SHA256}" + + - name: Generate GitHub App Token for pull request + id: pr-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Create or update pull request + # actionlint (pinned in .github/actionlint-version.json) rejects zizmor's preferred '$/' syntax. + uses: ./.github/actions/create-pull-request # zizmor: ignore[self-repository] + with: + token: ${{ steps.pr-token.outputs.token }} + branch: update-actionlint + base: main + draft: true + commit-message: "[Automated] Update actionlint to v${{ needs.check.outputs.version }}" + labels: | + area-engineering-systems + title: "[automated] Update actionlint to v${{ needs.check.outputs.version }}" + body: | + Updates the actionlint pin in `.github/actionlint-version.json` from v${{ needs.check.outputs.previous-version }} to v${{ needs.check.outputs.version }}. + + - Release: https://github.com/rhysd/actionlint/releases/tag/v${{ needs.check.outputs.version }} + - `actionlint_${{ needs.check.outputs.version }}_linux_amd64.tar.gz` SHA-256: `${{ needs.check.outputs.sha256 }}` + + The archive was downloaded and verified against the release API asset digest, and the candidate passed the handwritten-workflow lint before this PR was opened. The digest is published by the same upstream release, so review the release notes before merging. + update-existing-pr-metadata: true + + # File/append a single deduplicated issue when a scheduled run fails (release + # lookup, digest verification, candidate lint, token minting, or PR creation). + # The scheduled-workflow watchdog backstops startup_failure/timed_out runs. See + # docs/ci/pipeline-failure-issues.md. + create-issue-on-failure: + name: Create Issue on Failure + needs: [check, update] + runs-on: ubuntu-latest + if: ${{ failure() && github.event_name == 'schedule' && github.repository_owner == 'microsoft' }} + permissions: + contents: read # checkout to require the local .js modules + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + + - name: File or update failure issue + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKFLOW_FILE: update-actionlint.yml + DISPLAY_NAME: Update actionlint + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await require('./.github/workflows/report-pipeline-failure.js').report({ + github, context, core, + labels: ['automation-broken', 'area-engineering-systems'], + }); diff --git a/docs/ci/ci-failure-issues.md b/docs/ci/ci-failure-issues.md index e20f99f6ae2..ed74ba1c80a 100644 --- a/docs/ci/ci-failure-issues.md +++ b/docs/ci/ci-failure-issues.md @@ -16,7 +16,7 @@ It is a consumer of the shared, repo-agnostic tracking-issue engine ([`tracking-issue.js`](../../.github/workflows/tracking-issue.js)), alongside the [scheduled-workflow scanner](monitor-scheduled-workflows.md), the [specialized-test failure reporter](specialized-test-failure-issues.md), and the -[nightly-pipeline failure reporter](pipeline-failure-issues.md). +[scheduled-pipeline failure reporter](pipeline-failure-issues.md). ## Push only — PR failures are excluded diff --git a/docs/ci/monitor-scheduled-workflows.md b/docs/ci/monitor-scheduled-workflows.md index fc705182554..ba6a7f26137 100644 --- a/docs/ci/monitor-scheduled-workflows.md +++ b/docs/ci/monitor-scheduled-workflows.md @@ -54,7 +54,7 @@ second marker): - `tests-outerloop`, `tests-quarantine` (see [specialized-test-failure-issues.md](specialized-test-failure-issues.md)) -- `tests-daily-smoke`, `deployment-tests` (see +- `tests-daily-smoke`, `deployment-tests`, `update-actionlint` (see [pipeline-failure-issues.md](pipeline-failure-issues.md)) - **To add a full-watch workflow:** add a `{ "file", "name" }` entry (entries @@ -150,7 +150,7 @@ The reusable issue mechanics (marker dedup, the comment-recording loop with per-run dedup, octokit primitives) live in the generic, repo-agnostic engine [`tracking-issue.js`](../../.github/workflows/tracking-issue.js), shared with the [specialized-test failure reporter](specialized-test-failure-issues.md), the -[nightly-pipeline failure reporter](pipeline-failure-issues.md), the +[scheduled-pipeline failure reporter](pipeline-failure-issues.md), the [red-main CI reporter](ci-failure-issues.md), and unit-tested by [`TrackingIssueTests`](../../tests/Infrastructure.Tests/WorkflowScripts/TrackingIssueTests.cs). diff --git a/docs/ci/pipeline-failure-issues.md b/docs/ci/pipeline-failure-issues.md index bc6040a1675..c58aefd3126 100644 --- a/docs/ci/pipeline-failure-issues.md +++ b/docs/ci/pipeline-failure-issues.md @@ -1,7 +1,8 @@ -# Nightly-pipeline failure issues (deployment E2E & daily smoke) +# Scheduled-pipeline failure issues (deployment E2E, daily smoke & actionlint updater) -The scheduled `Deployment E2E Tests` (`deployment-tests.yml`) and -`Daily CLI Smoke Tests` (`tests-daily-smoke.yml`) workflows run unattended and +The scheduled `Deployment E2E Tests` (`deployment-tests.yml`), +`Daily CLI Smoke Tests` (`tests-daily-smoke.yml`), and weekly `Update actionlint` +(`update-actionlint.yml`) workflows run unattended and otherwise fail silently — GitHub only emails whoever last edited the workflow file. Each workflow files a GitHub issue when a **scheduled** run fails. @@ -11,7 +12,7 @@ This is a consumer of the shared, repo-agnostic tracking-issue engine [specialized-test failure reporter](specialized-test-failure-issues.md), and the [red-main CI reporter](ci-failure-issues.md). Like the specialized reporter it runs *inside* the pipeline (so it has the run context), -but unlike it, a failure here is **not** classified into test-vs-infra: a nightly +but unlike it, a failure here is **not** classified into test-vs-infra: a scheduled deployment/smoke failure can be either, and these pipelines do not inspect results to tell them apart. The issue just records "the scheduled run failed" with a link to the run. @@ -20,10 +21,11 @@ to the run. When a scheduled run fails: -- **Title:** `Nightly run failing: ` +- **Title:** `Scheduled run failing: ` - **Labels:** the workflow's existing labels **plus** `automation-broken`: - deployment: `automation-broken`, `area-testing`, `deployment-e2e` - smoke: `automation-broken`, `area-cli`, `failing-test` + - actionlint updater: `automation-broken`, `area-engineering-systems` - **Body marker:** a hidden HTML comment `` on the first line, used for dedup. diff --git a/docs/ci/specialized-test-failure-issues.md b/docs/ci/specialized-test-failure-issues.md index 330b4341b88..acebeabb4d8 100644 --- a/docs/ci/specialized-test-failure-issues.md +++ b/docs/ci/specialized-test-failure-issues.md @@ -104,7 +104,7 @@ The reusable issue mechanics (marker dedup, the comment-recording loop with per-run dedup, octokit primitives) live in the generic, repo-agnostic engine [`tracking-issue.js`](../../.github/workflows/tracking-issue.js), shared with the [scheduled-workflow scanner](monitor-scheduled-workflows.md), the -[nightly-pipeline failure reporter](pipeline-failure-issues.md), and the +[scheduled-pipeline failure reporter](pipeline-failure-issues.md), and the [red-main CI reporter](ci-failure-issues.md), and unit-tested by [`TrackingIssueTests`](../../tests/Infrastructure.Tests/WorkflowScripts/TrackingIssueTests.cs). diff --git a/eng/github-ci/ci-skip-entirely-patterns.txt b/eng/github-ci/ci-skip-entirely-patterns.txt index fd86b1773cd..70a0bad9cfc 100644 --- a/eng/github-ci/ci-skip-entirely-patterns.txt +++ b/eng/github-ci/ci-skip-entirely-patterns.txt @@ -57,7 +57,6 @@ Aspire-Core.slnf # Dev/AzDO/scheduled-only infrastructure with no regular GitHub PR CI consumer. .config/dotnet-tools.json eng/Version.Details.xml -.github/actions/create-pull-request/** .github/actions/preload-azure-cli-requests/** # GitHub workflow files that do not affect the CI build or test process @@ -77,7 +76,13 @@ eng/Version.Details.xml .github/workflows/specialized-test-runner.yml .github/workflows/tests-outerloop.yml .github/workflows/tests-quarantine.yml -.github/workflows/update-*.yml +# Scheduled updater workflows without regular PR-CI validation. Keep update-actionlint.yml +# out of this list because its edits must run the workflow and CI-selector contract tests. +.github/workflows/update-azure-vm-sizes.yml +.github/workflows/update-ai-foundry-models.yml +.github/workflows/update-nix-cli-flake.yml +.github/workflows/update-aspire-skills-bundle.yml +.github/workflows/update-dependencies.yml .github/workflows/auto-rerun-transient-ci-failures.* # GitHub Agentic Workflows (gh-aw): shared action locks (.github/aw/**), compiled diff --git a/eng/github-ci/test-trigger-map.yml b/eng/github-ci/test-trigger-map.yml index 831ddff1760..3c22856c87a 100644 --- a/eng/github-ci/test-trigger-map.yml +++ b/eng/github-ci/test-trigger-map.yml @@ -211,9 +211,10 @@ path_rules: # the blast radius is not precise enough, keep the conservative full-matrix target. - paths: - .github/actions/check-changed-files/** + - .github/actions/create-pull-request/** - .github/actions/select-tests/** targets: [test:Infrastructure.Tests] - reason: Infrastructure.Tests validates the changed-file gate and selector action behavior + reason: Infrastructure.Tests validates these composite actions' behavior - paths: - .github/actions/enumerate-tests/** targets: [DOTNET_TESTS] @@ -508,6 +509,7 @@ path_rules: - paths: - .github/workflows/** - .github/actionlint.yaml + - .github/actionlint-version.json - eng/pipelines/** - eng/Signing.props - eng/Publishing.props diff --git a/tests/Infrastructure.Tests/Shared/ProcessRunner.cs b/tests/Infrastructure.Tests/Shared/ProcessRunner.cs index 07e6e119fd4..09e39b7fe6a 100644 --- a/tests/Infrastructure.Tests/Shared/ProcessRunner.cs +++ b/tests/Infrastructure.Tests/Shared/ProcessRunner.cs @@ -12,9 +12,14 @@ public static async Task RunAsync( ITestOutputHelper output, string fileName, IReadOnlyList arguments, - string workingDirectory) + string workingDirectory, + IReadOnlyDictionary? environment = null) { using var process = new Process(); + foreach (var (name, value) in environment ?? new Dictionary()) + { + process.StartInfo.Environment[name] = value; + } process.StartInfo.FileName = fileName; process.StartInfo.WorkingDirectory = workingDirectory; process.StartInfo.RedirectStandardOutput = true; diff --git a/tests/Infrastructure.Tests/Shared/ProcessRunnerTests.cs b/tests/Infrastructure.Tests/Shared/ProcessRunnerTests.cs index 5c6a6028b0f..1b5fa0aaa13 100644 --- a/tests/Infrastructure.Tests/Shared/ProcessRunnerTests.cs +++ b/tests/Infrastructure.Tests/Shared/ProcessRunnerTests.cs @@ -28,6 +28,26 @@ public async Task CapturesStreamsExitCodeAndInvocationContext() Assert.Equal(result.StandardOutput + result.StandardError, result.Output); } + [Fact] + [RequiresTools(["bash"])] + public async Task AppliesEnvironmentOverridesOnTopOfInheritedEnvironment() + { + using var workspace = TemporaryWorkspace.Create(output); + + var result = await ProcessRunner.RunAsync( + output, + "bash", + // HOME is never synthesized by bash itself, so it is only set when the + // parent environment was inherited (PATH lookup would still succeed via + // bash's built-in default search path). + ["-c", """printf '%s|%s' "$RUNNER_TEST_VALUE" "${HOME:+inherited-home}" """], + workspace.Path, + new Dictionary { ["RUNNER_TEST_VALUE"] = "override" }); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("override|inherited-home", result.StandardOutput); + } + [Fact] [RequiresTools(["bash"])] public async Task DrainsBothOutputStreamsConcurrently() diff --git a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs index 08a50cfebbc..d5bc4ab0297 100644 --- a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs +++ b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs @@ -507,6 +507,10 @@ public void DocumentedJobTargetVocabularyMatchesReferencedMapJobs() ".github/actionlint.yaml", ["test:Infrastructure.Tests"] }, + { + ".github/actionlint-version.json", + ["test:Infrastructure.Tests"] + }, { ".github/scripts/assert-extension-e2e-bridge-vsix.ps1", ["job:extension-unit"] @@ -1563,7 +1567,6 @@ public void EveryLocalActionUsedByAWorkflowIsAccountedFor() var skippedActions = new HashSet(StringComparer.Ordinal) { - "create-pull-request", "preload-azure-cli-requests", }; diff --git a/tests/Infrastructure.Tests/WorkflowScripts/CreatePullRequestActionTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/CreatePullRequestActionTests.cs new file mode 100644 index 00000000000..0dd8970cd12 --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/CreatePullRequestActionTests.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace Infrastructure.Tests; + +public sealed class CreatePullRequestActionTests(ITestOutputHelper output) +{ + [Fact] + [RequiresTools(["bash"])] + public async Task ExistingPullRequestMetadataIsUpdatedOnlyWhenEnabled() + { + var action = ReadYaml(Path.Combine(RepoRoot.Path, ".github", "actions", "create-pull-request", "action.yml")); + var runs = (YamlMappingNode)action.Children["runs"]; + var steps = (YamlSequenceNode)runs.Children["steps"]; + var createPrStep = Assert.Single(steps.Children.Cast(), step => + step.Children.TryGetValue("id", out var id) && ((YamlScalarNode)id).Value == "create-pr"); + var script = ((YamlScalarNode)createPrStep.Children["run"]).Value!; + + const string title = "[automated] Update actionlint to v1.7.13"; + const string body = "Updates the actionlint pin to v1.7.13.\n\n- Verified release digest."; + + using var workspace = TemporaryWorkspace.Create(output); + var disabled = await RunCreatePrScriptAsync(script, workspace.Path, updateMetadata: false, title, body); + + Assert.Equal(0, disabled.ExitCode); + Assert.Contains("pull-request-operation=none", await File.ReadAllTextAsync(disabled.OutputPath)); + Assert.False(File.Exists(disabled.EditArgumentsPath)); + + var enabled = await RunCreatePrScriptAsync(script, workspace.Path, updateMetadata: true, title, body); + + Assert.Equal(0, enabled.ExitCode); + Assert.Contains("pull-request-operation=updated", await File.ReadAllTextAsync(enabled.OutputPath)); + Assert.Equal( + ["pr", "edit", "20610", "--title", title, "--body-file", "-"], + await File.ReadAllLinesAsync(enabled.EditArgumentsPath)); + Assert.Equal(body + "\n", await File.ReadAllTextAsync(enabled.EditBodyPath)); + } + + private async Task RunCreatePrScriptAsync( + string actionScript, + string workingDirectory, + bool updateMetadata, + string title, + string body) + { + var outputPath = Path.Combine(workingDirectory, $"github-output-{Guid.NewGuid():N}.txt"); + var editArgumentsPath = Path.Combine(workingDirectory, $"edit-arguments-{Guid.NewGuid():N}.txt"); + var editBodyPath = Path.Combine(workingDirectory, $"edit-body-{Guid.NewGuid():N}.txt"); + var script = $$""" + set -euo pipefail + + gh() { + if [[ "$1" == "pr" && "$2" == "list" ]]; then + printf '%s\n' '{"number":20610,"url":"https://github.com/microsoft/aspire/pull/20610"}' + return 0 + fi + if [[ "$1" == "pr" && "$2" == "edit" ]]; then + printf '%s\n' "$@" > "$EDIT_ARGUMENTS_PATH" + cat > "$EDIT_BODY_PATH" + return 0 + fi + printf 'Unexpected gh invocation: %s\n' "$*" >&2 + return 97 + } + + # Drain stdin like the real jq so `echo ... | jq` cannot fail with + # SIGPIPE under `set -o pipefail` when this fake exits first. + jq() { + cat > /dev/null + case "$2" in + .number) printf '20610\n' ;; + .url) printf 'https://github.com/microsoft/aspire/pull/20610\n' ;; + *) printf 'Unexpected jq query: %s\n' "$2" >&2; return 98 ;; + esac + } + + {{actionScript}} + """; + + var result = await ProcessRunner.RunAsync( + output, + "bash", + ["-c", script], + workingDirectory, + new Dictionary + { + ["BRANCH"] = "update-actionlint", + ["BASE"] = "main", + ["PR_TITLE"] = title, + ["PR_BODY"] = body, + ["UPDATE_EXISTING_PR_METADATA"] = updateMetadata ? "true" : "false", + ["EDIT_ARGUMENTS_PATH"] = editArgumentsPath, + ["EDIT_BODY_PATH"] = editBodyPath, + ["GITHUB_OUTPUT"] = outputPath, + ["GITHUB_REPOSITORY"] = "microsoft/aspire", + ["GH_TOKEN"] = "test-token", + }); + + return new ScriptResult(result.ExitCode, outputPath, editArgumentsPath, editBodyPath); + } + + private static YamlMappingNode ReadYaml(string path) + { + var yaml = new YamlStream(); + yaml.Load(new StringReader(File.ReadAllText(path))); + return (YamlMappingNode)yaml.Documents[0].RootNode; + } + + private sealed record ScriptResult(int ExitCode, string OutputPath, string EditArgumentsPath, string EditBodyPath); +} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/ExtensionWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/ExtensionWorkflowTests.cs index 6a4183a37fe..9eb0457e02c 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/ExtensionWorkflowTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/ExtensionWorkflowTests.cs @@ -302,16 +302,17 @@ public void FinalResultsRequireStabilizationOnlyWhenEnabledForPrs() { var results = Mapping(s_ciJobs, "results"); Assert.Equal( - ["prepare_for_ci", "tests", "stabilization_check"], + ["actionlint", "prepare_for_ci", "tests", "stabilization_check"], SequenceScalars(results, "needs")); var failureStep = Assert.Single(Steps(results), step => Scalar(step, "name") == "Fail if any of the dependent jobs failed"); Assert.Equal( - "${{ always() && needs.prepare_for_ci.outputs.skip_workflow != 'true' && " + + "${{ always() && (needs.actionlint.result != 'success' || " + + "(needs.prepare_for_ci.outputs.skip_workflow != 'true' && " + "(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || " + "needs.tests.result != 'success' || " + "(needs.prepare_for_ci.outputs.STABILIZATION_ENABLED == 'true' && " + - "github.event_name == 'pull_request' && needs.stabilization_check.result != 'success')) }}", + "github.event_name == 'pull_request' && needs.stabilization_check.result != 'success')))) }}", CollapseWhitespace(Scalar(failureStep, "if"))); } @@ -320,7 +321,7 @@ public void CiFailureTrackerDoesNotRequirePrOnlyStabilizationOnPush() { var tracker = Mapping(s_ciJobs, "ci_failure_tracker"); - Assert.Equal(["prepare_for_ci", "tests", "stabilization_check"], SequenceScalars(tracker, "needs")); + Assert.Equal(["actionlint", "prepare_for_ci", "tests", "stabilization_check"], SequenceScalars(tracker, "needs")); Assert.Equal( "${{ always() && github.event_name == 'push' && github.repository_owner == 'microsoft' }}", Scalar(tracker, "if")); @@ -329,7 +330,8 @@ public void CiFailureTrackerDoesNotRequirePrOnlyStabilizationOnPush() var environment = Mapping(scriptStep, "env"); Assert.Equal("${{ contains(needs.*.result, 'failure') }}", Scalar(environment, "CI_RED")); Assert.Equal( - "${{ needs.prepare_for_ci.result == 'success' && needs.tests.result == 'success' }}", + "${{ needs.actionlint.result == 'success' && needs.prepare_for_ci.result == 'success' && " + + "needs.tests.result == 'success' }}", CollapseWhitespace(Scalar(environment, "CI_GREEN"))); } diff --git a/tests/Infrastructure.Tests/WorkflowScripts/LintHandwrittenWorkflowsScriptTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/LintHandwrittenWorkflowsScriptTests.cs new file mode 100644 index 00000000000..17641e1391d --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/LintHandwrittenWorkflowsScriptTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for .github/workflows/lint-handwritten-workflows.sh, the file selection shared by +/// the blocking ci.yml actionlint gate and the update-actionlint.yml candidate check. +/// +[SkipOnPlatform(TestPlatforms.Windows, "The script runs only on Linux runners and the fake actionlint relies on Unix PATH lookup.")] +public sealed class LintHandwrittenWorkflowsScriptTests(ITestOutputHelper output) : IDisposable +{ + private static readonly string s_scriptPath = Path.Combine( + RepoRoot.Path, + ".github", + "workflows", + "lint-handwritten-workflows.sh"); + + private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); + + [Fact] + [RequiresTools(["bash"])] + public async Task LintsOnlyTopLevelHandwrittenWorkflows() + { + CreateFiles( + ".github/workflows/ci.yml", + ".github/workflows/release.yaml", + ".github/workflows/agent.lock.yml", + ".github/workflows/agent.md", + ".github/workflows/agentics-maintenance-microsoft-aspire.dev.yml", + ".github/workflows/helper.js", + ".github/workflows/nested/inner.yml", + ".github/actions/local/action.yml"); + + var result = await RunScriptAsync(actionlintExitCode: 0); + + Assert.Equal(0, result.ExitCode); + Assert.Equal( + [ + "-shellcheck=", + "-pyflakes=", + ".github/workflows/ci.yml", + ".github/workflows/release.yaml", + ], + await ReadActionlintArgumentsAsync()); + } + + [Fact] + [RequiresTools(["bash"])] + public async Task PropagatesActionlintFailure() + { + CreateFiles(".github/workflows/ci.yml"); + + var result = await RunScriptAsync(actionlintExitCode: 1); + + Assert.Equal(1, result.ExitCode); + } + + [Fact] + [RequiresTools(["bash"])] + public async Task FailsWithoutRunningActionlintWhenNoWorkflowsMatch() + { + CreateFiles(".github/workflows/agent.lock.yml"); + + var result = await RunScriptAsync(actionlintExitCode: 0); + + Assert.Equal(1, result.ExitCode); + Assert.False(File.Exists(ArgumentsPath)); + } + + private string ArgumentsPath => Path.Combine(_workspace.Path, "actionlint-arguments.txt"); + + private void CreateFiles(params string[] relativePaths) + { + foreach (var relativePath in relativePaths) + { + var path = Path.Combine(_workspace.Path, "repo", relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "name: test\n"); + } + } + + private async Task ReadActionlintArgumentsAsync() => await File.ReadAllLinesAsync(ArgumentsPath); + + private async Task RunScriptAsync(int actionlintExitCode) + { + // The fake actionlint records its arguments so the test can assert the exact + // file set the script selects, without depending on a real actionlint install. + var binDirectory = Directory.CreateDirectory(Path.Combine(_workspace.Path, "bin")).FullName; + var fakeActionlint = Path.Combine(binDirectory, "actionlint"); + await File.WriteAllTextAsync( + fakeActionlint, + """ + #!/usr/bin/env bash + printf '%s\n' "$@" > "$FAKE_ACTIONLINT_ARGUMENTS" + exit "$FAKE_ACTIONLINT_EXIT_CODE" + """); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(fakeActionlint, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return await ProcessRunner.RunAsync( + output, + "bash", + [s_scriptPath], + Path.Combine(_workspace.Path, "repo"), + new Dictionary + { + ["PATH"] = $"{binDirectory}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}", + ["FAKE_ACTIONLINT_ARGUMENTS"] = ArgumentsPath, + ["FAKE_ACTIONLINT_EXIT_CODE"] = actionlintExitCode.ToString(), + }); + } + + public void Dispose() => _workspace.Dispose(); +} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureIntegrationTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureIntegrationTests.cs index f50b140ce7e..89ebc4b60b0 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureIntegrationTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureIntegrationTests.cs @@ -58,7 +58,7 @@ public async Task FilesIssueWithAllLabelsAndRecordsRunAsComment() Assert.False(result.Threw); var issue = Assert.Single(result.Issues); - Assert.Equal("Nightly run failing: Deployment E2E Tests", issue.Title); + Assert.Equal("Scheduled run failing: Deployment E2E Tests", issue.Title); Assert.Contains("ci-failure:deployment-tests.yml:scheduled", issue.Body); // Carries the existing labels PLUS automation-broken. Assert.Equal(s_deploymentLabels, issue.Labels); diff --git a/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureTests.cs index b0322c29dd0..c5c87d32c03 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/ReportPipelineFailureTests.cs @@ -45,12 +45,12 @@ public async Task BuildMarkerEmbedsWorkflowFile(string file, string expected) [RequiresTools(["node"])] public async Task BuildIssueTitleIsNeutral() { - // The title must not pre-classify the failure as test-vs-infra: a nightly + // The title must not pre-classify the failure as test-vs-infra: a scheduled // deployment/smoke failure can be either, and these pipelines do not inspect // results to tell them apart. var title = await InvokeHarnessAsync("buildIssueTitle", new { displayName = "Deployment E2E Tests" }); - Assert.Equal("Nightly run failing: Deployment E2E Tests", title); + Assert.Equal("Scheduled run failing: Deployment E2E Tests", title); } [Fact] @@ -69,7 +69,7 @@ public async Task BuildIssueBodyIsStaticDescriptionWithMarker() }); Assert.Contains("", body); - Assert.Contains("is failing on its nightly run", body); + Assert.Contains("is failing on its scheduled run", body); Assert.Contains("comment below", body); Assert.DoesNotContain("ci-failure-runs:begin", body); Assert.DoesNotContain("[run #", body); diff --git a/tests/Infrastructure.Tests/WorkflowScripts/UpdateActionlintTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/UpdateActionlintTests.cs new file mode 100644 index 00000000000..159046b0a9c --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/UpdateActionlintTests.cs @@ -0,0 +1,365 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for .github/workflows/update-actionlint.js, which proposes bumps to the +/// .github/actionlint-version.json pin. +/// +public sealed class UpdateActionlintTests : IDisposable +{ + private const string FixtureSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string NewArchiveContent = "actionlint 1.7.13 archive bytes"; + + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web); + private static readonly string s_newArchiveSha256 = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(NewArchiveContent))); + + private readonly TemporaryWorkspace _workspace; + private readonly string _repoRoot; + private readonly string _harnessPath; + private readonly ITestOutputHelper _output; + + public UpdateActionlintTests(ITestOutputHelper output) + { + _output = output; + _workspace = TemporaryWorkspace.Create(output); + _repoRoot = RepoRoot.Path; + _harnessPath = Path.Combine(_repoRoot, "tests", "Infrastructure.Tests", "WorkflowScripts", "update-actionlint.harness.js"); + } + + public void Dispose() => _workspace.Dispose(); + + [Fact] + [RequiresTools(["node"])] + public async Task ReadPinParsesCommittedPinFile() + { + var content = await File.ReadAllTextAsync(Path.Combine(_repoRoot, ".github", "actionlint-version.json")); + using var document = JsonDocument.Parse(content); + var expectedPin = new Pin( + document.RootElement.GetProperty("version").GetString()!, + document.RootElement.GetProperty("linuxAmd64Sha256").GetString()!); + + var pin = await InvokeAsync("readPin", new { content }); + + Assert.Equal(expectedPin, pin); + } + + [Theory] + [RequiresTools(["node"])] + [InlineData("""{"version":"1.7","linuxAmd64Sha256":"8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"}""", "Pinned version '1.7' is not a MAJOR.MINOR.PATCH version.")] + [InlineData("""{"version":"1.7.12","linuxAmd64Sha256":"ABC"}""", "Pinned linuxAmd64Sha256 'ABC' is not a lowercase SHA-256 hex digest.")] + [InlineData("""{"version":"1.7.12"}""", "Pin file must contain exactly 'version' and 'linuxAmd64Sha256' but has 'version'.")] + [InlineData("""{"version":"1.7.12","linuxAmd64Sha256":"8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8","checksums":"https://example.com"}""", "Pin file must contain exactly 'version' and 'linuxAmd64Sha256' but has 'checksums,linuxAmd64Sha256,version'.")] + public async Task ReadPinRejectsMalformedPins(string content, string expectedError) + { + var error = await InvokeExpectingErrorAsync("readPin", new { content }); + + Assert.Equal(expectedError, error); + } + + [Theory] + [RequiresTools(["node"])] + [InlineData("1.7.13", "1.7.12", 1)] + [InlineData("1.10.0", "1.9.99", 1)] + [InlineData("2.0.0", "1.99.99", 1)] + [InlineData("1.7.12", "1.7.12", 0)] + [InlineData("1.7.11", "1.7.12", -1)] + public async Task CompareVersionsUsesNumericSemverOrdering(string left, string right, int expected) + { + Assert.Equal(expected, await InvokeAsync("compareVersions", new { left, right })); + } + + [Theory] + [RequiresTools(["node"])] + [InlineData("1.7.13")] + [InlineData("v1.7.13-rc.1")] + [InlineData("v1.7")] + [InlineData("v01.7.13")] + public async Task ParseReleaseTagRejectsNonReleaseTags(string tag) + { + var error = await InvokeExpectingErrorAsync("parseReleaseTag", new { tag }); + + Assert.Equal($"Release tag '{tag}' is not of the form vMAJOR.MINOR.PATCH.", error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task ApplyPinRewritesAllFieldsTogether() + { + var content = PinContent("1.7.12"); + + var updated = await InvokeAsync("applyPin", new { content, version = "1.7.13", sha256 = s_newArchiveSha256 }); + + Assert.Equal( + $$""" + { + "version": "1.7.13", + "linuxAmd64Sha256": "{{s_newArchiveSha256}}" + } + + """, + updated); + } + + [Theory] + [RequiresTools(["node"])] + [InlineData("1.7.12")] + [InlineData("1.7.11")] + public async Task ApplyPinRefusesNonUpgrades(string version) + { + var error = await InvokeExpectingErrorAsync("applyPin", new { content = PinContent("1.7.12"), version, sha256 = s_newArchiveSha256 }); + + Assert.Equal($"Refusing to change actionlint from 1.7.12 to {version}; only upgrades are allowed.", error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task SelectLinuxAmd64AssetReturnsDigest() + { + var asset = await InvokeAsync("selectLinuxAmd64Asset", new { release = Release("1.7.13", $"sha256:{s_newArchiveSha256}"), version = "1.7.13" }); + + Assert.Equal( + new Asset( + "actionlint_1.7.13_linux_amd64.tar.gz", + "https://github.com/rhysd/actionlint/releases/download/v1.7.13/actionlint_1.7.13_linux_amd64.tar.gz", + s_newArchiveSha256), + asset); + } + + [Theory] + [RequiresTools(["node"])] + [InlineData(null, "Asset 'actionlint_1.7.13_linux_amd64.tar.gz' has no sha256 digest (got 'null').")] + [InlineData("sha512:abc", "Asset 'actionlint_1.7.13_linux_amd64.tar.gz' has no sha256 digest (got 'sha512:abc').")] + public async Task SelectLinuxAmd64AssetRequiresSha256Digest(string? digest, string expectedError) + { + var error = await InvokeExpectingErrorAsync("selectLinuxAmd64Asset", new { release = Release("1.7.13", digest), version = "1.7.13" }); + + Assert.Equal(expectedError, error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task SelectLinuxAmd64AssetRejectsPrerelease() + { + var release = new { tag_name = "v1.7.13", draft = false, prerelease = true, assets = Array.Empty() }; + + var error = await InvokeExpectingErrorAsync("selectLinuxAmd64Asset", new { release, version = "1.7.13" }); + + Assert.Equal("Release v1.7.13 is a draft or prerelease.", error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task SelectLinuxAmd64AssetRejectsMissingAsset() + { + var release = new { tag_name = "v1.7.13", draft = false, prerelease = false, assets = Array.Empty() }; + + var error = await InvokeExpectingErrorAsync("selectLinuxAmd64Asset", new { release, version = "1.7.13" }); + + Assert.Equal("Expected exactly one 'actionlint_1.7.13_linux_amd64.tar.gz' asset in release v1.7.13 but found 0.", error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task SelectLinuxAmd64AssetRejectsUnexpectedDownloadUrl() + { + var release = new + { + tag_name = "v1.7.13", + draft = false, + prerelease = false, + assets = new[] { new { name = "actionlint_1.7.13_linux_amd64.tar.gz", browser_download_url = "https://example.com/actionlint.tar.gz", digest = $"sha256:{s_newArchiveSha256}" } }, + }; + + var error = await InvokeExpectingErrorAsync("selectLinuxAmd64Asset", new { release, version = "1.7.13" }); + + Assert.Equal("Asset 'actionlint_1.7.13_linux_amd64.tar.gz' has unexpected download URL 'https://example.com/actionlint.tar.gz'.", error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task CheckIsNoOpWhenPinIsLatest() + { + var result = await InvokeAsync("check", new { pinContent = PinContent("1.7.12"), release = Release("1.7.12", $"sha256:{FixtureSha256}"), archive = "" }); + + Assert.False(result.Updated); + Assert.Equal("1.7.12", result.PreviousVersion); + Assert.Equal(["json https://api.github.com/repos/rhysd/actionlint/releases/latest"], result.Calls); + } + + [Fact] + [RequiresTools(["node"])] + public async Task CheckDownloadsAndVerifiesNewerRelease() + { + var result = await InvokeAsync("check", new { pinContent = PinContent("1.7.12"), release = Release("1.7.13", $"sha256:{s_newArchiveSha256}"), archive = NewArchiveContent }); + + Assert.True(result.Updated); + Assert.Equal("1.7.12", result.PreviousVersion); + Assert.Equal("1.7.13", result.Version); + Assert.Equal(s_newArchiveSha256, result.Sha256); + Assert.Equal("/tmp/out/actionlint_1.7.13_linux_amd64.tar.gz", result.ArchivePath); + Assert.Equal( + [ + "json https://api.github.com/repos/rhysd/actionlint/releases/latest", + "bytes https://github.com/rhysd/actionlint/releases/download/v1.7.13/actionlint_1.7.13_linux_amd64.tar.gz", + "write /tmp/out/actionlint_1.7.13_linux_amd64.tar.gz", + ], + result.Calls); + } + + [Fact] + [RequiresTools(["node"])] + public async Task CheckFailsWhenDownloadDoesNotMatchDigest() + { + var error = await InvokeExpectingErrorAsync("check", new { pinContent = PinContent("1.7.12"), release = Release("1.7.13", $"sha256:{new string('0', 64)}"), archive = NewArchiveContent }); + + Assert.Equal( + $"Downloaded 'actionlint_1.7.13_linux_amd64.tar.gz' has SHA-256 {s_newArchiveSha256} but the release API digest is {new string('0', 64)}.", + error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task CheckRefusesToDowngrade() + { + var error = await InvokeExpectingErrorAsync("check", new { pinContent = PinContent("1.7.12"), release = Release("1.7.11", $"sha256:{FixtureSha256}"), archive = "" }); + + Assert.Equal("Latest actionlint release 1.7.11 is older than the pinned 1.7.12; refusing to downgrade.", error); + } + + [Fact] + [RequiresTools(["node"])] + public async Task FormatStepOutputsEmitsOnlyStatusForNoOp() + { + var outputs = await InvokeAsync("formatStepOutputs", new { result = new { updated = false, previousVersion = "1.7.12" } }); + + Assert.Equal("updated=false\nprevious-version=1.7.12\n", outputs); + } + + [Fact] + [RequiresTools(["node"])] + public async Task FormatStepOutputsEmitsVerifiedCandidateForUpdate() + { + var outputs = await InvokeAsync("formatStepOutputs", new + { + result = new { updated = true, previousVersion = "1.7.12", version = "1.7.13", sha256 = s_newArchiveSha256, archivePath = "/tmp/out/actionlint_1.7.13_linux_amd64.tar.gz" }, + }); + + Assert.Equal( + $"updated=true\nprevious-version=1.7.12\nversion=1.7.13\nsha256={s_newArchiveSha256}\narchive-path=/tmp/out/actionlint_1.7.13_linux_amd64.tar.gz\n", + outputs); + } + + [Fact] + [RequiresTools(["node"])] + public async Task ApplyCommandRewritesPinFileInWorkingDirectory() + { + var pinDirectory = Directory.CreateDirectory(Path.Combine(_workspace.Path, ".github")); + var pinPath = Path.Combine(pinDirectory.FullName, "actionlint-version.json"); + await File.WriteAllTextAsync(pinPath, PinContent("1.7.12")); + + using var command = new NodeCommand(_output, "update-actionlint-apply"); + command.WithWorkingDirectory(_workspace.Path); + var result = await command.ExecuteScriptAsync( + Path.Combine(_repoRoot, ".github", "workflows", "update-actionlint.js"), + "apply", + "1.7.13", + s_newArchiveSha256); + + Assert.Equal(0, result.ExitCode); + Assert.Equal( + $$""" + { + "version": "1.7.13", + "linuxAmd64Sha256": "{{s_newArchiveSha256}}" + } + + """, + await File.ReadAllTextAsync(pinPath)); + } + + [Fact] + [RequiresTools(["node"])] + public async Task ApplyCommandLeavesPinUntouchedOnDowngrade() + { + var pinDirectory = Directory.CreateDirectory(Path.Combine(_workspace.Path, ".github")); + var pinPath = Path.Combine(pinDirectory.FullName, "actionlint-version.json"); + var original = PinContent("1.7.12"); + await File.WriteAllTextAsync(pinPath, original); + + using var command = new NodeCommand(_output, "update-actionlint-apply"); + command.WithWorkingDirectory(_workspace.Path); + var result = await command.ExecuteScriptAsync( + Path.Combine(_repoRoot, ".github", "workflows", "update-actionlint.js"), + "apply", + "1.7.11", + s_newArchiveSha256); + + Assert.NotEqual(0, result.ExitCode); + Assert.Equal(original, await File.ReadAllTextAsync(pinPath)); + } + + private static string PinContent(string version) => + JsonSerializer.Serialize(new Dictionary + { + ["version"] = version, + ["linuxAmd64Sha256"] = FixtureSha256, + }); + + private static object Release(string version, string? digest) => new + { + tag_name = $"v{version}", + draft = false, + prerelease = false, + assets = new object[] + { + new { name = $"actionlint_{version}_checksums.txt", browser_download_url = $"https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_checksums.txt", digest = $"sha256:{new string('1', 64)}" }, + new { name = $"actionlint_{version}_linux_amd64.tar.gz", browser_download_url = $"https://github.com/rhysd/actionlint/releases/download/v{version}/actionlint_{version}_linux_amd64.tar.gz", digest }, + }, + }; + + private async Task InvokeAsync(string operation, object payload) + { + var response = await InvokeHarnessAsync(operation, payload); + Assert.Null(response.Error); + return response.Result!; + } + + private async Task InvokeExpectingErrorAsync(string operation, object payload) + { + var response = await InvokeHarnessAsync(operation, payload); + Assert.NotNull(response.Error); + return response.Error!; + } + + private async Task> InvokeHarnessAsync(string operation, object payload) + { + var requestPath = Path.Combine(_workspace.Path, $"{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(requestPath, JsonSerializer.Serialize(new { operation, payload }, s_jsonOptions)); + + using var command = new NodeCommand(_output, "update-actionlint"); + command.WithWorkingDirectory(_repoRoot); + + var result = await command.ExecuteScriptAsync(_harnessPath, requestPath); + Assert.Equal(0, result.ExitCode); + + var response = JsonSerializer.Deserialize>(result.Output, s_jsonOptions); + Assert.NotNull(response); + return response!; + } + + private sealed record HarnessResponse(T? Result, string? Error); + + private sealed record Pin(string Version, string Sha256); + + private sealed record Asset(string Name, string Url, string Sha256); + + private sealed record CheckResult(bool Updated, string PreviousVersion, string? Version, string? Sha256, string? ArchivePath, string[] Calls); +} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/report-pipeline-failure.integration.harness.js b/tests/Infrastructure.Tests/WorkflowScripts/report-pipeline-failure.integration.harness.js index 2e6867e9817..8108b7c6acb 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/report-pipeline-failure.integration.harness.js +++ b/tests/Infrastructure.Tests/WorkflowScripts/report-pipeline-failure.integration.harness.js @@ -1,4 +1,4 @@ -// Integration harness for the nightly-pipeline failure reporter's report() +// Integration harness for the scheduled-pipeline failure reporter's report() // orchestrator (.github/workflows/report-pipeline-failure.js). report() owns the // find-or-create + comment-dedup branching that the pure-helper tests cannot // reach, so this harness drives it against an in-memory fake of the octokit diff --git a/tests/Infrastructure.Tests/WorkflowScripts/update-actionlint.harness.js b/tests/Infrastructure.Tests/WorkflowScripts/update-actionlint.harness.js new file mode 100644 index 00000000000..b959f8103c0 --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/update-actionlint.harness.js @@ -0,0 +1,84 @@ +// Test harness for .github/workflows/update-actionlint.js, driven by UpdateActionlintTests.cs. +// +// Usage: node update-actionlint.harness.js +// request.json: { "operation": "", "payload": { ... } } +// +// Dispatches the operation to the updater's exported functions and prints a +// single JSON object to stdout: { "result": ... } on success or +// { "error": "" } when the updater throws. The 'check' operation +// replaces the network and filesystem with in-memory fakes so tests can supply +// release metadata and archive bytes and assert on every fetch and write. + +const fs = require('node:fs/promises'); +const updater = require('../../../.github/workflows/update-actionlint.js'); + +async function main() { + const inputPath = process.argv[2]; + if (!inputPath) { + throw new Error('Expected the input payload file path as the first argument.'); + } + + const request = JSON.parse(await fs.readFile(inputPath, 'utf8')); + let response; + try { + response = { result: await dispatch(request.operation, request.payload ?? {}) }; + } catch (error) { + response = { error: error.message }; + } + + process.stdout.write(JSON.stringify(response)); +} + +async function dispatch(operation, payload) { + switch (operation) { + case 'readPin': + return updater.readPin(payload.content); + + case 'applyPin': + return updater.applyPin(payload.content, payload.version, payload.sha256); + + case 'compareVersions': + return updater.compareVersions(payload.left, payload.right); + + case 'parseReleaseTag': + return updater.parseReleaseTag(payload.tag); + + case 'formatStepOutputs': + return updater.formatStepOutputs(payload.result); + + case 'selectLinuxAmd64Asset': + return updater.selectLinuxAmd64Asset(payload.release, payload.version); + + case 'check': { + // Network and filesystem fakes: the release JSON and archive bytes come + // from the payload, and every fetch/write is recorded for assertions. + const calls = []; + const result = await updater.check({ + pinContent: payload.pinContent, + outputDir: '/tmp/out', + fetchJson: async url => { + calls.push(`json ${url}`); + return payload.release; + }, + fetchBytes: async url => { + calls.push(`bytes ${url}`); + return Buffer.from(payload.archive, 'utf8'); + }, + writeFile: async filePath => { + calls.push(`write ${filePath}`); + }, + log: () => {}, + }); + + return { ...result, calls }; + } + + default: + throw new Error(`Unsupported operation '${operation}'.`); + } +} + +main().catch(error => { + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 1; +});