Skip to content

Commit 29d2f9c

Browse files
W2-1 · Release pipeline: JuliaRegistrator + TagBot + SBOM (#43)
## What this does Implements the wave-2 release pipeline (work order `.claude/tasks/prod-readiness/w2-1-release-pipeline.md`): release via **JuliaRegistrator + TagBot** to the Julia General registry, with a source tarball, an SPDX SBOM, and **real** SLSA provenance attached to each GitHub Release. The pipeline stays inert until a maintainer registers a version — no tag is created by this PR. ### Files - **`.github/workflows/TagBot.yml`** (new) — `JuliaRegistries/TagBot@e4c81aa…` (SHA-pinned `# v1.25.9`), triggered by `issue_comment` from `JuliaTagBot` or `workflow_dispatch`; default `GITHUB_TOKEN` flow (no SSH deploy key, since there is no Documenter site). Job-level `if` uses only `github.*` context (no `hashFiles()`/`secrets`), per the repo rule. - **`.github/workflows/release.yml`** (rewritten) — on `v*` tag push / `workflow_dispatch(tag)`: build a `git archive` source tarball, generate an SPDX SBOM with `anchore/sbom-action` (syft, SHA-pinned), compute **real** `base64-subjects` (`sha256sum … | base64 -w0`) and feed them to `slsa-framework/slsa-github-generator` (no more `base64-subjects: ""`, which used to error on every tag), and attach tarball + SBOM + provenance to the release. All template `TODO` residue removed. - **`docs/RELEASING.adoc`** (new) — end-to-end maintainer runbook: version bump → merge → `@JuliaRegistrator register` → registry AutoMerge → TagBot → attach-artifacts, with AutoMerge/first-registration caveats and a first-release checklist. ## Changes in this revision (addressing review must-fix) 1. **BLOCKER — changelog job could never run (so no release was ever produced).** The `Install git-cliff` step fetched `releases/latest/download/git-cliff-$(uname -m)-unknown-linux-gnu.tar.gz`, but git-cliff assets are **version-stamped** — the current latest asset is `git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz`, and the versionless path returns **HTTP 404** (verified below). Under the default `bash -eo pipefail` shell that failed the step → failed the `changelog` job → and because `release: needs: [build, changelog]` the release job was **skipped**, so nothing ever attached. Fixed by pinning `GIT_CLIFF_VERSION=2.13.1`, building the real versioned asset URL, and adding `git-cliff --version` to fail fast if the binary is bad (`release.yml` lines ~121-137). 2. **MISLEADING PIN — `softprops/action-gh-release`.** The SHA `718ea10b132b3b2eba29c1007bb80653f286566b` was commented `# v2`, but that SHA is actually tag **v3.0.1 / v3** (the real `v2` points at `3bb12739…`). Corrected the comment to `# v3.0.1` (`release.yml` line ~177). Confirmed v3.0.1 behaviour is intended, not an accident: at that SHA the runner is `node24` and every input the workflow uses (`tag_name`, `files`, `body`, `draft`, `prerelease`, `generate_release_notes`) exists. 3. **PR now opened** with the SLSA dry-run output and the first-release checklist below (the two acceptance criteria that require PR-body content). ## Verification **`actionlint` 1.7.7 — clean** on both workflows (WSL Debian): ``` $ actionlint .github/workflows/release.yml .github/workflows/TagBot.yml EXIT=0 ``` **git-cliff URL facts (verified via GitHub API + curl):** ``` $ gh api repos/orhun/git-cliff/releases/latest --jq .tag_name v2.13.1 # asset: git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz $ curl -sSL -o /dev/null -w "%{http_code}\n" \ https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-x86_64-unknown-linux-gnu.tar.gz 404 $ curl -sSfL https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz | tar -tz | grep '/git-cliff$' git-cliff-2.13.1/git-cliff # versioned URL downloads; --strip-components=1 lands the binary ``` **softprops SHA fact (verified via GitHub API):** tags at `718ea10b…` = `v3.0.1`, `v3`; real `v2` = `3bb12739…`; `action.yml@718ea10b…` has `using: "node24"`. **SLSA `base64-subjects` dry-run** — ran the exact `release.yml` snippet on two scratch files (`statistikles-0.1.0.tar.gz`, `statistikles-0.1.0.spdx.json`) and round-tripped it back: ``` $ sha256sum statistikles-0.1.0.tar.gz statistikles-0.1.0.spdx.json > checksums.txt $ cat checksums.txt 8f51e5a4968e4a01e5dd77b720ecf8e3a4d2bd5eb01e9a8357b0b6f787a674f3 *statistikles-0.1.0.tar.gz 033f748d28edb19db9e73d8b31ea18fe6f08d668ee43c202e1c20fe5ffc2e28f *statistikles-0.1.0.spdx.json $ HASHES=$(base64 -w0 checksums.txt) # this is the base64-subjects value handed to the generator $ echo "$HASHES" OGY1MWU1YTQ5NjhlNGEwMWU1ZGQ3N2I3MjBlY2Y4ZTNhNGQyYmQ1ZWIwMWU5YTgzNTdiMGI2Zjc4N2E2NzRmMyAqc3RhdGlzdGlrbGVzLTAuMS4wLnRhci5negowMzNmNzQ4ZDI4ZWRiMTlkYjllNzNkOGIzMWVhMThmZTZmMDhkNjY4ZWU0M2MyMDJlMWMyMGZlNWZmYzJlMjhmICpzdGF0aXN0aWtsZXMtMC4xLjAuc3BkeC5qc29uCg== $ base64 -d <<<"$HASHES" | sha256sum -c - statistikles-0.1.0.tar.gz: OK statistikles-0.1.0.spdx.json: OK ``` The decoded value is exactly the `sha256sum` manifest the generator expects, and it verifies OK against the files. ## First release checklist (Also lives in `docs/RELEASING.adoc` for the maintainer.) - [ ] `version` bumped in `Project.toml` (semver; `0.x` breaking-change rule — pre-1.0, breaking → minor, non-breaking → patch). - [ ] `[compat]` complete for every non-stdlib dependency **and** `julia`, each with an upper bound (`Statistics = "1"` was added in wave 1). - [ ] PR merged to `main` with **"E2E — Julia Test Suite"** green (Base ruleset blocks a red merge). - [ ] Registrator GitHub App installed on the repo (one-time). - [ ] `@JuliaRegistrator register` commented on the **merge commit on `main`**. - [ ] Registry PR AutoMerged — **watch for**: the first-ever registration sits in a **3-day new-package waiting period**; a missing `[compat]` upper bound is the usual AutoMerge decline (its PR comment names the exact guideline; fix in-repo and re-comment). - [ ] TagBot created the `vX.Y.Z` tag + GitHub Release. - [ ] **Release** workflow run (*Actions → Release → tag = `vX.Y.Z`*) so the tarball, SBOM, and SLSA provenance attach. This manual step is required by default because a `GITHUB_TOKEN`-created tag does not fire the downstream `push` event; the optional deploy-key upgrade in `docs/RELEASING.adoc` automates it. ## Skipped / not verified by execution - Not run: `slsa-github-generator`, `anchore/sbom-action`, TagBot, and JuliaRegistrator themselves — they only run in GitHub Actions on a real `v*` tag, and the task forbids creating tags. They are verified by careful reading + SHA-pinning; the SLSA subject computation (the one novel shell bit) is dry-run-verified above. - Out of scope per the work order: actually registering the package, creating any tag, and container image releases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 37bd237 commit 29d2f9c

3 files changed

Lines changed: 423 additions & 41 deletions

File tree

.github/workflows/TagBot.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# TagBot — cuts the GitHub release + git tag once a new version of Statistikles
5+
# is merged into the Julia General registry.
6+
#
7+
# Flow (see docs/RELEASING.adoc for the full maintainer walkthrough):
8+
# 1. Maintainer bumps `version` in Project.toml and merges to main.
9+
# 2. Maintainer comments `@JuliaRegistrator register` on the merge commit.
10+
# 3. The General registry PR AutoMerges.
11+
# 4. JuliaTagBot posts a trigger comment here -> this workflow runs -> TagBot
12+
# creates the `vX.Y.Z` tag and the GitHub Release.
13+
#
14+
# Token model: default GITHUB_TOKEN flow. This package ships no Documenter site,
15+
# so no SSH deploy key (DOCUMENTER_KEY) is required to push docs; the built-in
16+
# token can create the release tag. The `ssh` input is therefore omitted (its
17+
# value is a private-key string, not a boolean, so "off" == not set). One
18+
# consequence — a tag created via GITHUB_TOKEN does NOT re-trigger other
19+
# workflows — is documented, with its remedy, in docs/RELEASING.adoc.
20+
name: TagBot
21+
22+
on:
23+
issue_comment:
24+
types:
25+
- created
26+
workflow_dispatch:
27+
28+
permissions:
29+
contents: read
30+
31+
jobs:
32+
TagBot:
33+
# React only to JuliaTagBot's registry trigger comment or a manual dispatch,
34+
# never to arbitrary issue comments. This job-level conditional uses only the
35+
# github.* context (no hashFiles()/secrets), per the repo's step-level rule.
36+
if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot'
37+
runs-on: ubuntu-latest
38+
timeout-minutes: 10
39+
permissions:
40+
actions: read
41+
checks: read
42+
contents: write
43+
deployments: read
44+
discussions: read
45+
issues: read
46+
packages: read
47+
pull-requests: read
48+
statuses: read
49+
steps:
50+
- name: Cut GitHub release from the registered version
51+
uses: JuliaRegistries/TagBot@e4c81aa138ef4179bddea165bb4a9680c223aca4 # v1.25.9
52+
with:
53+
token: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/release.yml

Lines changed: 114 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,101 @@
11
# SPDX-License-Identifier: MPL-2.0
22
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
33
#
4-
# Release workflow — triggered by version tags (v*).
5-
# Builds artifacts, generates changelog via git-cliff, creates a GitHub Release,
6-
# and produces SLSA provenance attestations.
4+
# Release workflow — attaches distributable artifacts to a Statistikles release.
5+
#
6+
# Statistikles is released through the Julia General registry via
7+
# JuliaRegistrator + TagBot (see docs/RELEASING.adoc). TagBot creates the
8+
# `vX.Y.Z` git tag and the GitHub Release; this workflow then builds a
9+
# reproducible source tarball, an SPDX SBOM (syft), and SLSA provenance over
10+
# both, and attaches them to that release.
11+
#
12+
# Trigger model
13+
# -------------
14+
# TagBot cuts the tag with the default GITHUB_TOKEN, and GitHub deliberately
15+
# suppresses the downstream `push:` event (recursive-workflow prevention). So
16+
# the normal maintainer path is to run this workflow manually via
17+
# `workflow_dispatch`, passing the `vX.Y.Z` tag TagBot created. The `push: tags`
18+
# trigger is kept for the optional deploy-key setup described in
19+
# docs/RELEASING.adoc, under which TagBot's tag push fires this workflow
20+
# automatically. Either way the pipeline stays inert until a real version is
21+
# registered — nothing here runs on its own.
722
name: Release
823

924
on:
1025
push:
1126
tags:
1227
- 'v*'
28+
workflow_dispatch:
29+
inputs:
30+
tag:
31+
description: 'Existing git tag to build the release for (e.g. v0.1.0)'
32+
required: true
33+
type: string
1334

1435
permissions:
1536
contents: read
1637

1738
jobs:
1839
build:
19-
name: Build Artifacts
40+
name: Build Source Tarball + SBOM
2041
runs-on: ubuntu-latest
2142
timeout-minutes: 10
2243
permissions:
2344
contents: read
45+
outputs:
46+
version: ${{ steps.meta.outputs.version }}
47+
# Real SLSA subjects: sha256sum of the tarball + SBOM, base64-encoded.
48+
hashes: ${{ steps.hash.outputs.hashes }}
2449
steps:
2550
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
51+
with:
52+
# Empty on a tag push (checkout uses the pushed tag); the chosen tag on
53+
# a manual dispatch.
54+
ref: ${{ inputs.tag }}
55+
56+
- name: Derive version from tag
57+
id: meta
58+
run: |
59+
TAG="${{ inputs.tag || github.ref_name }}"
60+
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
61+
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
62+
63+
- name: Generate SPDX SBOM (syft)
64+
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
65+
with:
66+
path: .
67+
format: spdx-json
68+
output-file: statistikles-${{ steps.meta.outputs.version }}.spdx.json
69+
# This workflow uploads the SBOM itself (below) and attaches it to the
70+
# release, so the action's own artifact upload is unnecessary.
71+
upload-artifact: false
72+
73+
- name: Build source tarball
74+
run: |
75+
git archive --format=tar.gz \
76+
--prefix="statistikles-${{ steps.meta.outputs.version }}/" \
77+
-o "statistikles-${{ steps.meta.outputs.version }}.tar.gz" \
78+
HEAD
2679
27-
- name: Build
80+
- name: Compute SLSA base64-subjects
81+
id: hash
2882
run: |
29-
echo "Build your artifacts here"
30-
# TODO: Replace with your build commands
31-
# Examples:
32-
# cargo build --release
33-
# zig build -Doptimize=ReleaseFast
34-
# gleam build
35-
# mix release
36-
37-
# TODO: Upload build artifacts if needed
38-
# - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
39-
# with:
40-
# name: release-artifacts
41-
# path: target/release/
83+
# slsa-github-generator expects `sha256sum` output, base64 (-w0) encoded.
84+
sha256sum \
85+
"statistikles-${{ steps.meta.outputs.version }}.tar.gz" \
86+
"statistikles-${{ steps.meta.outputs.version }}.spdx.json" \
87+
> checksums.txt
88+
echo "hashes=$(base64 -w0 checksums.txt)" >> "$GITHUB_OUTPUT"
89+
90+
- name: Upload artifacts for the release job
91+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
92+
with:
93+
name: release-artifacts
94+
path: |
95+
statistikles-*.tar.gz
96+
statistikles-*.spdx.json
97+
if-no-files-found: error
98+
retention-days: 5
4299

43100
changelog:
44101
name: Generate Changelog
@@ -52,16 +109,32 @@ jobs:
52109
steps:
53110
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
54111
with:
112+
ref: ${{ inputs.tag }}
55113
fetch-depth: 0
56114

57115
- name: Extract version from tag
58116
id: version
59-
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
117+
run: |
118+
TAG="${{ inputs.tag || github.ref_name }}"
119+
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
60120
61121
- name: Install git-cliff
122+
env:
123+
# git-cliff release assets are version-stamped (e.g.
124+
# git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz), so the versionless
125+
# `releases/latest/download/...` path returns HTTP 404 and, under the
126+
# default `bash -eo pipefail` shell, fails this step -> fails the
127+
# changelog job -> skips `release` (needs: [build, changelog]). Pin an
128+
# explicit version and build the real, versioned asset URL. Bump this
129+
# value to adopt a newer git-cliff.
130+
GIT_CLIFF_VERSION: '2.13.1'
62131
run: |
63-
curl -sSfL https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-$(uname -m)-unknown-linux-gnu.tar.gz \
64-
| tar -xz --strip-components=1 -C /usr/local/bin/ git-cliff-*/git-cliff
132+
ARCH="$(uname -m)"
133+
curl -sSfL \
134+
"https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-${ARCH}-unknown-linux-gnu.tar.gz" \
135+
| tar -xz --strip-components=1 -C /usr/local/bin/ "git-cliff-${GIT_CLIFF_VERSION}/git-cliff"
136+
# Fail fast (and visibly) if the binary did not land or cannot run.
137+
git-cliff --version
65138
66139
- name: Generate changelog for this release
67140
id: cliff
@@ -87,46 +160,46 @@ jobs:
87160
retention-days: 5
88161

89162
release:
90-
name: Create GitHub Release
163+
name: Attach Artifacts to GitHub Release
91164
needs: [build, changelog]
92165
runs-on: ubuntu-latest
93166
timeout-minutes: 10
94167
permissions:
95168
contents: write
96169
steps:
97-
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
98-
99-
# TODO: Download build artifacts if uploading to the release
100-
# - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
101-
# with:
102-
# name: release-artifacts
103-
# path: artifacts/
170+
- name: Download build artifacts
171+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
172+
with:
173+
name: release-artifacts
174+
path: artifacts
104175

105-
- name: Create GitHub Release
106-
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2
176+
- name: Create or update GitHub Release
177+
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
107178
with:
179+
tag_name: ${{ inputs.tag || github.ref_name }}
108180
body: ${{ needs.changelog.outputs.changelog }}
109181
draft: false
110-
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
182+
prerelease: ${{ contains(inputs.tag || github.ref_name, '-rc') || contains(inputs.tag || github.ref_name, '-beta') || contains(inputs.tag || github.ref_name, '-alpha') }}
111183
generate_release_notes: false
112-
# TODO: Add artifact files to the release
113-
# files: |
114-
# artifacts/*
184+
files: |
185+
artifacts/*
115186
env:
116187
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117188

118189
provenance:
119190
name: SLSA Provenance
120-
needs: [build]
191+
needs: [build, release]
121192
permissions:
122193
actions: read
123194
id-token: write
124195
contents: write
125-
# SLSA generator must run in a separate, isolated workflow
196+
# SLSA generator must run in a separate, isolated (reusable) workflow.
126197
# See: https://slsa.dev/spec/v1.0/requirements#build-l3
198+
# Subjects are the real sha256sums computed by the build job (no longer "").
127199
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0
128200
with:
129-
base64-subjects: ""
130-
# TODO: Replace with actual artifact hashes
131-
# Generate with: sha256sum artifact | base64 -w0
132-
# base64-subjects: "${{ needs.build.outputs.hashes }}"
201+
base64-subjects: ${{ needs.build.outputs.hashes }}
202+
upload-assets: true
203+
# Empty on a tag push (generator uses the pushed tag); the chosen tag on a
204+
# manual dispatch, as the generic generator requires off-tag runs.
205+
upload-tag-name: ${{ inputs.tag }}

0 commit comments

Comments
 (0)