Skip to content

Commit 3242244

Browse files
dmcilvaneychristopherco
authored andcommitted
ci(spec render): Add informational workflow that detects stale spec renders
1 parent 3d32f59 commit 3242244

7 files changed

Lines changed: 979 additions & 324 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Stub workflow — A copy of this workflow must live on the default branch (3.0) so that the
2+
# pull_request_target event can trigger it with access to GITHUB_TOKEN (pull-requests: write).
3+
# It delegates all real work to the reusable template on tomls/base/main.
4+
#
5+
# This two-stage design lets fork PRs trigger the check safely: the stub runs in the
6+
# context of the default branch (with write token), but the reusable workflow checks out
7+
# the PR's data files (TOML configs, specs) into a separate directory — never mixing
8+
# untrusted code with execution context.
9+
#
10+
# The stub must exist on the default branch because pull_request_target always runs
11+
# workflows from there. The reusable workflow on tomls/base/main has the actual scripts,
12+
# container setup, and rendering logic.
13+
name: Check Rendered Specs
14+
15+
# pull_request_target gives us a GITHUB_TOKEN with pull-requests: write even for fork PRs.
16+
# The stub itself runs NO code from the PR — it only delegates to a trusted reusable
17+
# workflow pinned to tomls/base/main, which checks out PR data (not code) into an
18+
# isolated subdirectory.
19+
on: # zizmor: ignore[dangerous-triggers]
20+
pull_request_target:
21+
branches:
22+
- tomls/base/main
23+
24+
permissions: {}
25+
26+
concurrency:
27+
group: render-check-${{ github.event.pull_request.number }}
28+
cancel-in-progress: true
29+
30+
jobs:
31+
check:
32+
# Prevent forks from running a stale/vulnerable copy of this stub with Actions enabled
33+
if: github.repository == 'microsoft/azurelinux'
34+
# Intentionally branch-pinned so the reusable workflow picks up updates automatically.
35+
uses: microsoft/azurelinux/.github/workflows/check-rendered-specs.yml@tomls/base/main # zizmor: ignore[unpinned-uses]
36+
permissions:
37+
contents: read
38+
pull-requests: write # Post/update/delete drift comments on PRs
39+
with:
40+
pr-head-sha: ${{ github.event.pull_request.head.sha }}
41+
pr-head-repo: ${{ github.event.pull_request.head.repo.full_name }}
42+
pr-number: ${{ github.event.pull_request.number }}
43+
repo: ${{ github.repository }}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Reusable workflow — renders specs from a PR and checks for drift.
2+
#
3+
# Called by the stub on the default branch (check-rendered-specs-stub.yml) via
4+
# pull_request_target. The stub provides the PR details; this workflow does all
5+
# the real work:
6+
# 1. Checks out base branch (trusted tools/scripts)
7+
# 2. Checks out PR head into pr-head/ (untrusted data — TOML configs, specs)
8+
# 3. Renders specs inside a privileged container using azldev -C pr-head/
9+
# 4. Checks for drift (compares rendered output against PR's committed specs)
10+
# 5. Posts a PR comment with results + downloadable fix patch
11+
#
12+
# Security: the PR checkout is data-only. We never execute code from the PR —
13+
# azldev is installed from upstream, scripts come from the base branch checkout.
14+
name: "Check Rendered Specs"
15+
16+
on:
17+
workflow_call:
18+
inputs:
19+
pr-head-sha:
20+
required: true
21+
type: string
22+
pr-head-repo:
23+
required: true
24+
type: string
25+
pr-number:
26+
required: true
27+
type: string
28+
repo:
29+
required: true
30+
type: string
31+
32+
permissions: {}
33+
34+
# Belt-and-suspenders: the stub also sets concurrency, but keep it here so the
35+
# contract survives refactoring / direct invocation.
36+
concurrency:
37+
group: render-check-${{ inputs.repo }}-${{ inputs.pr-number }}
38+
cancel-in-progress: true
39+
40+
jobs:
41+
# Render PR's specs + check for drift. Runs PR-derived data through the
42+
# container; deliberately has NO pull-requests write (and no secrets beyond
43+
# the default read-only token) so that even if the container somehow leaks
44+
# into the host, it can't touch the PR. All output flows to the next job via
45+
# artifacts + job outputs only.
46+
render:
47+
name: Render + drift check
48+
runs-on: ubuntu-latest
49+
timeout-minutes: 60
50+
permissions:
51+
contents: read
52+
outputs:
53+
patch-url: ${{ steps.upload-patch.outputs.artifact-url }}
54+
steps:
55+
# --- Trusted base branch (tools, scripts, container config) ---
56+
- name: Checkout base (trusted)
57+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
58+
with:
59+
repository: ${{ inputs.repo }}
60+
ref: tomls/base/main
61+
persist-credentials: false
62+
63+
# --- PR head (untrusted data — TOML configs, overlays, specs) ---
64+
- name: Checkout PR head (data)
65+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
66+
with:
67+
repository: ${{ inputs.pr-head-repo }}
68+
ref: ${{ inputs.pr-head-sha }}
69+
path: pr-head
70+
fetch-depth: 0
71+
persist-credentials: false
72+
73+
- name: Build azldev runner container
74+
run: |
75+
docker build \
76+
--build-arg UID=$(id -u) \
77+
-t localhost/azldev-runner \
78+
-f .github/workflows/containers/azldev-runner.Dockerfile \
79+
.github/workflows/containers/
80+
81+
# Render + drift-check run entirely inside the container. The host never
82+
# invokes git against PR data: all git operations happen in the sandboxed
83+
# environment, and the host only reads trusted-shape outputs
84+
# (report.json, rendered-specs.patch) from a dedicated output volume.
85+
# This dodges the whole class of poisoned-.git/config attacks
86+
# (diff.external, diff drivers, filter drivers, hooks, etc.).
87+
#
88+
# Sandbox knobs:
89+
# --cap-add=SYS_ADMIN mock needs mount namespaces for chroot
90+
# seccomp=unconfined mock uses syscalls filtered by the default profile
91+
# apparmor=unconfined ubuntu-latest ships docker-default AppArmor which
92+
# blocks `mount -t tmpfs` on paths under /var/lib/mock
93+
# even with SYS_ADMIN granted
94+
# We still avoid --privileged (broader blast radius).
95+
# --security-opt no-new-privileges would be nice but mock's userhelper
96+
# requires setuid, which that flag blocks.
97+
- name: Render + check for drift
98+
id: check
99+
continue-on-error: true # TODO: flip off once check stabilizes (see PR #16674)
100+
env:
101+
WORKSPACE: ${{ github.workspace }}
102+
run: |
103+
set -euo pipefail
104+
mkdir -p "$WORKSPACE/render-output"
105+
docker run --rm \
106+
--cap-add=SYS_ADMIN \
107+
--security-opt seccomp=unconfined \
108+
--security-opt apparmor=unconfined \
109+
-v "$WORKSPACE/pr-head:/workdir" \
110+
-v "$WORKSPACE/render-output:/output" \
111+
-v "$WORKSPACE/.github/workflows/scripts:/scripts:ro" \
112+
localhost/azldev-runner \
113+
bash -eu -o pipefail -c '
114+
azldev component render -q -a --clean-stale -O json \
115+
> /output/render-output.json
116+
SPECS_DIR=$(azldev config dump -q -f json \
117+
| python3 -c "import json,sys; print(json.load(sys.stdin)[\"project\"][\"renderedSpecsDir\"])")
118+
python3 /scripts/check_rendered_specs.py \
119+
--specs-dir "$SPECS_DIR" \
120+
--report /output/render-check-report.json \
121+
--patch /output/rendered-specs.patch
122+
'
123+
124+
# Dual upload: `archive: false` (v7 feature) gives browser users a direct
125+
# download of the raw patch (artifact name is derived from the filename,
126+
# so `name:` is ignored here — that's why we need the second upload).
127+
# The zipped `rendered-specs-patch` artifact is for `gh run download`,
128+
# which only works with named artifacts.
129+
- name: Upload fix patch (unzipped, for browser download)
130+
id: upload-patch
131+
if: hashFiles('render-output/rendered-specs.patch') != ''
132+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
133+
with:
134+
path: render-output/rendered-specs.patch
135+
archive: false
136+
137+
# See: https://github.com/cli/cli/issues/13012 for why this is needed.
138+
- name: Upload fix patch (zipped, for gh run download)
139+
if: hashFiles('render-output/rendered-specs.patch') != ''
140+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
141+
with:
142+
name: rendered-specs-patch
143+
path: render-output/rendered-specs.patch
144+
145+
- name: Upload render output
146+
if: always()
147+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
148+
with:
149+
name: render-output
150+
path: |
151+
render-output/render-output.json
152+
render-output/render-check-report.json
153+
154+
# Post the PR comment. Runs in a separate job so the `pull-requests: write`
155+
# token is only granted to a job that does NOT execute any PR-derived code:
156+
# it only checks out the trusted base branch (for the poster script) and
157+
# consumes the trusted-shape report artifact from the render job.
158+
comment:
159+
name: Post drift comment
160+
needs: render
161+
if: always() && needs.render.result != 'cancelled'
162+
runs-on: ubuntu-latest
163+
timeout-minutes: 5
164+
permissions:
165+
contents: read
166+
pull-requests: write # Post/update/delete drift comments on PRs
167+
steps:
168+
- name: Checkout base (trusted)
169+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
170+
with:
171+
repository: ${{ inputs.repo }}
172+
ref: tomls/base/main
173+
persist-credentials: false
174+
175+
- name: Set up Python
176+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
177+
with:
178+
python-version: "3.12"
179+
180+
- name: Download render report
181+
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
182+
with:
183+
name: render-output
184+
path: render-output
185+
186+
- name: Post PR comment
187+
continue-on-error: true
188+
env:
189+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
190+
PR_REPO: ${{ inputs.repo }}
191+
PR_NUMBER: ${{ inputs.pr-number }}
192+
PATCH_URL: ${{ needs.render.outputs.patch-url }}
193+
RUN_ID: ${{ github.run_id }}
194+
run: |
195+
python .github/workflows/scripts/post_render_comment.py \
196+
--repo "$PR_REPO" \
197+
--pr "$PR_NUMBER" \
198+
--report render-output/render-check-report.json \
199+
--artifacts-url "$PATCH_URL" \
200+
--run-id "$RUN_ID"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
FROM mcr.microsoft.com/azurelinux/base/core:3.0
2+
3+
# Generic azldev runner image for CI PR checks. Provides the toolchain
4+
# required to run arbitrary `azldev` subcommands (render, build, ...)
5+
# against an untrusted PR checkout.
6+
#
7+
# Callers are expected to bind-mount:
8+
# /workdir : PR checkout (typically rw — azldev writes specs/ and build/)
9+
# /output : trusted-shape outputs produced by the container (ro on host)
10+
# /scripts : trusted helper scripts from the base branch (ro)
11+
#
12+
# `azldev` is baked into the image (installed to /usr/local/bin) so callers
13+
# don't need to set up Go or bind-mount a GOPATH.
14+
#
15+
# Kept intentionally minimal — anything that isn't needed by every azldev
16+
# workflow should be added by the caller (e.g. via a derived image) rather
17+
# than baked in here.
18+
# build-essential + openssl/symcrypt/symcrypt-openssl: required by Microsoft
19+
# Go's default `systemcrypto` GOEXPERIMENT (cgo at build time, system crypto
20+
# libs at run time). See:
21+
# https://github.com/microsoft/go/blob/microsoft/main/eng/doc/MigrationGuide.md
22+
RUN tdnf -y install \
23+
build-essential \
24+
ca-certificates \
25+
git \
26+
golang \
27+
mock \
28+
mock-rpmautospec \
29+
openssl \
30+
python3 \
31+
shadow-utils \
32+
sudo \
33+
symcrypt \
34+
symcrypt-openssl \
35+
&& tdnf clean all
36+
37+
# TODO: pin to a tagged release once azure-linux-dev-tools cuts one.
38+
# `@main` is a moving target — fine while azldev is pre-1.0 and we want
39+
# CI to track upstream, but we should swap to `@vX.Y.Z` (and bump it
40+
# deliberately) once the tool stabilizes. ADO #18834
41+
RUN GOBIN=/usr/local/bin go install \
42+
github.com/microsoft/azure-linux-dev-tools/cmd/azldev@main \
43+
&& rm -rf /root/go /root/.cache
44+
45+
ARG UID=1000
46+
47+
RUN useradd -u "${UID}" -G mock -m builduser
48+
49+
USER builduser
50+
WORKDIR /workdir

0 commit comments

Comments
 (0)