A green release can still ship an unsigned plugin

How a version bump on main tags, builds, attests and signs the Sensor Floor Map plugin. Also two gaps CI missed: an unsigned 0.2.1 zip and a signing break that showed only after merge.

Phan Minh Dang Khoa
5 min readAI draft · AI reviewed
Contents

dkhoa-dev/grafana-sensor-floor-map is the public Grafana panel plugin from the post on SVG floor plans. It shipped four releases between 26 Oct and 1 Nov 2026: 0.1.0, 0.2.0, 0.2.1 and 0.2.2. This post covers the workflow that builds them, and two gaps that let a green run produce the wrong archive.

A version bump creates the tag

Releases used to start from a v* tag pushed by hand. PR #26, merged on 26 Oct, moved the trigger to the version in package.json. The Tag release workflow runs on each push to main that changes that file:

# .github/workflows/tag-release.yml
tag="v$(jq -r .version package.json)"
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --silent 2>/dev/null; then
  echo "${tag} already exists, nothing to release."
  exit 0
fi
gh api "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/tags/${tag}" -f sha="${GITHUB_SHA}" --silent
gh workflow run release.yml --repo "${GITHUB_REPOSITORY}" --ref "${tag}"

The Release workflow listens for tag pushes, but this tag push never reaches it. GitHub starts no new workflow run for events caused by GITHUB_TOKEN. workflow_dispatch and repository_dispatch events are an exception. So Release also accepts workflow_dispatch, and the job dispatches it on the tag ref. Running on the tag keeps the tag/version check in Grafana’s build-plugin action passing.

  1. triggerVersion bumppackage.json on main
  2. tagTag releasecreates v<version>
  3. dispatchReleaseworkflow_dispatch on the tag
  4. buildbuild-pluginNode 24, sign, attest
  5. outputDraft releasezip and its SHA1
One merge that changes the version in package.json starts the whole release.

Attestation and signature are two files

Grafana’s review of 0.2.0 recommended a build provenance attestation. PR #29 set attestation: true on build-plugin and gave the job the id-token and attestations permissions. Anyone can check a downloaded archive against the repository:

gh attestation verify dkhoa-sensorfloormap-panel-0.2.2.zip --repo dkhoa-dev/grafana-sensor-floor-map

For the 0.2.2 zip this passes, and the signer workflow is release.yml at refs/tags/v0.2.2.

The attestation says where the zip was built. The Grafana signature is something else: a MANIFEST.txt inside the zip, added by sign-plugin with an access policy token. Until 1 Nov the policy_token input in release.yml was commented out.

Green release, unsigned zip

The v0.2.1 release ran without a signing token. The run went green and the zip it published had no MANIFEST.txt. Grafana had already reviewed that exact archive, so a rebuild would have given the review different bytes.

On 1 Nov a one-off workflow on branch sign/v0.2.1 signed the published zip instead. It downloads the archive, checks its SHA1 against the reviewed one and stops if a MANIFEST.txt is already there. Then it signs with the sign-plugin version locked at the tag, re-zips and attests the result. The 0.2.1 release assets were replaced the same day. Their attestation names sign-release.yml on that branch.

For the next releases, PR #36 passes the token to the build step:

# .github/workflows/release.yml (comments removed)
- uses: grafana/plugin-actions/build-plugin@build-plugin/v1.2.1
  with:
    attestation: true
    node-version: '24'
    policy_token: ${{ secrets.GRAFANA_ACCESS_POLICY_TOKEN }}

src/release.test.ts reads that file. It fails when the build step loses the token, when the deprecated grafana_token input returns or when attestation is off. It also fails when CHANGELOG.md has no heading for the version in package.json. The PR records six mutations, each run alone, and each fails exactly one test.

A bump that broke only on main

Dependabot’s PR #32 moved @grafana/sign-plugin from 3.3.3 to 4.0.0. Version 4 needs Node 24.5.0 or newer, and CI ran Node 22. The PR’s CI run still passed, because the signing step in ci.yml runs only when the token is set:

# .github/workflows/ci.yml
- name: Sign plugin
  run: npm run sign
  if: ${{ env.GRAFANA_ACCESS_POLICY_TOKEN != '' }}

Workflows that Dependabot pull requests trigger get no Actions secrets, so on PR #32 the step was skipped. After the merge on 1 Nov, the next CI run on main failed at that step:

Node.js >=24.5.0 is required. Current: v22.23.4

PR #35 moved every workflow to Node 24. build-plugin defaults to Node 20, so release.yml now passes node-version: '24'. The engines field became >=24.5.0.

The PR also adds src/nodeVersion.test.ts. It reads the engines.node range of the installed sign-plugin and checks every node-version in .github/workflows against it, plus .nvmrc and engines:

// src/nodeVersion.test.ts
const tooOld = versions.filter((v) => !semver.intersects(v.version, signRange));
expect(tooOld).toEqual([]);

It is a unit test and needs no secret. The next sign-plugin bump that raises its Node floor fails on its own pull request. In the PR’s mutation run, putting Node 22 back in any one of the five workflows, .nvmrc or engines failed one test.

0.2.2

PR #36 also set the version to 0.2.2. Before the merge, the PR’s CI build was signed with the same token, and @grafana/plugin-validator -strict with its signature and manifest analyzers reported no findings on it. When the PR merged on 1 Nov, Tag release created v0.2.2 and dispatched Release on it. The published dkhoa-sensorfloormap-panel-0.2.2.zip carries a PGP-signed MANIFEST.txt with signatureType: community, and its attestation verifies.

Lessons

  • A step behind if: <secret> != '' is untested on pull requests that get no secrets, Dependabot’s among them.
  • Check a tool’s Node floor in a unit test, which runs on every pull request.
  • A green release job does not prove the archive is signed. Test the workflow inputs that sign it.
  • A tag created with GITHUB_TOKEN starts no workflow. Dispatch the next one on the tag ref.
  • To sign an archive that was already reviewed, sign those bytes and check their hash first. Do not rebuild.
Phan Minh Dang Khoa
Principal Engineer · AI, IoT and industrial automation.
← Previous postA floor plan is untrusted input: cleaning SVG in a Grafana panel