Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ commands/ /release, /release-prepare, /release-status
3. Create signed tag: `git tag -s vX.Y.Z -m "vX.Y.Z"`
4. Push: `git push origin main vX.Y.Z`

**NEVER use `gh release create` without `--verify-tag`.** Where a release workflow exists it handles GitHub release creation. Where none does, publish against the already-pushed signed tag: `gh release create vX.Y.Z --verify-tag --notes-file <notes>` — `--verify-tag` aborts rather than creating a lightweight tag.
**NEVER use `gh release create` without `--verify-tag`.** Where a release workflow exists it handles GitHub release creation. Where none does, the fix is to add one that calls the org reusable for the artefact (`release-source-archive.yml` for a source tree; SLSA Build Level 3, see `skills/github-release/references/ci-workflow-templates.md`). Until then, publish against the already-pushed signed tag: `gh release create vX.Y.Z --verify-tag --notes-file <notes>` — `--verify-tag` aborts rather than creating a lightweight tag.
4 changes: 3 additions & 1 deletion skills/github-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ allowed-tools: Bash(gh:*) Bash(git:*) Read Write Edit Glob Grep

Blocked by hooks. Immutable releases (GA Oct 2025) make tag names permanent — a bare `gh release create` creates the tag when it is missing, that tag is lightweight, and the name is burned forever. `--verify-tag` aborts unless the tag already exists on the remote, so the invocation can only publish a tag somebody pushed on purpose; the guard allows it for that reason (`--verify-tag=false` stays blocked).

Where the repository has a workflow that publishes the release, that workflow does it and you do not run `gh release create` at all. Where it does not — see *Tag-only repositories* below — publishing by hand against the pushed signed tag is the correct step, not a workaround.
Where the repository has a workflow that publishes the release, that workflow does it and you do not run `gh release create` at all. Where it does not, **add one** — for a project shipped as its source tree, a tag-push caller of `netresearch/.github`'s `release-source-archive.yml` (`references/ci-workflow-templates.md`). Publishing by hand against the pushed signed tag (*Tag-only repositories* below) is the step for a release that cannot wait for that workflow, not a flow to keep.

**`gh release edit` is allowed ONLY for `--notes` / `--notes-file`** to overhaul the release description after CI publishes. All other `gh release edit` flags are blocked.

Expand All @@ -45,6 +45,8 @@ checks the published body.

### Tag-only repositories

**This state is a gap to close, not a flow to keep.** A repository in it gets its provenance from a workflow that runs after a hand-made release, so the build happens in the repository itself — SLSA Build Level 2 at best, because GitHub grants Level 3 only when the build runs inside a reusable the repository cannot edit. The fix is a release workflow on the tag push that calls the org reusable for the artefact: `release-source-archive.yml` (source tree), `release-go-app.yml` (Go), `release-typo3-extension.yml` (TYPO3). It builds, generates SBOMs, signs, attests and creates the release in one run; see `references/ci-workflow-templates.md`. Until that workflow exists:

Some repositories have no workflow that creates the release object. Their supply-chain workflows listen on `release: published` instead — provenance attestation, SBOM upload, registry publish — and that event never fires for a release created with `GITHUB_TOKEN` (`references/typo3-ter-publishing.md` has the mechanism). The release therefore has to be created by a human credential, which is `gh` on your machine:

```bash
Expand Down
11 changes: 8 additions & 3 deletions skills/github-release/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -1110,14 +1110,15 @@
{
"id": 41,
"prompt": "v6.4.0 is tagged with git tag -s and pushed. This repository has no workflow that creates the GitHub release - slsa-provenance.yml and the SBOM upload both trigger on `release: published`. Publish the release.",
"expected_output": "Recognise that no workflow creates the release object, and that a release created with GITHUB_TOKEN would not fire release: published, so the release must be created with a human credential. Run gh release create v6.4.0 --verify-tag with the notes, then verify the downstream jobs ran - gh attestation verify on the published archive.",
"expected_output": "Recognise that no workflow creates the release object, and that a release created with GITHUB_TOKEN would not fire release: published, so the release must be created with a human credential. Run gh release create v6.4.0 --verify-tag with the notes, then verify the downstream jobs ran - gh attestation verify on the published archive. Then state that the lasting fix is a tag-push release workflow calling netresearch/.github's release-source-archive.yml, which builds the archive inside the reusable (SLSA Build Level 3) and creates the release itself, so no release needs creating by hand again.",
"expectations": [
"Identifies that no workflow publishes the release",
"Uses --verify-tag",
"Verifies the release: published consumers afterwards",
"Does NOT: run gh release create without --verify-tag",
"Does NOT: delete or move the tag",
"Does NOT: refuse on the grounds that gh release create is always forbidden"
"Does NOT: refuse on the grounds that gh release create is always forbidden",
"Names the lasting fix: a tag-push release workflow calling release-source-archive.yml"
],
"assertions": [
{
Expand All @@ -1135,10 +1136,14 @@
{
"type": "must_not",
"pattern": "(?i)git tag (-d|-f|--delete)"
},
{
"type": "content",
"pattern": "(?i)release-source-archive"
}
],
"samples": {
"passing": "Nothing in .github/workflows creates the release object, and a release created with GITHUB_TOKEN would not fire release: published, so the provenance and SBOM jobs would never start. Publish it yourself against the tag that is already pushed: gh release create v6.4.0 --title \"v6.4.0\" --verify-tag --notes-file notes.md. --verify-tag makes gh abort rather than create the tag. Then confirm the downstream run landed: gh attestation verify timetracker-v6.4.0-source.tar.gz --repo netresearch/timetracker.",
"passing": "Nothing in .github/workflows creates the release object, and a release created with GITHUB_TOKEN would not fire release: published, so the provenance and SBOM jobs would never start. Publish it yourself against the tag that is already pushed: gh release create v6.4.0 --title \"v6.4.0\" --verify-tag --notes-file notes.md. --verify-tag makes gh abort rather than create the tag. Then confirm the downstream run landed: gh attestation verify timetracker-v6.4.0-source.tar.gz --repo netresearch/timetracker. For the next release, add a tag-push release workflow that calls netresearch/.github's release-source-archive.yml: it builds the archive inside the reusable (SLSA Build Level 3) and creates the release itself, so none needs creating by hand.",
"failing": [
"Run gh release create v6.4.0 --title \"v6.4.0\" --notes-file notes.md and the release workflow will take it from there.",
"First clear the tag so the release can recreate it: git tag -d v6.4.0 and push the deletion, then publish with --verify-tag and check the provenance once release: published has fired."
Expand Down
57 changes: 30 additions & 27 deletions skills/github-release/references/ci-workflow-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,49 +18,52 @@ Netresearch maintains org-level reusable workflows:

## GitHub Release Only (No Package Registry)

A project installed straight from its repository (a herdr plugin, for example) publishes nothing to PyPI, npm, Packagist or TER; the release carries the tagged source tree as an archive plus a checksum file. The org `python-release.yml` covers this with `publish-pypi: false`. Pattern from [netresearch/herdr-bg-activity](https://github.com/netresearch/herdr-bg-activity/blob/main/.github/workflows/release.yml):
A project installed straight from its repository — an application, a plugin, anything shipped as its tagged source tree — publishes nothing to PyPI, npm, Packagist or TER. Its release carries the source archive. Use the org reusable `release-source-archive.yml`; it is the whole release workflow:

```yaml
name: Release

on:
push:
tags: ['v*.*.*']
tags: ["v*"]

permissions: {}

jobs:
release:
uses: netresearch/.github/.github/workflows/python-release.yml@main
uses: netresearch/.github/.github/workflows/release-source-archive.yml@main
Comment thread
coderabbitai[bot] marked this conversation as resolved.
permissions:
contents: write
id-token: write
with:
publish-pypi: false
package-manager: pip
# Fail before building when the tag and the manifest disagree.
check-cmd: >-
python -c 'import os, tomllib;
v = tomllib.load(open("herdr-plugin.toml", "rb"))["version"];
t = os.environ["GITHUB_REF_NAME"].removeprefix("v");
assert v == t, f"tag {t} != herdr-plugin.toml version {v}"'
build-cmd: >-
mkdir -p dist &&
git archive --format=tar.gz --prefix="my-plugin-${GITHUB_REF_NAME}/"
-o "dist/my-plugin-${GITHUB_REF_NAME}.tar.gz" HEAD &&
(cd dist && sha256sum -- *.tar.gz > SHA256SUMS.txt)
release-files: 'dist/*'

# python-release.yml uploads dist/ as the `dist` artifact but attests nothing.
attest:
contents: write # create the release
id-token: write # Sigstore OIDC identity
attestations: write # file the attestations with GitHub

# The release is created with GITHUB_TOKEN, so no `release` event starts
# anything afterwards; checks on the published release run as jobs here.
verify:
needs: release
uses: netresearch/.github/.github/workflows/attest-release-files.yml@main
uses: netresearch/.github/.github/workflows/verify-release.yml@main
permissions:
contents: read
attestations: read
id-token: write
attestations: write
with:
subject-path: 'dist/*' # same value as release-files
tag: ${{ github.ref_name }}
identity-regexp: '^https://github\.com/netresearch/\.github/\.github/workflows/release-source-archive\.yml@'
signer-workflow: netresearch/.github/.github/workflows/release-source-archive.yml
```

Provenance is a separate reusable, `attest-release-files.yml`, rather than an input on `python-release.yml`: it needs `attestations: write`, and a called workflow's job permissions are checked at startup, so adding that scope to `python-release.yml` would fail every caller that does not grant it. The reusable workflow is the signer, so verification has to name it; `--repo` alone checks the signer against the caller repository and fails:
On the tag push it checks out the tag (refusing a lightweight one), builds `<repo>-<tag>-source.tar.gz` with `git archive --format=tar | gzip -9 -n`, generates SPDX and CycloneDX SBOMs, attests build provenance and the SBOM, writes `checksums.txt`, signs every file with Cosign, and creates the release last: a draft, every file uploaded with per-asset retries, then published. Because the build runs inside a reusable the project cannot edit, the provenance is **SLSA Build Level 3** — GitHub: "Artifact attestations by itself provides SLSA v1.0 Build Level 2. Reusable workflows can provide isolation between the build process and the calling workflow, to meet SLSA v1.0 Build Level 3." The first caller is [netresearch/timetracker](https://github.com/netresearch/timetracker/blob/main/.github/workflows/release.yml).

Verification names the reusable as the signer; `--repo` alone checks the signer against the caller repository and fails:

```bash
gh attestation verify <repo>-<tag>-source.tar.gz --repo <owner>/<repo> \
--signer-workflow netresearch/.github/.github/workflows/release-source-archive.yml
```

### Level 2 alternative: your own build command

`python-release.yml` with `publish-pypi: false` runs a `build-cmd` the caller supplies, and `attest-release-files.yml` attests its output. That is the shape [netresearch/herdr-bg-activity](https://github.com/netresearch/herdr-bg-activity/blob/main/.github/workflows/release.yml) uses. The build command lives in the calling repository, so the provenance is **Level 2**, however isolated the signer is. Use it only when the release has to carry something `release-source-archive.yml` does not build; for a plain source archive, switch to the reusable above. `attest-release-files.yml` needs `attestations: write`, which is why it is a separate reusable: a called workflow's job permissions are checked at startup, so adding that scope to `python-release.yml` would fail every caller that does not grant it. Verification names it as the signer:

```bash
gh attestation verify <file> --repo <owner>/<repo> \
Expand Down
2 changes: 1 addition & 1 deletion skills/github-release/references/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The complete flow from "create a release" to "release published on GitHub."

So the hooks block `gh release create` only without `--verify-tag` (and `--verify-tag=false` counts as without), and `gh release delete` always. `gh release edit` is allowed only for `--notes`/`--notes-file` flags (release description overhaul). Reading `--help` is allowed for any subcommand.

**Which of the two flows applies is a property of the repository, not a preference.** If a workflow creates the release object, that workflow is the only thing that may create it. If none does — because the supply-chain workflows listen on `release: published`, which a `GITHUB_TOKEN`-created release never fires (see `typo3-ter-publishing.md`) — then a human credential must create it, and `gh release create … --verify-tag` is that step. Establish which case you are in by reading the workflows, not by trying the command:
**Which of the two flows applies is a property of the repository, not a preference.** If a workflow creates the release object, that workflow is the only thing that may create it. If none does — because the supply-chain workflows listen on `release: published`, which a `GITHUB_TOKEN`-created release never fires (see `typo3-ter-publishing.md`) — then a human credential must create it, and `gh release create … --verify-tag` is that step. That second case is a gap to close rather than a flow to keep: the build then runs in the repository, which is SLSA Build Level 2 at best. Add a tag-push release workflow that calls the org reusable for the artefact (`release-source-archive.yml`, `release-go-app.yml`, `release-typo3-extension.yml` — see `ci-workflow-templates.md`), and the first case applies from then on. Establish which case you are in by reading the workflows, not by trying the command:

```bash
grep -rln "on:" .github/workflows | xargs grep -ln "release:" ; grep -rn "gh release create\|softprops/action-gh-release\|create-release" .github/workflows
Expand Down
Loading