From 502989c8d7b0986345b0c4570a5e2e0b4e2202e3 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 2 Sep 2026 08:33:43 +0200 Subject: [PATCH 1/2] docs: document how to pin this action, and what pinning doesn't cover This action receives production deploy credentials, so the ref a consumer pins decides who can run code with them. The README had no guidance on that and used the floating @v2 tag in every example, including a Quick start that targets `server: production`. Adds a Pinning section covering: - SHA / @v2.0.0 / @v2 trade-offs, described by actual mutability rather than by a promise about maintainer behaviour. Git tags can always be moved; saying otherwise is an unverifiable claim in a doc whose whole job is to help readers calibrate trust. - The limit of SHA pinning here. install-cli.sh downloads the dhq binary at run time and verifies it against a checksums.txt fetched from the same release, so pinning fixes the installer, not the CLI bytes. Documenting a control while omitting the hole it leaves converts an unknown risk into false confidence. - Secret scoping and job permissions, which bound the blast radius regardless of ref and are a stronger control than pinning alone. - A Dependabot config to keep SHA pins current, flagged to merge into an existing dependabot.yml rather than replace it, and to stay out of any auto-merge rule (auto-merged bumps reintroduce implicit upgrades). Also cross-references Pinning from the Quick start, so readers who copy the first snippet see the production caveat without scrolling. Reviewed by Review Council (Claude, Google Antigravity; Codex skipped on quota). The runtime-CLI gap and the Quick start contradiction came from that review. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012LYiKY8Hp3aUVRG2mzNGPd --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/README.md b/README.md index 897d8df..6272290 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ jobs: The action installs the pinned `dhq` CLI on the runner, calls `dhq deploy`, waits for the deployment to reach a terminal status, and fails the job if it didn't succeed. +> **Deploying to production?** The examples below use `@v2` for readability. `@v2` is a floating tag that moves with each v2 release — pin a commit SHA instead for any workflow holding production credentials. See [Pinning](#pinning). + ## Inputs | Name | Required | Default | Description | @@ -113,6 +115,65 @@ The action installs the pinned `dhq` CLI on the runner, calls `dhq deploy`, wait extra-args: "--copy-config --run-build" ``` +## Pinning + +This action receives production deploy credentials, so the ref you pin decides who can run code with them. + +| Ref | Mutable? | Use when | +|---|---|---| +| `@` | No — a commit SHA always names the same tree | **Recommended** for any workflow holding production secrets. | +| `@v2.0.0` | Yes — git tags can always be moved | You want a readable ref and accept that risk. | +| `@v2` | Yes, **by design** — tracks the latest v2.x.y | Non-production targets, or you accept implicit upgrades. | + +`@v2` moves whenever a new v2 release ships. That is the point of a floating major tag, but it also means anyone with write access to this repository — or anyone who compromises that access — can change the code your deploy credentials run, with no review on your side. For production, pin the SHA and record the version alongside it: + +```yaml +- uses: deployhq/deployhq-action@ffe9caa159b501c83cac4b70d2983078a316d15d # v2.0.0 + with: + api-key: ${{ secrets.DEPLOYHQ_API_KEY }} + account: ${{ secrets.DEPLOYHQ_ACCOUNT }} + email: ${{ secrets.DEPLOYHQ_EMAIL }} + project: my-project + server: production +``` + +That SHA is `v2.0.0`. Don't copy it blindly once later releases exist — resolve the one you want: + +```sh +gh api repos/deployhq/deployhq-action/commits/v2.0.0 --jq .sha +``` + +(Use the `commits` endpoint, not `git/ref/tags` — these are annotated tags, so `git/ref/tags` returns the tag object rather than the commit you need.) + +### What SHA pinning does not cover + +Pinning this action fixes the installer script and the default `cli-version`. It does **not** fix the bytes of the `dhq` binary: `scripts/install-cli.sh` downloads the CLI from `github.com/deployhq/deployhq-cli/releases` at run time and verifies it against a `checksums.txt` fetched from that same release. That catches a corrupted download, not someone able to replace the release assets. + +If you need an immutable chain end to end, vendor the CLI yourself or run this action on a runner with `dhq` already installed. + +### Bounding the blast radius + +Pinning controls *what code* runs. These control *what it can reach*, and compose with it: + +- Store `DEPLOYHQ_*` as **environment** secrets on a protected environment (`environment: production` with required reviewers) rather than repository secrets, so other workflows in the same repo can't read them. +- Set `permissions: contents: read` on the job. This action needs no `GITHUB_TOKEN` scope. + +### Keeping SHA pins current + +Dependabot bumps SHA pins for you, rewriting both the SHA and the trailing version comment so upgrades arrive as reviewable pull requests. Merge this into your existing `.github/dependabot.yml` rather than replacing the file — clobbering it would silently disable your other ecosystems: + +```yaml +version: 2 +updates: + # add alongside any existing entries + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly +``` + +This only helps if a human reads the PR — exclude this action from any Dependabot auto-merge rule, or you get `@v2`'s implicit upgrades with a SHA pin's false confidence. + ## Requirements - A DeployHQ API key (**Account Settings → API access**). From 738deb01227e231f6ded2251365f40477fddb0ea Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 2 Sep 2026 08:49:16 +0200 Subject: [PATCH 2/2] docs: correct two overstated claims in the pinning section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged both on #5; verified against the scripts before acting. The preinstalled-CLI advice was wrong. action.yml runs install-cli.sh unconditionally and the script never consults PATH — it downloads its own copy and appends the install dir to $GITHUB_PATH, which the runner prepends, shadowing any dhq already on the runner. Say that, and point at the real escape hatch (vendor the CLI and call dhq outside this action). Keep the one genuine exception the review missed: install-cli.sh:32 skips the download when a binary already sits at the exact cache path, so a self-hosted runner can pre-seed it. Note the version format, since VERSION has its leading `v` stripped at line 11 and the check is an exact match. Environment secrets don't stop other workflows reading them. Any job declaring `environment: production` can request them; release is gated on the environment's protection rules passing. Required reviewers and deployment branch policies are the actual control, so say to set them — an environment without rules buys the scoping and none of the protection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YZFLxNBpAH1FDzXgUPPXKZ --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6272290..1aaf219 100644 --- a/README.md +++ b/README.md @@ -149,13 +149,13 @@ gh api repos/deployhq/deployhq-action/commits/v2.0.0 --jq .sha Pinning this action fixes the installer script and the default `cli-version`. It does **not** fix the bytes of the `dhq` binary: `scripts/install-cli.sh` downloads the CLI from `github.com/deployhq/deployhq-cli/releases` at run time and verifies it against a `checksums.txt` fetched from that same release. That catches a corrupted download, not someone able to replace the release assets. -If you need an immutable chain end to end, vendor the CLI yourself or run this action on a runner with `dhq` already installed. +If you need an immutable chain end-to-end, vendor the CLI yourself and call `dhq` directly rather than through this action. Installing `dhq` on the runner's `PATH` does **not** work — `scripts/install-cli.sh` downloads its own copy regardless and prepends that directory to `$GITHUB_PATH`. The one exception is a self-hosted runner, where you can pre-seed `$RUNNER_TOOL_CACHE/dhq//_/dhq` (version without the leading `v`, e.g. `0.17.1/linux_amd64`); the installer reuses a binary already at that exact path instead of downloading. ### Bounding the blast radius Pinning controls *what code* runs. These control *what it can reach*, and compose with it: -- Store `DEPLOYHQ_*` as **environment** secrets on a protected environment (`environment: production` with required reviewers) rather than repository secrets, so other workflows in the same repo can't read them. +- Store `DEPLOYHQ_*` as **environment** secrets on a protected environment rather than repository secrets. Only a job that declares `environment: production` can request them, and they're released only once that environment's protection rules pass. The scoping alone doesn't stop another workflow from asking — the required reviewers and deployment branch policies you put on the environment are what actually gate it, so set them. - Set `permissions: contents: read` on the job. This action needs no `GITHUB_TOKEN` scope. ### Keeping SHA pins current