Browse / github.com/storytold/craft-common-actions / patch
ci: keep release drafts and tool installs on reviewed inputs
ci: keep release drafts and tool installs on reviewed inputs ## Summary - Rebased onto current `main`. FreeBSD custom commands and quoted Cargo and Nix arguments already land there as data, so those older commits are not replayed. - A draft GitHub Release is published only for a run on the `release` branch. Another run for the same repository and ref waits. Build jobs stay at `contents: read`; only the draft job has `contents: write`. - A missing `.deb`, an empty AppImage, or an unreadable server archive fails the job. The archive architecture is passed through `MATRIX_ARCH`. nfpm, Trunk, and actionlint install only versions whose SHA256 is stored in th
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ihsanmokhlisse (imported from pull request #28, open)
- Base
be6bbf3ca5a5de466786908b3986163da7c05314- Size
- +261 −27 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c6eb93c73a63bf02658a51f6
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/freebsd.yml .github/workflows/nix.yml .github/workflows/packaging-lint.yml .github/workflows/release.yml .github/workflows/test-freebsd.yml .github/workflows/windows-arm64.yml README.md actions/install-nfpm/action.yml actions/install-trunk/action.yml actions/packaging-lint/action.yml tests/test_freebsd.py tests/test_release_guards.py tests/test_release_inputs.py
@@ -72,6 +72,8 @@ jobs: CARGO_BUILD_JOBS: 4 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-fonts if: inputs.craft-fonts-ref != '' uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main@@ -19,6 +19,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - uses: DeterminateSystems/nix-installer-action@3138316df39ed29be04236d7ffc686fa525866aa # v23 - name: Check flake evaluation shell: bash@@ -14,12 +14,17 @@ on: required: false default: "" +permissions:+ contents: read+ jobs: lint: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Run packaging lint uses: storytold/craft-common-actions/actions/packaging-lint@main with:@@ -28,4 +33,15 @@ jobs: if: inputs.extra-checks-script != '' env: CHECKS_SCRIPT: ${{ inputs.extra-checks-script }}- run: bash -- "$CHECKS_SCRIPT"+ run: |+ case "$CHECKS_SCRIPT" in+ ""|/*|../*|*/../*|*/..)+ echo "::error::extra-checks-script must be a relative path inside the repository"+ exit 1+ ;;+ esac+ if [ ! -f "$CHECKS_SCRIPT" ]; then+ echo "::error::extra-checks-script was not found: $CHECKS_SCRIPT"+ exit 1+ fi+ bash -- "$CHECKS_SCRIPT"@@ -121,6 +121,10 @@ on: permissions: contents: read +concurrency:+ group: craft-release-${{ github.repository }}-${{ github.ref }}+ cancel-in-progress: false+ jobs: # Fails fast before any build: git dependencies must come from github.com/storytold, and the # lock file must not declare source replacement, patches or path overrides that could silently@@ -134,6 +138,8 @@ jobs: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Git dependencies come from storytold run: | set -euo pipefail@@ -177,6 +183,8 @@ jobs: prerelease: ${{ steps.v.outputs.prerelease }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - id: v name: Resolve version uses: storytold/craft-common-actions/actions/resolve-version@main@@ -264,6 +272,8 @@ jobs: exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-fonts if: inputs.craft-fonts-ref != '' uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main@@ -404,6 +414,8 @@ jobs: exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-fonts if: inputs.craft-fonts-ref != '' uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main@@ -509,6 +521,8 @@ jobs: INPUT_VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-fonts if: inputs.craft-fonts-ref != '' uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main@@ -535,13 +549,22 @@ jobs: CRAFT_FONTS_REQUIRED: "1" run: packaging/linux/package.sh - name: Check the packages+ shell: bash run: |- dpkg-deb --info dist/release/*.deb || true- dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/|metainfo|applications' || true+ shopt -s nullglob+ debs=(dist/release/*.deb)+ if [ "${#debs[@]}" -eq 0 ]; then+ echo "::error::no deb package was produced"+ exit 1+ fi+ for deb in "${debs[@]}"; do+ dpkg-deb --info "$deb" >/dev/null+ dpkg-deb --contents "$deb" >/dev/null+ done for appimage in dist/release/*.AppImage; do- if [ -f "$appimage" ]; then- chmod +x "$appimage"- APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version || true+ if [ ! -s "$appimage" ]; then+ echo "::error::AppImage is empty: $appimage"+ exit 1 fi done - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2@@ -612,6 +635,8 @@ jobs: FIGCRAFT_BUILD_SHA: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }}@@ -657,6 +682,8 @@ jobs: arch: [x86_64, aarch64] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }}@@ -771,6 +798,8 @@ jobs: ;; esac - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-fonts if: inputs.craft-fonts-ref != '' uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main@@ -1014,6 +1043,8 @@ jobs: INPUT_VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable@@ -1112,6 +1143,8 @@ jobs: INPUT_VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-fonts if: inputs.craft-fonts-ref != '' uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main@@ -1150,9 +1183,18 @@ jobs: QEMU_LD_PREFIX: /usr/riscv64-linux-gnu run: packaging/linux/package.sh --formats "deb rpm tar" - name: Check the packages+ shell: bash run: |- dpkg-deb --info dist/release/*-linux-riscv64.deb 2>/dev/null || true- dpkg-deb --contents dist/release/*-linux-riscv64.deb 2>/dev/null | grep -E 'usr/bin/|metainfo|applications' || true+ shopt -s nullglob+ debs=(dist/release/*-linux-riscv64.deb)+ if [ "${#debs[@]}" -eq 0 ]; then+ echo "::error::no riscv64 deb package was produced"+ exit 1+ fi+ for deb in "${debs[@]}"; do+ dpkg-deb --info "$deb" >/dev/null+ dpkg-deb --contents "$deb" >/dev/null+ done - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-riscv64@@ -1183,6 +1225,8 @@ jobs: FIGCRAFT_BUILD_SHA: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-i18n uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1@@ -1193,15 +1237,16 @@ jobs: key: release-server-${{ matrix.arch }} - name: Apply version override if: inputs.version != ''- run: cargo xtask version set "$VERSION" || cargo run --locked -q -p xtask -- version set "$VERSION" || true+ run: cargo xtask version set "$VERSION" || cargo run --locked -q -p xtask -- version set "$VERSION" - name: Build and package (tar.gz) run: packaging/server/package.sh - name: Check the archive shell: bash env: APP_NAME: ${{ inputs.app-name }}+ MATRIX_ARCH: ${{ matrix.arch }} run: |- tar -tzf "dist/release/$APP_NAME-server-$VERSION-linux-${{ matrix.arch }}.tar.gz" || true+ tar -tzf "dist/release/$APP_NAME-server-$VERSION-linux-$MATRIX_ARCH.tar.gz" >/dev/null - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-server-linux-${{ matrix.arch }}@@ -1211,7 +1256,7 @@ jobs: release: timeout-minutes: 10 name: Draft GitHub Release- if: "!cancelled() && !contains(needs.*.result, 'failure')"+ if: github.ref == 'refs/heads/release' && !cancelled() && !contains(needs.*.result, 'failure') needs: [version, macos, windows, linux, linux-riscv64, flatpak, arch, freebsd, web, server] runs-on: ubuntu-latest environment: release@@ -35,6 +35,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Install YAML parser run: sudo apt-get update && sudo apt-get install -y --no-install-recommends python3-yaml tcsh - name: Exercise failing tests, package validation and required fonts@@ -46,6 +46,8 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false - name: Check out craft-i18n uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1@@ -67,6 +67,8 @@ jobs: List every signing secret explicitly. The signing jobs run in the caller repo's `release` environment and read the values from there, but only for the names listed here: an unlisted name is empty. On the `release` branch the macOS and Windows jobs fail when their signing secrets are empty, rather than shipping unsigned builds. +A draft GitHub Release is published only when the caller runs on the `release` branch. A run from another branch still builds artifacts and does not create or replace the draft. Overlapping runs for the same repository and ref wait. Build jobs use `contents: read`. Only the draft job has `contents: write`.+ macOS and Windows packages are signed by the app's own packaging scripts. `release.yml` does not request `id-token` or `attestations`, so existing callers keep building as they do now. Build provenance, a CycloneDX SBOM, and a Sigstore signature of the published files are a second job. Add it only in an app that is ready to grant those permissions. `release.yml` does not call `supply-chain.yml`.@@ -444,7 +446,7 @@ Individual actions are also available for composing custom pipelines: - `actions/checkout-craft-i18n`: Checks out `storytold/craft-i18n` at the caller's `craft-i18n.rev`, if any, and sets `CRAFT_I18N_DIR` and `CRAFT_I18N_REQUIRED=1`. - `actions/resolve-version`: Slices version from `packaging/env.sh` and validates format. - `actions/install-wix`: Installs WiX toolset and UI/Util extensions.-- `actions/install-nfpm`: Installs `nfpm` with strict SHA256 checksum verification.+- `actions/install-nfpm`: Installs `nfpm` only for a version whose SHA256 is stored in the action. - `actions/install-linux-deps`: Installs Linux system build libraries and nfpm. - `actions/install-apt-packages`: Installs the given Ubuntu packages and keeps the downloaded `.deb` files in the Actions cache, so later runs install without the mirror download. By default only pushes to the default branch save the cache; PRs restore it. For an app's CI: @@ -454,9 +456,9 @@ Individual actions are also available for composing custom pipelines: packages: pkg-config libxkbcommon-dev libwayland-dev mesa-vulkan-drivers ``` - `actions/install-riscv64-cross`: Installs Ubuntu ports RISC-V multiarch and cross-compilers.-- `actions/install-trunk`: Installs Trunk for WebAssembly builds.+- `actions/install-trunk`: Installs Trunk for WebAssembly builds only for a version whose SHA256 is stored in the action. - `actions/draft-release`: Collects artifacts, calculates SHA256 checksums, and publishes/updates draft GitHub release.-- `actions/packaging-lint`: Steps for packaging linter.+- `actions/packaging-lint`: Steps for packaging linter. actionlint installs only for a version whose SHA256 is stored in the action. - `actions/ast-grep`: Installs a pinned ast-grep, runs the caller's rule tests and scans with its `sgconfig.yml` (see `ast-grep.yml`). ## Storytold Organization Hosting@@ -468,8 +470,9 @@ This repository is hosted at `storytold/craft-common-actions` for all Storytold Keep app-specific packaging assertions in the caller repository and pass their Bash script to `packaging-lint.yml` with `extra-checks-script: packaging/check-app-specific.sh`. It runs after the shared-lint action, with the checkout and lint tools available. A missing or failing-requested script fails the job. Native Windows installer probes remain separate+lint action, with the checkout and lint tools available. The script path has to+stay inside the repository: an absolute path or a `..` segment is rejected. A+missing or failing requested script fails the job. Native Windows installer probes remain separate caller jobs; Linux static lint does not replace them. ## Job time limits@@ -48,8 +48,8 @@ runs: expected="27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97" ;; *)- curl --proto '=https' --proto-redir =https -fsSL -o "$RUNNER_TEMP/checksums.txt" "https://github.com/goreleaser/nfpm/releases/download/v${version}/checksums.txt"- expected=$(grep -E "nfpm_${version}_${arch}\.deb" "$RUNNER_TEMP/checksums.txt" | awk '{print $1}')+ echo "::error::no reviewed SHA256 for nfpm ${version} (${arch}). Add the hash to this action before using that version."+ exit 1 ;; esac @@ -32,8 +32,8 @@ runs: expected="f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b" ;; *)- curl --proto '=https' --proto-redir =https -fsSL -o "$RUNNER_TEMP/trunk.sha256" "$tar_url.sha256"- expected=$(awk '{print $1}' "$RUNNER_TEMP/trunk.sha256")+ echo "::error::no reviewed SHA256 for trunk ${version}. Add the hash to this action before using that version."+ exit 1 ;; esac echo "$expected $RUNNER_TEMP/trunk.tar.gz" | sha256sum -c -@@ -29,8 +29,8 @@ runs: expected="8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" ;; *)- curl --proto '=https' --proto-redir =https -fsSL -o "$RUNNER_TEMP/checksums.txt" "https://github.com/rhysd/actionlint/releases/download/v${version}/actionlint_${version}_checksums.txt"- expected=$(grep -E "actionlint_${version}_linux_amd64\.tar\.gz" "$RUNNER_TEMP/checksums.txt" | awk '{print $1}')+ echo "::error::no reviewed SHA256 for actionlint ${version}. Add the hash to this action before using that version."+ exit 1 ;; esac echo "$expected $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -@@ -203,6 +203,12 @@ fi self.assertEqual(result["code"], 0, result) self.assertFalse(result["injected"], result) + def test_custom_command_delimiter_cannot_exit_the_outer_script(self):+ result = self.run_vm(command="CRAFT_TESTS\ntouch injected\nexit 0\n", package_check=False)+ self.assertNotEqual(result["code"], 0, result)+ self.assertFalse(result["injected"], result)+ self.assertFalse(result["packaged"], result)+ def test_each_failure_in_custom_chain_is_fatal_before_packaging(self): command = " && ".join("cargo test --group " + str(n) for n in range(1, 5)) for group in range(1, 5):new file mode 100644@@ -0,0 +1,152 @@+"""Release checks fail closed, and tool installs accept only reviewed hashes."""++from pathlib import Path+import os+import stat+import subprocess+import tempfile+import unittest++import yaml+++ROOT = Path(__file__).resolve().parents[1]+RELEASE = ROOT / ".github/workflows/release.yml"+PACKAGING_LINT = ROOT / ".github/workflows/packaging-lint.yml"+++def load(path):+ # BaseLoader keeps the workflow key "on" as a string. SafeLoader treats it as boolean true.+ return yaml.load(path.read_text(), Loader=yaml.BaseLoader)+++class ReleaseGuardTests(unittest.TestCase):+ def setUp(self):+ self.release = load(RELEASE)++ def test_draft_waits_for_the_release_branch(self):+ self.assertEqual(+ self.release["concurrency"]["group"],+ "craft-release-${{ github.repository }}-${{ github.ref }}",+ )+ self.assertEqual(self.release["concurrency"]["cancel-in-progress"], "false")+ draft = self.release["jobs"]["release"]+ self.assertIn("github.ref == 'refs/heads/release'", draft["if"])+ self.assertEqual(self.release["permissions"]["contents"], "read")+ self.assertEqual(draft["permissions"]["contents"], "write")++ def test_app_checkouts_do_not_persist_credentials(self):+ for job in self.release["jobs"].values():+ for step in job["steps"]:+ if step.get("uses", "").startswith("actions/checkout@"):+ self.assertEqual(step["with"]["persist-credentials"], "false")++ def check_step(self, job_name):+ return next(step for step in self.release["jobs"][job_name]["steps"]+ if step.get("name") == "Check the packages")++ def run_package_check(self, job_name, files):+ step = self.check_step(job_name)+ with tempfile.TemporaryDirectory(prefix="release-guard-") as directory:+ work = Path(directory)+ tools = work / "tools"+ tools.mkdir()+ dpkg = tools / "dpkg-deb"+ dpkg.write_text("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$PWD/dpkg.log\"\n")+ dpkg.chmod(dpkg.stat().st_mode | stat.S_IEXEC)+ for name, contents in files.items():+ path = work / name+ path.parent.mkdir(parents=True, exist_ok=True)+ path.write_text(contents)+ result = subprocess.run(["bash", "-c", step["run"]], cwd=work, text=True,+ capture_output=True, timeout=10,+ env={**os.environ, "PATH": str(tools) + os.pathsep + os.environ["PATH"]})+ log = (work / "dpkg.log").read_text().splitlines() if (work / "dpkg.log").exists() else []+ return result, log++ def test_linux_package_check_rejects_a_missing_deb(self):+ result, log = self.run_package_check("linux", {})+ self.assertNotEqual(result.returncode, 0, result.stderr)+ self.assertIn("no deb package was produced", result.stdout)+ self.assertEqual(log, [])++ def test_linux_package_check_rejects_an_empty_appimage(self):+ result, log = self.run_package_check("linux", {+ "dist/release/app.deb": "deb",+ "dist/release/app.AppImage": "",+ })+ self.assertNotEqual(result.returncode, 0, result.stderr)+ self.assertIn("AppImage is empty", result.stdout)+ self.assertEqual(log, ["--info dist/release/app.deb", "--contents dist/release/app.deb"])++ def test_linux_package_check_accepts_a_deb_without_an_appimage(self):+ result, log = self.run_package_check("linux", {"dist/release/app.deb": "deb"})+ self.assertEqual(result.returncode, 0, result.stderr)+ self.assertEqual(log, ["--info dist/release/app.deb", "--contents dist/release/app.deb"])++ def test_riscv64_package_check_rejects_a_missing_deb(self):+ result, log = self.run_package_check("linux-riscv64", {"dist/release/app-linux-x86_64.deb": "deb"})+ self.assertNotEqual(result.returncode, 0, result.stderr)+ self.assertIn("no riscv64 deb package was produced", result.stdout)+ self.assertEqual(log, [])++ def test_server_archive_check_fails_when_tar_fails(self):+ step = next(item for item in self.release["jobs"]["server"]["steps"]+ if item.get("name") == "Check the archive")+ self.assertNotIn("${{ matrix.arch }}", step["run"])+ self.assertNotIn("|| true", step["run"])+ with tempfile.TemporaryDirectory(prefix="release-guard-") as directory:+ work = Path(directory)+ tools = work / "tools"+ tools.mkdir()+ tar = tools / "tar"+ tar.write_text("#!/bin/sh\nprintf '%s\\n' \"$*\" > \"$PWD/tar.log\"\nexit 1\n")+ tar.chmod(tar.stat().st_mode | stat.S_IEXEC)+ env = {**os.environ, "PATH": str(tools) + os.pathsep + os.environ["PATH"],+ "APP_NAME": "pdfcraft", "VERSION": "1.2.3", "MATRIX_ARCH": "x86_64"}+ result = subprocess.run(["bash", "-c", step["run"]], cwd=work, env=env,+ text=True, capture_output=True, timeout=10)+ self.assertNotEqual(result.returncode, 0, result.stderr)+ self.assertIn("pdfcraft-server-1.2.3-linux-x86_64.tar.gz", (work / "tar.log").read_text())+++class ReviewedToolHashTests(unittest.TestCase):+ def test_installers_reject_unreviewed_versions(self):+ expectations = {+ ROOT / "actions/install-nfpm/action.yml": "no reviewed SHA256 for nfpm",+ ROOT / "actions/install-trunk/action.yml": "no reviewed SHA256 for trunk",+ ROOT / "actions/packaging-lint/action.yml": "no reviewed SHA256 for actionlint",+ }+ for path, message in expectations.items():+ text = path.read_text()+ self.assertIn(message, text)+ self.assertNotIn("checksums.txt", text)+ self.assertNotIn(".sha256", text)++ def test_extra_checks_script_stays_inside_the_repository(self):+ step = next(item for item in load(PACKAGING_LINT)["jobs"]["lint"]["steps"]+ if item.get("name") == "App-specific packaging checks")+ cases = {+ "/tmp/check.sh": 1,+ "../check.sh": 1,+ "nested/../../check.sh": 1,+ "missing.sh": 1,+ }+ with tempfile.TemporaryDirectory(prefix="packaging-lint-") as directory:+ work = Path(directory)+ script = work / "packaging/check.sh"+ script.parent.mkdir()+ script.write_text("#!/bin/sh\ntouch ran\n")+ for checks_script, expected in {**cases, "packaging/check.sh": 0}.items():+ with self.subTest(checks_script=checks_script):+ ran = work / "ran"+ ran.unlink(missing_ok=True)+ result = subprocess.run(["bash", "-c", step["run"]], cwd=work, text=True,+ capture_output=True, timeout=10,+ env={**os.environ, "CHECKS_SCRIPT": checks_script})+ self.assertEqual(result.returncode, expected, result.stdout + result.stderr)+ self.assertEqual(ran.exists(), expected == 0)+++if __name__ == "__main__":+ unittest.main()@@ -54,14 +54,18 @@ Path('args.json').write_text(json.dumps(sys.argv[1:])) if item.get('uses', '').startswith('vmactions/freebsd-vm@')) forwarded = vm['with']['envs'].split() configured = {key: value for key, value in configured.items() if key in forwarded}- env.update({key: value.replace(EXPRESSION, app_name)- for key, value in configured.items()})++ def github(value):+ # Expressions are resolved before the shell starts, including env values.+ return (value.replace(EXPRESSION, app_name)+ .replace('${{ matrix.arch }}', 'x86_64')+ .replace('${{ steps.cargo-cache.outcome }}', 'success'))++ env.update({key: github(value) for key, value in configured.items()}) env.update(VERSION='1.2.3', INPUT_VERSION='') # Model GitHub expression replacement in source as well as env so a # regression to interpolation executes the payload in this fixture.- script = step['run'].replace(EXPRESSION, app_name)- script = script.replace('${{ matrix.arch }}', 'x86_64')- script = script.replace('${{ steps.cargo-cache.outcome }}', 'success')+ script = github(step['run']) script = script.replace('/var/tmp/', str(root) + '/') shell = 'sh' if job_name == 'freebsd' else 'bash' result = subprocess.run([shell, '-eu', '-c', script], cwd=root, env=env,