collective

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

diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml--- a/.github/workflows/freebsd.yml+++ b/.github/workflows/freebsd.yml@@ -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@maindiff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml--- a/.github/workflows/nix.yml+++ b/.github/workflows/nix.yml@@ -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: bashdiff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml--- a/.github/workflows/packaging-lint.yml+++ b/.github/workflows/packaging-lint.yml@@ -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"diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml--- a/.github/workflows/release.yml+++ b/.github/workflows/release.yml@@ -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: releasediff --git a/.github/workflows/test-freebsd.yml b/.github/workflows/test-freebsd.yml--- a/.github/workflows/test-freebsd.yml+++ b/.github/workflows/test-freebsd.yml@@ -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 fontsdiff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml--- a/.github/workflows/windows-arm64.yml+++ b/.github/workflows/windows-arm64.yml@@ -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 # v1diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -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 limitsdiff --git a/actions/install-nfpm/action.yml b/actions/install-nfpm/action.yml--- a/actions/install-nfpm/action.yml+++ b/actions/install-nfpm/action.yml@@ -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 diff --git a/actions/install-trunk/action.yml b/actions/install-trunk/action.yml--- a/actions/install-trunk/action.yml+++ b/actions/install-trunk/action.yml@@ -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 -diff --git a/actions/packaging-lint/action.yml b/actions/packaging-lint/action.yml--- a/actions/packaging-lint/action.yml+++ b/actions/packaging-lint/action.yml@@ -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 -diff --git a/tests/test_freebsd.py b/tests/test_freebsd.py--- a/tests/test_freebsd.py+++ b/tests/test_freebsd.py@@ -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):diff --git a/tests/test_release_guards.py b/tests/test_release_guards.pynew file mode 100644--- /dev/null+++ b/tests/test_release_guards.py@@ -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()diff --git a/tests/test_release_inputs.py b/tests/test_release_inputs.py--- a/tests/test_release_inputs.py+++ b/tests/test_release_inputs.py@@ -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,