Skip to content

ci: publish canary releases - #290

Open
NWRichmond wants to merge 11 commits into
mainfrom
283/npm-canary-for-prs
Open

ci: publish canary releases#290
NWRichmond wants to merge 11 commits into
mainfrom
283/npm-canary-for-prs

Conversation

@NWRichmond

@NWRichmond NWRichmond commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Motivation

This PR closes #283.

What does this PR do?

Adds opt-in npm canary publishes for the @grafana/prometheus package. When we add a npm-canary label to a PR, the build is published under the canary dist-tag. The workflow comments the install command on the PR.

How it works

  • npm-canary-build.yml is the unprivileged publish variant. It builds, derives a version from the PR's changesets, uploads a .tgz pinned to that run.
  • release-npm.yml is the trusted variant. It re-reads the PR, verifies the packed manifest, publishes the tarball after approval. It never runs PR code.

The tarball's contents are chosen by the PR, so publishing rests on: no PR code in the OIDC job, no forks, human approval, and a manifest check requiring @grafana/prometheus at <version>-canary.<pr>.<run-id>.<run-attempt> - values the trusted side resolved itself. That's what stops a canary passing as a release.

What happens when we add new commits?

When we push a new commit to a PR with the npm-canary label, or remove and re-add the npm-canary label, this publishes a new canary version.

Security

The tarball is packed by the unprivileged workflow, so its contents and version are chosen by the PR. Publishing relies on:

  • the OIDC-holding job never running PR code
  • the publishing steps not being able to run on forks
  • someone (with the requisite privileges) adding the npm-canary tag to the PR
  • verify-npm-canary-tarball.js requiring the manifest to name @grafana/prometheus with a version whose prerelease identifiers are exactly the PR number, run ID and run attempt that trusted code resolved for itself. That stops a canary being published as a plain release, resolving in place of one, or being attributed to another PR.

The canary version format is: <next-version>-canary.<pr>.<run-id>.<run-attempt>.

Provenance is off for canaries: npm attests the commit that ran npm publish (a main commit), but the tarball was built earlier, from the PR. The attestation would point at code the package doesn't contain.

Before merging

  • Confirm npm's trusted publisher covers release-npm.yml + npm-publish.
  • Create the npm-canary label (it's fine if this happens after merging)

Testing

This PR adds 39 new tests. Some examples: accepts a valid canary, rejects a plain release version, foreign PR number, wrong package name, extra file, filename/manifest mismatch.

@njvrzm njvrzm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial review but I'm not sure I'll be able to get back to it before your day starts, so here's my comments so far :)

Comment thread .github/workflows/npm-canary-build.yml Outdated
Comment thread .github/scripts/verify-npm-canary-tarball.sh Outdated
--head-sha "$HEAD_SHA" \
--pr-number "$PR_NUMBER" \
--run-id "$RUN_ID" \
--run-attempt "$RUN_ATTEMPT" > "$plan_file"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like if any of these variables is set but empty the script will run without complaint. The tests check that a missing value fails but neither the tests nor the code check for any empty value as far as I can tell. Probably not an issue but I'd be a bit happier with an explicit check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this, and empty values were already rejected at each entry point: the Node parsers treat '' as missing, the validators reject it, and the shell wrapper uses ${VAR:?}, which also rejects set-but-empty values. I added explicit empty-value tests to both suites to better communicate this.


on:
pull_request:
types: [opened, synchronize, reopened, labeled]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worth noting, although I'm not sure it's important for the purpose of this PR, that if main changes after this workflow runs, the published package will differ from the one that would be published after merge, if that makes sense. As long as these canary packages aren't meant to provide a definitive test of the changes that's probably fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. The canary checks out the PR head SHA rather than the merge ref, so it represents the branch as-is and can differ from the eventual post-merge release. That's intentional, but it wasn’t documented clearly. I added a note clarifying that canaries are for trying the branch, not verifying the eventual release.

Comment thread .github/workflows/release-npm.yml Outdated
Comment thread .github/workflows/release-npm.yml Outdated
# The name binds the artifact to this exact run, so the publish workflow
# can only ever download the tarball built by the run that triggered it.
name: npm-canary-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/npm-canary-artifact

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm no GHA expert, but it looks like this will upload the entire directory as an artifact, and similarly the download later will download the directory. Not sure if I'm missing something but I don't see that being handled in the verify script.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is tricky! The artifact contains the directory’s contents, not the directory itself, so the tarball lands directly in $ARTIFACT_DIR as expected. I added a comment to make that clear.

@NWRichmond

Copy link
Copy Markdown
Contributor Author

Thanks for the review @njvrzm! I'll comb through the feedback today.

@NWRichmond
NWRichmond requested review from njvrzm and a balanced review from Copilot August 13, 2026 04:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in canary publishing for @grafana/prometheus from labeled same-repository pull requests.

Changes:

  • Adds unprivileged canary build and trusted publish workflows.
  • Adds version planning and tarball verification scripts.
  • Adds documentation, tests, and Changesets parsing dependency.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
.github/workflows/npm-canary-build.yml Builds and uploads canary artifacts.
.github/workflows/release-npm.yml Authorizes and publishes canaries.
.github/scripts/verify-npm-canary-tarball.sh Validates artifact layout and filename.
scripts/plan-npm-canary.js Computes canary versions.
scripts/verify-npm-canary-tarball.js Validates package identity and version.
scripts/__tests__/plan-npm-canary.test.js Tests canary planning.
scripts/__tests__/verify-npm-canary-tarball.test.js Tests manifest verification.
scripts/README.md Documents canary publishing.
package.json Adds Changesets parser dependency.
package-lock.json Locks the dependency update.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

const headSha = requireCommitSha('--head-sha', options['head-sha']);

const plan = planCanary({
baseVersion: JSON.parse(exec(['show', `${baseSha}:${PACKAGE_JSON}`])).version,
# Serialise canaries per source pull request. Manual releases fall back to the
# run ID, which is unique, so they are never queued behind one another.
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || github.run_id }}
cancel-in-progress: false
Comment on lines +22 to +23
mapfile -d '' entries < <(find "$ARTIFACT_DIR" -mindepth 1 -maxdepth 1 -print0)
if [[ "${#entries[@]}" -ne 1 || "${entries[0]}" != *.tgz ]]; then
Comment on lines +42 to +43
if [[ "$tarball" != "$ARTIFACT_DIR/grafana-prometheus-${version}.tgz" ]]; then
echo "Error: canary tarball filename does not match the version in its manifest." >&2
# contain the published code, and record that permanently in Sigstore's
# public transparency log.
NPM_CONFIG_PROVENANCE: 'false'
run: npm publish "$TARBALL" --access public --tag canary --ignore-scripts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Canary version publishing for @grafana/prometheus

5 participants