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@main
diff --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: bash
diff --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: release
diff --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 fonts
diff --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 # v1
diff --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 limits
diff --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.py
new 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,
