Skip to content

ci(release): gate the tag release on make ci before publishing - #413

Draft
bketelsen wants to merge 1 commit into
mainfrom
ci/release-gate-make-ci
Draft

ci(release): gate the tag release on make ci before publishing#413
bketelsen wants to merge 1 commit into
mainfrom
ci/release-gate-make-ci

Conversation

@bketelsen

Copy link
Copy Markdown
Contributor

Summary

.github/workflows/release.yml triggers only on a tag push, and a tag is not
required to point at a commit the Tests workflow ever saw green — it can name
an old commit, a branch head, or a main whose checks failed. Nothing stood
between such a tag and a published, attested, R2-mirrored release plus a
frostyard/snosi rebuild dispatch.

This adds a gate job (CI gate) that runs before goreleaser: checkout
(persist-credentials: false), actions/setup-go from go.mod, the same
mise.lock-verified golangci-lint the Tests workflow's Lint job installs via
jdx/mise-action, and then make ci — which already encapsulates
verify-static (tidy, vet, gofmt, pinned lint), unit tests with the coverage
floor, the e2e suite, the race detector, and the linux amd64/arm64
cross-builds. The goreleaser job now declares needs: gate, so GoReleaser,
actions/attest-build-provenance, the R2 publish, and the snosi dispatch are
all skipped when the gate fails. permissions: contents: read on the new job.

No new tooling is introduced, and the Tests workflow's triggers and jobs are
untouched — nothing that gates PRs or main today is weakened.

updex/release_workflow_contract_test.go gains
TestReleaseWorkflowGatesPublishOnCI, which pins the contract on pull
requests (the tag workflow itself never runs on one, so a contract test is the
only pre-merge gate available, as with the existing trigger and provenance
tests in that file): the publish job must needs: a job that exists, holds
only contents: read, checks the tree out, sets Go up from go.mod, and runs
make ci.

Addresses Snowcat work item 7bee7eb3-1871-4e5a-8c96-7bc11ec8b91e.

Checks

  • make fmt — code is formatted
  • make ci — tidy, vet, gofmt, lint (.golangci.yml), unit tests, the
    80.0% coverage floor (make test-coverage-check then make coverage-check), race tests, linux
    amd64/arm64 builds
  • CLI/e2e changes: go test -v ./cmd/updex ./tests/e2e/... green (via make ci)
  • New or changed behavior has focused tests, including failure paths

Risk classification

  • Tier 1: Low
  • Tier 2: Moderate
  • Tier 3: High
  • Tier 4: Critical

Rationale:

  • The change edits .github/workflows/release.yml, inside the
    workflow-and-permissions protected boundary in
    policies/agent-governance.json
    (.github/workflows/**, decision: review-required,
    minimum_risk_tier: high), and it changes release automation and the
    artifact-provenance path — Tier 3's "GitHub Actions permissions, release
    automation, or artifact provenance". Per docs/risk-tiers.md, touching a
    protected boundary makes it Tier 4: Critical.

Threat/abuse analysis (trust boundary crossed). The boundary is
tag-push → publish: the tag workflow holds contents: write,
id-token: write, and attestations: write, and its output is an artifact set
signed with GitHub's Sigstore instance and mirrored to
repository.frostyard.org. Anyone able to push a tag could previously
manufacture a provenance-attested release from a commit that fails lint,
vet, the tests, the coverage floor, or the race detector, because the
attestation proves origin, not quality — and downstream snosi image builds
consume it. This PR narrows that: the publish path is now reachable only
through a job that ran the same checks main and every PR run. The new job
holds contents: read only and uses persist-credentials: false, so it
neither widens nor inherits the publishing job's write scopes; the
goreleaser job's own permissions, steps, action pins, and secret handling
are unchanged. Failure mode added: a flaky gate run blocks a release until
re-run — availability, not integrity, and strictly the safer direction.

Rollback plan. Revert this commit (drop the gate job and the needs:
line, drop TestReleaseWorkflowGatesPublishOnCI) and the release path returns
byte-for-byte to its current behavior; no state, tag, or published artifact is
migrated, so nothing needs undoing beyond the revert. A maintainer who needs a
release out during an incident can also re-run only the goreleaser job, or
revert on a branch and tag from there.

Docs housekeeping

  • AGENTS.md (Release Automation) and docs/design/overview.md describe
    the gate job, why a tag needs it, and the new contract test
  • New docs started from their category's TEMPLATE.md and indexed in
    docs/README.md — n/a, no new docs
  • New significant decision recorded as an ADR first — n/a: this
    implements the existing gate/never-relax posture
    (policies/agent-governance.json never_relax), it does not decide
    anything new
  • Conformance aliases (ADR-0012) untouched — AGENTS.md edited as the
    canonical file

Verification

  • make ci — green (==> CI gate passed). One local-environment note:
    the first run failed at go test with
    error obtaining VCS status: exit status 128, a -buildvcs stamping
    artifact of running inside a linked git worktree in this sandbox, not a
    repository defect. Re-run as GOFLAGS=-buildvcs=false make ci and every
    stage passed: tidy, vet, gofmt, golangci-lint 2.13.1 (0 issues),
    unit tests, make test-coverage-check + make coverage-check (total
    86.2%, .coverage-baseline 86.2 — unchanged, so no baseline bump), e2e,
    race detector, and both cross-builds. CI's own actions/checkout is a
    normal clone and stamps VCS fine, as today's Tests jobs already show.
  • actionlint .github/workflows/release.yml — clean (v1.7.12).
  • node scripts/check-docs.mjs green — docs_index_coverage,
    link_integrity, symlink_resolution all 1.000.
  • TestReleaseWorkflowGatesPublishOnCI negative-tested: with the
    needs: gate line removed it fails with
    "goreleaser job declares no needs; a tag that fails CI would publish
    unchecked"
    ; it passes with the line restored. So the test is a real
    gate, not a tautology.
  • Checked against the
    PR review rubric

Acceptance criteria: what is proven, and what is not

The originating work item lists three acceptance criteria. Being explicit
about which are evidenced here:

  1. Proven. goreleaser declares needs: gate, and gate runs make ci
    before any GoReleaser or publish step. Verified by reading the job graph,
    by actionlint, and by TestReleaseWorkflowGatesPublishOnCI (including
    the negative test above).
  2. NOT verified — needs a maintainer. "A tag pushed against a
    deliberately broken commit causes gate to fail and goreleaser to be
    skipped." Confirming this end-to-end means pushing a throwaway tag, which
    on this repository means exercising the real publish path (contents: write, Sigstore attestation, the frostyard/updex release list, the R2
    bucket, and a frostyard/snosi dispatch). That is a maintainer's call, not
    something to do unilaterally, so no tag was pushed. What is shown
    statically: needs: in GitHub Actions skips the dependent job when the
    dependency fails, and every publishing step lives inside goreleaser.
  3. NOT verified — needs a maintainer. "A passing tag still produces a
    completed release exactly as before." The goreleaser job's steps, action
    pins, permissions, secrets, and ordering are byte-for-byte unchanged (only
    the needs: line was added), so no regression is expected — but a real tag
    is the only way to prove it, and none was pushed.

Suggested maintainer test when convenient: on a throwaway branch, break a test
deliberately, push a v0.0.0-gate-test tag, confirm gate fails and
goreleaser shows as skipped with nothing released or attested; then revert
the breakage, re-tag, and confirm the release completes with the usual
artifacts, attestation, and R2 publish.

A tag push is the only trigger that publishes, and a tag is not required
to point at a commit the Tests workflow ever saw green: it can name an old
commit, a branch head, or a `main` whose checks failed. Nothing stood
between such a tag and a published, attested, R2-mirrored release.

Add a `gate` job that checks the tree out, sets Go up from `go.mod`,
installs the same `mise.lock`-verified `golangci-lint` the Tests
workflow's Lint job installs, and runs `make ci` (verify-static, unit
tests with the coverage floor, e2e, race detector, linux amd64/arm64
cross-builds) under `permissions: contents: read`. The `goreleaser` job
now declares `needs: gate`, so GoReleaser, the provenance attestation,
the R2 publish, and the snosi dispatch are all skipped when the gate
fails. No new tooling is introduced, and the Tests workflow's triggers
and jobs are untouched.

`TestReleaseWorkflowGatesPublishOnCI` pins the contract on pull requests,
where the tag workflow itself never runs: the publish job must need a job
that exists, holds only `contents: read`, checks out, sets Go up from
`go.mod`, and runs `make ci`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code github_actions Pull requests that update GitHub Actions code labels Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant