diff --git a/.github/workflows/azldev-smoke.yml b/.github/workflows/azldev-smoke.yml new file mode 100644 index 00000000000..4e27d8b5f92 --- /dev/null +++ b/.github/workflows/azldev-smoke.yml @@ -0,0 +1,86 @@ +# Smoke-test the azldev version pinned in .azldev-version. +# +# When a PR bumps .azldev-version (or touches the runner image / this workflow), +# build the runner container with that exact pin and confirm the resulting +# binary can (a) run and (b) parse every component definition in the repo via +# `azldev component list`. This catches the two failure modes of a version bump: +# the pin doesn't `go install`, or the new version breaks on the repo's TOMLs. +name: "azldev Smoke Test" + +on: + pull_request: + branches: ["4.0"] + paths: + - ".azldev-version" + - ".github/workflows/containers/azldev-runner.Dockerfile" + - ".github/workflows/azldev-smoke.yml" + workflow_dispatch: + +# Cancel in-progress runs of this workflow if a new run is triggered. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: {} + +jobs: + smoke: + name: "comp list" + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate .azldev-version format + run: | + set -euo pipefail + version="$(tr -d '\n' < .azldev-version)" + if [ -z "$version" ]; then + echo "::error::.azldev-version is empty" + exit 1 + fi + # Restrict to the charset Go module versions use (commit SHAs, tags, + # pseudo-versions). This blocks shell metacharacters so the value is + # safe to pass straight through to `docker build --build-arg` below. + if ! printf '%s' "$version" | grep -Eq '^[0-9A-Za-z._+-]+$'; then + echo "::error::.azldev-version contains unexpected characters: '$version'" + exit 1 + fi + echo "azldev version pin: $version" + + - name: Build azldev runner container + run: | + set -euo pipefail + docker build \ + --build-arg UID="$(id -u)" \ + --build-arg AZLDEV_VERSION="$(cat .azldev-version)" \ + -t localhost/azldev-runner \ + -f .github/workflows/containers/azldev-runner.Dockerfile \ + .github/workflows/containers/ + + # `component list` only parses TOML, so no mock sandbox flags are needed + # here (contrast with the render/build checks). Mount the checkout rw to + # match the documented /workdir convention and avoid surprises if azldev + # writes a cache. + - name: Smoke-test azldev + run: | + set -euo pipefail + docker run --rm \ + -v "$GITHUB_WORKSPACE:/workdir" \ + localhost/azldev-runner \ + bash -eu -o pipefail -c ' + echo "=== azldev version ===" + azldev --version + echo "=== azldev component list ===" + count=$(azldev component list -a -q -O json | jq length) + echo "azldev resolved ${count} component(s)" + if [ "${count}" -le 0 ]; then + echo "::error::azldev component list returned no components" + exit 1 + fi + ' diff --git a/.github/workflows/check-rendered-specs.yml b/.github/workflows/check-rendered-specs.yml index 396ec955b6b..177dd3fbb4d 100644 --- a/.github/workflows/check-rendered-specs.yml +++ b/.github/workflows/check-rendered-specs.yml @@ -15,8 +15,13 @@ # stale lock would steer it toward the wrong scope. When locks are dirty, # render is skipped and only the locks comment is posted. # -# Security: the PR checkout is data-only. We never execute code from the PR — -# azldev is installed from upstream, scripts come from the base branch checkout. +# Security: the PR checkout is data-only. We never run PR scripts, and the +# runner Dockerfile + build context come from the trusted base checkout. The +# azldev binary is built from the version pinned in the PR's .azldev-version +# (so a version-bump PR is validated with the binary that will actually run +# post-merge), but the Dockerfile hardcodes the module path to the +# Microsoft-owned github.com/microsoft/azure-linux-dev-tools repo -- the PR +# can only select a ref within that repo's network, not redirect the install. name: "Check Rendered Specs" on: @@ -83,9 +88,23 @@ jobs: - name: Build azldev runner container run: | + set -euo pipefail + # Build with the azldev version the PR proposes (pr-head/.azldev-version), + # NOT the base branch's. For a version-bump PR these differ, and the whole + # point of the drift check is to run with the binary that will actually run + # on 4.0 post-merge -- the base binary would pass the check and then drift + # the moment the bump lands. Only the version string comes from the PR; the + # Dockerfile + build context are the trusted base checkout, and the + # Dockerfile hardcodes the module path to the Microsoft-owned repo. + AZLDEV_VERSION="$(tr -d '\n' < pr-head/.azldev-version)" + # Hygiene: reject a malformed/garbage version before it reaches docker build. + if ! printf '%s' "$AZLDEV_VERSION" | grep -Eq '^[0-9A-Za-z._+-]+$'; then + echo "::error::pr-head/.azldev-version is empty or has unexpected characters" + exit 1 + fi docker build \ - --build-arg UID=$(id -u) \ - --build-arg AZLDEV_VERSION="$(cat .azldev-version)" \ + --build-arg UID="$(id -u)" \ + --build-arg AZLDEV_VERSION="$AZLDEV_VERSION" \ -t localhost/azldev-runner \ -f .github/workflows/containers/azldev-runner.Dockerfile \ .github/workflows/containers/ @@ -297,9 +316,23 @@ jobs: - name: Build azldev runner container run: | + set -euo pipefail + # Build with the azldev version the PR proposes (pr-head/.azldev-version), + # NOT the base branch's. For a version-bump PR these differ, and the whole + # point of the drift check is to run with the binary that will actually run + # on 4.0 post-merge -- the base binary would pass the check and then drift + # the moment the bump lands. Only the version string comes from the PR; the + # Dockerfile + build context are the trusted base checkout, and the + # Dockerfile hardcodes the module path to the Microsoft-owned repo. + AZLDEV_VERSION="$(tr -d '\n' < pr-head/.azldev-version)" + # Hygiene: reject a malformed/garbage version before it reaches docker build. + if ! printf '%s' "$AZLDEV_VERSION" | grep -Eq '^[0-9A-Za-z._+-]+$'; then + echo "::error::pr-head/.azldev-version is empty or has unexpected characters" + exit 1 + fi docker build \ - --build-arg UID=$(id -u) \ - --build-arg AZLDEV_VERSION="$(cat .azldev-version)" \ + --build-arg UID="$(id -u)" \ + --build-arg AZLDEV_VERSION="$AZLDEV_VERSION" \ -t localhost/azldev-runner \ -f .github/workflows/containers/azldev-runner.Dockerfile \ .github/workflows/containers/ @@ -348,86 +381,101 @@ jobs: -e BASE_REPO \ localhost/azldev-runner \ bash -eu -o pipefail -c ' - # Stale forks may not have the upstream base SHA in their - # object store. Fetch it explicitly so `compute_changed.sh` - # can resolve `--from `. The base repo is public - # so no auth is needed. + SPECS_DIR=$(azldev config dump -q -f json | jq -r .project.renderedSpecsDir) + + # Stale forks may not have the upstream base SHA in their object + # store. Fetch it explicitly so the version-bump diff below and + # `compute_change_set.sh` can both resolve the base commit. The + # base repo is public so no auth is needed. git -C /workdir remote add base "https://github.com/$BASE_REPO.git" 2>/dev/null || true git -C /workdir fetch --no-tags base "$PR_BASE_SHA" - # Compute the render set (PR-touched components). Safe here - # because the locks gate has already run successfully -- input - # fingerprints used by `azldev component changed` reflect the - # current head. - /scripts-components/compute_change_set.sh \ - --output-dir /output/change-set \ - --source-commit "$PR_HEAD_SHA" \ - --target-commit "$PR_BASE_SHA" - - SPECS_DIR=$(azldev config dump -q -f json | jq -r .project.renderedSpecsDir) - - # Components removed in this PR no longer have a comp.toml, so - # `azldev component render` will not touch (or clean) their old - # specs directory. Delete `///` for - # each deleted component in the working tree so the missing - # files surface as drift in the existing - # `check_rendered_specs.py` report (and end up in the fix - # patch as deletions). This replaces the orphan-cleanup that - # `azldev component render -a --clean-stale` used to do for - # us; with a scoped render the deleted components are not in - # the render set, so nothing else cleans them. - # - # Scope caveat: this only catches components deleted IN THIS - # PR. Pre-existing orphans (stale spec dirs whose component - # was removed in an earlier PR without a matching render) - # are not caught here. - jq -r ".[] | select(.changeType == \"deleted\") | .component" \ - /output/change-set/changed-components.json \ - | while IFS= read -r name; do - [ -n "$name" ] || continue - # Defense-in-depth: refuse component names with path - # metacharacters before constructing the rm -rf target. - # `azldev` does not currently validate names against - # this set, and `.comp.toml` quoted keys allow `/`, - # `..`, etc. - if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then - echo "::warning::Refusing suspicious component name from changed-components.json: $name" - continue - fi - # Spec bucket dirs are lowercase (specs/a/, specs/r/, ...) - # while component names can be uppercase (e.g. `R`, `AMF`, - # `CGAL`). Lowercase the first char so the path matches. - first_char="${name:0:1}" - first_char="${first_char,,}" - orphan_dir="$SPECS_DIR/$first_char/$name" - if [ -d "$orphan_dir" ]; then - echo "Cleaning orphan specs dir for deleted component: $orphan_dir" - rm -rf "$orphan_dir" - fi - done - - if [ ! -s /output/change-set/render-set.txt ]; then - echo "No PR-scoped components -- skipping render." - # Synthesize an empty render-output.json so the artifact - # upload (if: always()) and downstream comment job have a - # well-formed payload. - echo "[]" > /output/render-output.json - else - # `-x` fails loudly if the arg list would split into multiple - # xargs invocations -- each batch after the first would - # overwrite the previous JSON output to render-output.json. - # Current component counts are well below ARG_MAX, but - # `-n 50000` gives `-x` teeth (without `-n`/`-L`, GNU xargs - # never splits). `--` ends azldev option parsing so component - # names beginning with `-` (none today) are unambiguous. - xargs -x -n 50000 -d "\n" azldev component render -q -O json -- \ - < /output/change-set/render-set.txt \ + # A change to .azldev-version swaps the azldev binary, which can + # alter rendered output for ANY component -- not just the ones + # this PR edits -- so a PR-scoped render would miss that drift. + # Detect it with a plain git diff (both commits are local after + # the fetch above) and render everything when it moved. Using git + # here -- rather than the REST "list PR files" endpoint -- avoids + # the 3000-file cap and 30-results-per-page pagination of that + # endpoint, so detection stays correct for arbitrarily large PRs. + if git -C /workdir diff --name-only "$PR_BASE_SHA" "$PR_HEAD_SHA" -- .azldev-version | grep -q .; then + # `--clean-stale` prunes orphaned spec dirs globally, which + # subsumes the targeted deleted-component cleanup the scoped + # branch does below. + echo ".azldev-version changed -- rendering ALL components." + azldev component render -q -a --clean-stale -O json \ > /output/render-output.json + else + # Compute the render set (PR-touched components). Safe here + # because the locks gate has already run successfully -- input + # fingerprints used by `azldev component changed` reflect the + # current head. + /scripts-components/compute_change_set.sh \ + --output-dir /output/change-set \ + --source-commit "$PR_HEAD_SHA" \ + --target-commit "$PR_BASE_SHA" + + # Components removed in this PR no longer have a comp.toml, so + # `azldev component render` will not touch (or clean) their old + # specs directory. Delete `///` for + # each deleted component in the working tree so the missing + # files surface as drift in the existing + # `check_rendered_specs.py` report (and end up in the fix + # patch as deletions). The render-all branch above does not + # need this: `render -a --clean-stale` already prunes orphans. + # + # Scope caveat: this only catches components deleted IN THIS + # PR. Pre-existing orphans (stale spec dirs whose component + # was removed in an earlier PR without a matching render) + # are not caught here. + jq -r ".[] | select(.changeType == \"deleted\") | .component" \ + /output/change-set/changed-components.json \ + | while IFS= read -r name; do + [ -n "$name" ] || continue + # Defense-in-depth: refuse component names with path + # metacharacters before constructing the rm -rf target. + # `azldev` does not currently validate names against + # this set, and `.comp.toml` quoted keys allow `/`, + # `..`, etc. + if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then + echo "::warning::Refusing suspicious component name from changed-components.json: $name" + continue + fi + # Spec bucket dirs are lowercase (specs/a/, specs/r/, ...) + # while component names can be uppercase (e.g. `R`, `AMF`, + # `CGAL`). Lowercase the first char so the path matches. + first_char="${name:0:1}" + first_char="${first_char,,}" + orphan_dir="$SPECS_DIR/$first_char/$name" + if [ -d "$orphan_dir" ]; then + echo "Cleaning orphan specs dir for deleted component: $orphan_dir" + rm -rf "$orphan_dir" + fi + done + + if [ ! -s /output/change-set/render-set.txt ]; then + echo "No PR-scoped components -- skipping render." + # Synthesize an empty render-output.json so the artifact + # upload (if: always()) and downstream comment job have a + # well-formed payload. + echo "[]" > /output/render-output.json + else + # `-x` fails loudly if the arg list would split into multiple + # xargs invocations -- each batch after the first would + # overwrite the previous JSON output to render-output.json. + # Current component counts are well below ARG_MAX, but + # `-n 50000` gives `-x` teeth (without `-n`/`-L`, GNU xargs + # never splits). `--` ends azldev option parsing so component + # names beginning with `-` (none today) are unambiguous. + xargs -x -n 50000 -d "\n" azldev component render -q -O json -- \ + < /output/change-set/render-set.txt \ + > /output/render-output.json + fi fi # check_rendered_specs.py diffs the working tree (now containing - # any scoped re-render output and any orphan-cleanup deletions) - # against HEAD and produces the user-facing patch. + # any re-render output and any orphan-cleanup deletions) against + # HEAD and produces the user-facing patch. python3 /scripts-render/check_rendered_specs.py \ --specs-dir "$SPECS_DIR" \ --report /output/render-check-report.json \