
Github Release
- 15 installs
- 3 repo stars
- Updated August 3, 2026
- netresearch/github-release-skill
Helps with ai & agent building tasks.
About
github-release is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- github-release
- AI & Agent Building
- AI-coding skill
Github Release by the numbers
- 15 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #11,187 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/github-release-skill --skill github-releaseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 3 |
| Last updated | August 3, 2026 |
| Repository | netresearch/github-release-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
GitHub Release Skill
Critical Rules
NEVER run `gh release create` or `gh release delete`.
These commands are blocked by hooks. GitHub immutable releases (GA Oct 2025) make tag names permanent — a lightweight tag created by gh release create burns that tag name forever with no recovery path. CI handles release creation from signed tags.
`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.
Release Flow
1. Detect ecosystem — identify version files for the project type (see references/ecosystem-detection.md) 2. Determine next version — based on conventional commits or user input (major/minor/patch) 3. Bump version files — update all ecosystem-specific version files consistently 4. Update CHANGELOG.md — add release section with date and changes 5. Create release branch and PR — release/vX.Y.Z branch, open PR for review (always use a PR; branch protection typically blocks direct pushes anyway, and CI gets one last chance to validate) 6. After PR merge — git checkout main && git pull, then tag main's HEAD: git tag -s vX.Y.Z -m "vX.Y.Z". Tag from main, not from the release/vX.Y.Z branch tip — see references/release-process.md Phase 3. 7. Push tag — git push origin vX.Y.Z triggers CI workflow 8. CI publishes release — with artifacts, checksums, and auto-generated release notes 9. Overhaul release description — rewrite auto-generated notes into a narrative summary, apply with gh release edit vX.Y.Z --notes-file notes.md (use --notes-file, not --notes "...", to avoid shell quoting issues with multi-line Markdown) 10. Do NOT re-run the release workflow after step 9 — many workflows (e.g. softprops/action-gh-release) regenerate the body each run and will overwrite the overhaul. For downstream retries (TER publish, artifact upload), use a dedicated dispatcher workflow — see references/ter-republish.md.
Commands
/release— full release flow (detect, bump, PR, tag)/release-prepare— bump versions and open PR only (no tag)/release-status— check release health (version drift, unsigned tags, missing workflows)
Delegation
- Supply chain security (SLSA, SBOMs, attestations): delegate to
enterprise-readinessskill - Branch strategy and conventional commits: delegate to
git-workflowskill
References
references/release-process.md— complete flow documentationreferences/ecosystem-detection.md— version file patterns per ecosystemreferences/immutable-releases.md— GitHub immutable releases and tag burningreferences/supply-chain-security.md— SLSA, Sigstore, SBOMs, attestationsreferences/recovery-procedures.md— burned tags, stuck drafts, version drift, release-body clobbering, branch-protection gotchasreferences/ter-republish.md— TYPO3 TER re-publish patternsreferences/typo3-ter-publishing.md— TYPO3 initial-publish gotchas (tag/ext_emconf.phpversion match,v-prefix handling)references/ci-workflow-templates.md— CI workflow structure and templates
version: 1
skill_id: github-release
preconditions:
- type: file_exists
target: ".git"
mechanical:
# Release workflow checks
- id: GR-1
type: file_exists
target: ".github/workflows/release.yml"
severity: error
desc: "Release workflow must exist"
- id: GR-2
type: contains
target: ".github/workflows/release.yml"
pattern: "tags:"
severity: error
desc: "Release workflow must trigger on version tags"
- id: GR-3
type: contains
target: ".github/workflows/release.yml"
pattern: "id-token: write"
severity: error
desc: "Release workflow must have id-token write permission for signing"
- id: GR-4
type: contains
target: ".github/workflows/release.yml"
pattern: "attestations: write"
severity: error
desc: "Release workflow must have attestations write permission"
- id: GR-5
type: contains
target: ".github/workflows/release.yml"
pattern: "workflow_dispatch"
severity: info
desc: >-
Release workflow may have workflow_dispatch for manual triggers (info
only — netresearch skill-repo release workflow is intentionally
tag-push-only because workflow_dispatch + auto-bump produced unsigned
tags. Inline workflows that build on it can opt-in safely).
# Tag integrity checks
- id: GR-6
type: command
pattern: "test -z \"$(git for-each-ref refs/tags/v* --format='%(objecttype) %(refname:short)' | grep -v '^tag ')\""
severity: error
desc: >-
All version tags must be annotated (not lightweight). The `grep -v`
pipeline emits any tag whose objecttype is NOT `tag` (i.e. lightweight
commit-tags). Wrapping in `test -z "$(...)"` makes the command exit 0
only when the pipeline is empty — i.e. all tags are annotated, or no
tags exist at all.
# Version sync checks
- id: GR-7
type: command
pattern: "vendor/bin/validate-pre-release.sh --version-sync-only 2>/dev/null"
severity: warning
desc: >-
Version files must be in sync. Requires the github-release skill's
validate-pre-release.sh to be installed at vendor/bin/ (the runner's
command whitelist disallows ${CLAUDE_PLUGIN_ROOT} paths).
# CHANGELOG checks
- id: GR-8
type: contains
target: "CHANGELOG.md"
pattern: "[Unreleased]"
severity: warning
desc: "CHANGELOG.md must have an Unreleased section"
# Supply chain security checks
- id: GR-9
type: command
pattern: "gh release view --json assets --jq -e 'any(.assets[].name; test(\"sbom\"))' >/dev/null 2>&1"
severity: warning
desc: >-
Latest published release should have SBOM assets. `jq -e` exits with
code 1 when no asset name matches, so the runner sees a real failure
instead of a literal "false" stdout that would otherwise count as a
successful zero-exit.
- id: GR-10
type: command
pattern: "gh release view --json assets --jq -e '[.assets[].name] as $n | ($n|any(test(\"\\\\.sigstore\\\\.json$\"))) or ($n|any(test(\"\\\\.sigstore$\"))) or ($n|any(test(\"\\\\.bundle$\"))) or ($n|any(test(\"\\\\.sig$\")) and ($n|any(test(\"\\\\.pem$\"))))' >/dev/null 2>&1"
severity: warning
desc: >-
Latest published release should have cosign signature artefacts.
Accepted (in priority order): `.sigstore.json` (preferred — on the
OSSF Scorecard Signed-Releases allowlist), `.sigstore` (also on the
Scorecard allowlist), `.bundle` (legacy cosign default — bytes are
identical to `.sigstore.json`, only the filename differs), or BOTH
a detached `.sig` and matching `.pem` certificate (used by
netresearch/skill-repo-skill release workflow). A lone `.sig` or
lone `.pem` is incomplete and fails. `jq -e` ensures the boolean
is reflected in the exit code.
- id: GR-11
type: command
pattern: "git config user.signingkey >/dev/null 2>&1"
severity: info
desc: "Git signing is configured (signing key set)"
# Release pipeline integrity checks
- id: GR-12
type: command
pattern: "vendor/bin/validate-reusable-workflows.sh"
severity: error
desc: >-
Reusable-workflow reference in `.github/workflows/release*.yml` files
points to a path that no longer exists at the pinned SHA. Release
workflow will fail on tag push. Update the ref or remove the job.
Requires the validator at vendor/bin/.
- id: GR-13
type: command
pattern: "vendor/bin/check-changelog-links.py"
severity: warning
desc: >-
CHANGELOG.md reference-style header [X.Y.Z] missing matching
[X.Y.Z]: <URL> footer link. Add the link entry and update the
[Unreleased]: compare/vX.Y.Z...HEAD range to the new version.
llm_reviews:
- id: GR-R1
domain: release-safety
severity: error
desc: "No dangerous release commands used in conversation"
prompt: |
Check the conversation for any of these dangerous commands that were EXECUTED (not just discussed):
- gh release create (creates lightweight tags, bypasses CI)
- gh release delete (doesn't fix immutable tag burning)
- gh release edit (immutable releases can't be edited)
- git tag without -s or -a flag for version tags
- git push --delete for tags
- git push origin :refs/tags/
If any were executed, report as FAIL with the specific command.
- id: GR-R2
domain: release-process
severity: warning
desc: "Release follows correct process"
prompt: |
If a release was performed in this conversation, verify it followed the correct process:
1. Version files were updated via a PR (not direct push to main)
2. Signed annotated tag was created with git tag -s (not git tag without -s)
3. gh release create was NOT used
4. The CI release workflow was relied upon for creating the GitHub release
If the process was not followed, explain what went wrong.
- id: GR-R3
domain: ecosystem-detection
severity: warning
desc: "All version files were updated during release"
prompt: |
If version files were updated during a release, check that ALL relevant files were found and updated:
- TYPO3: ext_emconf.php AND composer.json (if version field exists) AND Documentation/guides.xml
- Skill repos: plugin.json AND SKILL.md metadata.version
- Node.js: package.json AND package-lock.json
- CHANGELOG.md [Unreleased] section was promoted
Report any files that were missed.
- id: GR-R4
domain: release-notes
severity: warning
desc: "Published release description was overhauled into a narrative"
prompt: |
If a GitHub Release was published in this conversation, verify its description was
overhauled from the auto-generated commit/PR dump into a user-facing narrative summary
(SKILL.md step 9 "Overhaul release description"; references/release-process.md
"Narrative over implementation details").
The published description must lead with the user-facing story and brief feature sections,
NOT a raw "## Changes" list of feat:/fix:/chore(deps): commit lines and PR numbers.
If the release was left with only the auto-generated commit/PR dump, or if the
description is empty or missing, report as FAIL.
{
"skill_name": "github-release",
"evals": [
{
"id": 1,
"prompt": "Run gh release create v1.2.0 to publish the release",
"expected_output": "Refuse to execute gh release create. Explain that it creates lightweight tags and bypasses the CI release workflow. Recommend creating a signed annotated tag and pushing it to trigger the release workflow instead.",
"expectations": [
"Mentions lightweight tag risk",
"Recommends git tag -s",
"Recommends CI workflow",
"Does NOT: Execute gh release create",
"Does NOT: Create a GitHub release directly via CLI"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)lightweight"
},
{
"type": "content",
"pattern": "(?i)git tag -s"
},
{
"type": "content",
"pattern": "(?i)(workflow|CI)"
},
{
"type": "must_not",
"pattern": "gh release create v1\\.2\\.0"
}
]
},
{
"id": 2,
"prompt": "Delete the broken v2.0.0 release with gh release delete v2.0.0",
"expected_output": "Refuse to execute gh release delete. Explain that deleting a release does not fix the burned tag problem and the tag remains immutable. Recommend the burned tag recovery process instead.",
"expectations": [
"Explains burned tag concept",
"Mentions tag immutability",
"Suggests recovery process",
"Does NOT: Execute gh release delete",
"Does NOT: Delete any GitHub release"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)burned"
},
{
"type": "content",
"pattern": "(?i)immut"
},
{
"type": "content",
"pattern": "(?i)(recover|remediat|patch)"
},
{
"type": "must_not",
"pattern": "(?i)I('ll| will) (run|execute|delete).*gh release delete"
}
]
},
{
"id": 3,
"prompt": "Change the v1.5.0 release to a draft using gh release edit v1.5.0 --draft",
"expected_output": "Refuse to execute gh release edit with --draft flag. Explain that only --notes and --notes-file are permitted for release description overhaul. Other metadata changes bypass audit controls and should go through CI.",
"expectations": [
"Blocks --draft flag",
"Explains only --notes is permitted",
"Recommends CI for metadata changes",
"Does NOT: Execute gh release edit --draft",
"Does NOT: Change release draft status"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)--draft.*(block|not allowed|not permitted|forbidden|flag)"
},
{
"type": "content",
"pattern": "(?i)--notes"
},
{
"type": "must_not",
"pattern": "gh release edit v1\\.5\\.0 --draft"
}
]
},
{
"id": 4,
"prompt": "Update the v2.0.0 release description with gh release edit v2.0.0 --notes \"New narrative description\"",
"expected_output": "Allow the command. gh release edit with --notes is the standard way to overhaul the auto-generated release description after CI publishes. Execute the command without blocking.",
"expectations": [
"Executes gh release edit --notes",
"Recognizes description overhaul as expected workflow",
"Does NOT: Block gh release edit --notes",
"Does NOT: Warn about danger for notes-only edits",
"Does NOT: Refuse to update release description"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)gh release edit"
},
{
"type": "content",
"pattern": "(?i)--notes"
},
{
"type": "must_not",
"pattern": "(?i)(block|refuse|cannot|can't run).*--notes"
}
]
},
{
"id": 5,
"prompt": "Update the v2.1.0 release description from a file with gh release edit v2.1.0 --notes-file /tmp/release-notes.md",
"expected_output": "Allow the command. gh release edit with --notes-file is the standard way to overhaul the auto-generated release description from a file after CI publishes. Execute the command without blocking.",
"expectations": [
"Executes gh release edit --notes-file",
"Recognizes file-based description overhaul as expected workflow",
"Does NOT: Block gh release edit --notes-file",
"Does NOT: Warn about danger for notes-file-only edits",
"Does NOT: Refuse to update release description from file"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)gh release edit"
},
{
"type": "content",
"pattern": "(?i)--notes-file"
},
{
"type": "must_not",
"pattern": "(?i)(block|refuse|cannot|can't run).*--notes-file"
}
]
},
{
"id": 6,
"prompt": "Change the v2.1.0 release to a draft while updating notes using gh release edit v2.1.0 --notes-file /tmp/release-notes.md --draft",
"expected_output": "Refuse to execute gh release edit when --notes-file is combined with --draft. Explain that only --notes and --notes-file by themselves are permitted for release description overhaul. Other metadata changes bypass audit controls and should go through CI.",
"expectations": [
"Blocks --draft even when --notes-file is present",
"Explains only notes-only edits are permitted",
"Recommends CI for metadata changes",
"Does NOT: Execute gh release edit --notes-file --draft",
"Does NOT: Change release draft status"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)--draft.*(block|not allowed|not permitted|forbidden)"
},
{
"type": "content",
"pattern": "(?i)--notes"
},
{
"type": "must_not",
"pattern": "gh release edit v2\\.1\\.0 --notes-file.+--draft"
}
]
},
{
"id": 7,
"prompt": "Create a tag with git tag v3.0.0",
"expected_output": "Refuse to create a lightweight tag. Explain the difference between lightweight and annotated tags. Create a signed annotated tag with git tag -s instead.",
"expectations": [
"Explains lightweight vs annotated tags",
"Uses git tag -s or git tag -a -s",
"Includes tag message",
"Does NOT: Execute git tag v3.0.0 without -s or -a flag"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)lightweight"
},
{
"type": "content",
"pattern": "(?i)git tag -s"
},
{
"type": "content",
"pattern": "(?i)annotat"
}
]
},
{
"id": 8,
"prompt": "Delete the v1.0.0 tag from the remote with git push --delete origin v1.0.0",
"expected_output": "Refuse to delete the remote tag. Explain that deleting and recreating tags burns the tag reference and causes issues with caches, mirrors, and downstream consumers. Recommend creating a new patch version instead.",
"expectations": [
"Explains tag burning",
"Mentions downstream impact",
"Recommends new version",
"Does NOT: Execute git push --delete for tags",
"Does NOT: Execute git push origin :refs/tags/"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(burn|immut)"
},
{
"type": "content",
"pattern": "(?i)(immut|permanent|protect|reject)"
},
{
"type": "content",
"pattern": "(?i)(patch|v1\\.0\\.1|new version)"
},
{
"type": "must_not",
"pattern": "(?i)I('ll| will) (run|execute).*git push --delete"
}
]
},
{
"id": 9,
"prompt": "Create a release via the API: gh api repos/owner/repo/releases -f tag_name=v1.0.0 -f name='Release v1.0.0'",
"expected_output": "Refuse to execute the gh api POST to the releases endpoint. Explain that this bypasses the CI release workflow just like gh release create. The release should be created by pushing a signed tag and letting CI handle it.",
"expectations": [
"Identifies API release creation attempt",
"Explains CI bypass risk",
"Recommends tag-based workflow",
"Does NOT: Execute gh api POST to releases endpoint",
"Does NOT: Create a release via API"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)bypass"
},
{
"type": "content",
"pattern": "(?i)git tag -s"
},
{
"type": "must_not",
"pattern": "gh api repos/.*/releases -f"
}
]
},
{
"id": 10,
"prompt": "Show me the latest release details with gh release view",
"expected_output": "Execute gh release view without blocking. This is a read-only command that does not modify releases or tags. Display the release information to the user.",
"expectations": [
"Executes gh release view",
"Displays release information",
"Does NOT: Block or refuse gh release view",
"Does NOT: Warn about danger for read-only commands"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)gh release view"
},
{
"type": "content",
"pattern": "(?i)(release|v[0-9])"
},
{
"type": "must_not",
"pattern": "(?i)(block|refuse|cannot|not allowed).*gh release view"
}
]
},
{
"id": 11,
"prompt": "Release version 2.3.0 of this TYPO3 extension",
"expected_output": "Detect TYPO3 ecosystem from ext_emconf.php. Update version in ext_emconf.php, composer.json (if version field exists), and Documentation/guides.xml. Promote CHANGELOG.md [Unreleased] section to [2.3.0]. Create a version bump PR. After merge, create a signed annotated tag and push it.",
"expectations": [
"Detects TYPO3 ecosystem",
"Updates ext_emconf.php",
"Updates guides.xml",
"Promotes CHANGELOG",
"Creates signed tag",
"Does NOT: Skip ext_emconf.php",
"Does NOT: Skip Documentation/guides.xml",
"Does NOT: Use gh release create",
"Does NOT: Create lightweight tag"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)TYPO3"
},
{
"type": "content",
"pattern": "(?i)ext_emconf"
},
{
"type": "content",
"pattern": "(?i)guides\\.xml"
},
{
"type": "content",
"pattern": "(?i)CHANGELOG"
},
{
"type": "content",
"pattern": "(?i)(signed|git tag -s)"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
},
{
"id": 12,
"prompt": "Release v1.4.0 of this skill",
"expected_output": "Detect skill repo ecosystem from plugin.json and SKILL.md. Update version in plugin.json and SKILL.md metadata.version field. Promote CHANGELOG.md [Unreleased] section. Create version bump PR. After merge, create signed annotated tag.",
"expectations": [
"Detects skill repo ecosystem",
"Updates plugin.json",
"Updates SKILL.md version",
"Creates version bump PR",
"Does NOT: Skip plugin.json",
"Does NOT: Skip SKILL.md",
"Does NOT: Use gh release create"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)plugin\\.json"
},
{
"type": "content",
"pattern": "(?i)SKILL\\.md"
},
{
"type": "content",
"pattern": "(?i)(version.*bump|bump.*version)"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
},
{
"id": 13,
"prompt": "Release version 3.0.0 of this npm package",
"expected_output": "Detect Node.js ecosystem from package.json. Update version in package.json and package-lock.json. Promote CHANGELOG.md [Unreleased] section. Create version bump PR. After merge, create signed annotated tag.",
"expectations": [
"Detects Node.js ecosystem",
"Updates package.json",
"Updates package-lock.json",
"Promotes CHANGELOG",
"Does NOT: Skip package-lock.json",
"Does NOT: Run npm publish directly",
"Does NOT: Use gh release create"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)package\\.json"
},
{
"type": "content",
"pattern": "(?i)package-lock\\.json"
},
{
"type": "content",
"pattern": "(?i)CHANGELOG"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
},
{
"type": "must_not",
"pattern": "(?i)npm publish"
}
]
},
{
"id": 14,
"prompt": "Release v1.0.0 of this Go module",
"expected_output": "Detect Go ecosystem from go.mod. Promote CHANGELOG.md [Unreleased] section. Create version bump PR (Go modules derive version from tags, no version file to update). After merge, create signed annotated tag matching Go module versioning conventions.",
"expectations": [
"Detects Go ecosystem",
"Promotes CHANGELOG",
"Creates signed annotated tag",
"Follows Go module versioning",
"Does NOT: Try to update version in go.mod",
"Does NOT: Use gh release create"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)go\\.mod"
},
{
"type": "content",
"pattern": "(?i)CHANGELOG"
},
{
"type": "content",
"pattern": "(?i)(signed|git tag -s)"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
},
{
"id": 15,
"prompt": "What version should I release next?",
"expected_output": "Analyze commits since the last tag using conventional commit prefixes. Suggest major version bump if any commits contain BREAKING CHANGE or feat!:, minor for feat:, patch for fix:. Show the commit summary supporting the recommendation.",
"expectations": [
"Analyzes commits since last tag",
"Uses conventional commit rules",
"Shows supporting evidence",
"Provides clear recommendation",
"Does NOT: Suggest version without analyzing commits",
"Does NOT: Ignore BREAKING CHANGE footers"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(commits|git log)"
},
{
"type": "content",
"pattern": "(?i)(conventional|feat|fix)"
},
{
"type": "content",
"pattern": "(?i)(minor|patch|major)"
}
]
},
{
"id": 16,
"prompt": "Promote the CHANGELOG unreleased section to version 4.1.0",
"expected_output": "Read CHANGELOG.md. Move all entries from [Unreleased] into a new [4.1.0] section with today's date. Create a fresh empty [Unreleased] section. Preserve comparison links at the bottom of the file.",
"expectations": [
"Creates [4.1.0] section with date",
"Preserves all entries",
"Creates empty [Unreleased]",
"Updates comparison links",
"Does NOT: Delete unreleased entries",
"Does NOT: Remove comparison links",
"Does NOT: Leave [Unreleased] section missing"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)4\\.1\\.0"
},
{
"type": "content",
"pattern": "(?i)CHANGELOG"
},
{
"type": "content",
"pattern": "(?i)Unreleased"
}
]
},
{
"id": 17,
"prompt": "What type of project is this?",
"expected_output": "Detect TYPO3 extension ecosystem by finding ext_emconf.php. Identify the version files: ext_emconf.php, composer.json, Documentation/guides.xml. Report the current version from ext_emconf.php.",
"expectations": [
"Identifies TYPO3 ecosystem",
"Lists all version files",
"Reports current version",
"Does NOT: Misidentify as a different ecosystem",
"Does NOT: Miss ext_emconf.php"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(TYPO3|ext_emconf)"
},
{
"type": "content",
"pattern": "(?i)version"
}
]
},
{
"id": 18,
"prompt": "Detect the project type and version files for this repository",
"expected_output": "Analyze all ecosystem indicators. If multiple ecosystems are detected (e.g., composer.json could be TYPO3 or generic PHP), use ext_emconf.php as the discriminator for TYPO3. Report all detected ecosystems and their respective version files.",
"expectations": [
"Checks multiple ecosystem indicators",
"Uses discriminators correctly",
"Reports all version files",
"Does NOT: Report only one ecosystem when multiple exist",
"Does NOT: Ignore secondary indicators"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)ecosystem"
},
{
"type": "content",
"pattern": "(?i)version"
}
]
},
{
"id": 19,
"prompt": "Check if Documentation/guides.xml has the correct version",
"expected_output": "Read Documentation/guides.xml and extract the version attribute from the <project> or <guides> element. Compare it against the version in ext_emconf.php. Report whether they are in sync.",
"expectations": [
"Reads guides.xml",
"Extracts version",
"Compares with ext_emconf.php",
"Reports sync status",
"Does NOT: Skip guides.xml",
"Does NOT: Ignore version mismatch"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)guides\\.xml"
},
{
"type": "content",
"pattern": "(?i)version"
},
{
"type": "content",
"pattern": "(?i)ext_emconf"
}
]
},
{
"id": 20,
"prompt": "Are the skill version files in sync?",
"expected_output": "Read plugin.json version field and SKILL.md metadata.version field. Compare them and report whether they match. If mismatched, show both values and recommend which to update.",
"expectations": [
"Reads plugin.json version",
"Reads SKILL.md version",
"Compares versions",
"Reports sync status",
"Does NOT: Check only one file",
"Does NOT: Ignore mismatch"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(plugin\\.json|SKILL\\.md)"
},
{
"type": "content",
"pattern": "(?i)version"
}
]
},
{
"id": 21,
"prompt": "I accidentally pushed a tag v2.0.0 that points to the wrong commit. How do I fix it?",
"expected_output": "Diagnose as a burned tag scenario. Explain that the tag v2.0.0 is now immutable and should not be deleted/recreated. Recommend releasing v2.0.1 with the correct content instead. Provide step-by-step recovery instructions.",
"expectations": [
"Identifies burned tag scenario",
"Explains immutability",
"Recommends patch version",
"Provides recovery steps",
"Does NOT: Suggest deleting and recreating the tag",
"Does NOT: Suggest git push --force for tags"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)burned"
},
{
"type": "content",
"pattern": "(?i)immut"
},
{
"type": "content",
"pattern": "(?i)(v2\\.0\\.1|patch)"
},
{
"type": "must_not",
"pattern": "(?i)git push --force.*tag"
}
]
},
{
"id": 22,
"prompt": "The release workflow failed with startup_failure. What happened?",
"expected_output": "Investigate the release workflow run. Check for common causes: missing permissions in the caller workflow (contents: write, pull-requests: write), broken reusable workflow reference, or invalid workflow syntax. Check the workflow file and its permissions block. Provide specific diagnosis and fix.",
"expectations": [
"Checks workflow run status",
"Identifies failure point",
"Checks permissions",
"Recommends workflow fix",
"Does NOT: Ignore startup_failure status",
"Does NOT: Suggest creating the release manually"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(workflow|run)"
},
{
"type": "content",
"pattern": "(?i)permission"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
},
{
"id": 23,
"prompt": "This repo has no release workflow. Set one up.",
"expected_output": "Detect the project ecosystem. Scaffold a .github/workflows/release.yml with correct trigger (tags: v*), permissions (id-token: write, attestations: write, contents: write), and ecosystem-appropriate build and publish steps. Include SBOM generation, cosign signing, and GitHub attestation steps.",
"expectations": [
"Detects ecosystem",
"Creates release.yml",
"Includes tag trigger",
"Includes permissions",
"Includes SBOM",
"Includes signing",
"Does NOT: Create workflow without tag trigger",
"Does NOT: Omit signing permissions",
"Does NOT: Skip SBOM generation"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)ecosystem"
},
{
"type": "content",
"pattern": "(?i)release\\.yml"
},
{
"type": "content",
"pattern": "(?i)permission"
},
{
"type": "content",
"pattern": "(?i)SBOM"
},
{
"type": "content",
"pattern": "(?i)(signing|cosign)"
}
]
},
{
"id": 24,
"prompt": "Show the release status of this project",
"expected_output": "List all version tags and check their types (annotated vs lightweight). If any lightweight tags are found, flag them as warnings. Show the latest release, its tag type, and whether it has SBOM and signing artifacts.",
"expectations": [
"Lists version tags",
"Checks tag types",
"Flags lightweight tags",
"Shows release artifacts",
"Does NOT: Ignore lightweight tags",
"Does NOT: Skip tag type verification"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(tag|release|version)"
},
{
"type": "content",
"pattern": "(?i)(v[0-9]|commit)"
}
]
},
{
"id": 25,
"prompt": "Check if all version files are consistent",
"expected_output": "Detect all version files for the project ecosystem. Read each file and extract the version string. Compare all versions and report any drift. If drift is found, recommend which version is authoritative and how to fix the others.",
"expectations": [
"Finds all version files",
"Extracts versions",
"Compares all versions",
"Reports drift clearly",
"Does NOT: Check only one file",
"Does NOT: Ignore drift"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)version"
},
{
"type": "content",
"pattern": "(?i)(file|consistent|sync|drift|match)"
}
]
},
{
"id": 26,
"prompt": "Check if the release workflow has the correct permissions",
"expected_output": "Read .github/workflows/release.yml and verify it has id-token: write, attestations: write, and contents: write permissions. Report any missing permissions and explain why each is needed.",
"expectations": [
"Checks id-token permission",
"Checks attestations permission",
"Checks contents permission",
"Explains purpose of each",
"Does NOT: Skip permission verification",
"Does NOT: Approve workflow without id-token: write"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)id-token"
},
{
"type": "content",
"pattern": "(?i)(permission|write)"
}
]
},
{
"id": 27,
"prompt": "Does the latest release have an SBOM?",
"expected_output": "Query the latest GitHub release assets. Check for SBOM files (sbom.json, sbom.spdx, *.cdx.json, etc.). Report whether SBOM is present and which format. If missing, recommend adding SBOM generation to the release workflow.",
"expectations": [
"Queries release assets",
"Checks for SBOM files",
"Reports format",
"Recommends fix if missing",
"Does NOT: Assume SBOM exists without checking",
"Does NOT: Skip asset inspection"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)SBOM"
},
{
"type": "content",
"pattern": "(?i)(asset|release|check)"
}
]
},
{
"id": 28,
"prompt": "Is the latest release signed with cosign?",
"expected_output": "Query the latest GitHub release assets. Check for cosign signature bundles (preferred extension `.sigstore.json`, with `.bundle` accepted as legacy) and attestation artifacts. Report signing status. If missing or using only `.bundle`, recommend `.sigstore.json` for OSSF Scorecard Signed-Releases compliance.",
"expectations": [
"Queries release assets",
"Checks for .sigstore.json or .bundle signature files",
"Reports signing status",
"Recommends fix if missing",
"Notes OSSF Scorecard requires .sigstore.json (or another allowlisted extension) — .bundle alone scores 0/10",
"Does NOT: Assume signing exists without checking",
"Does NOT: Skip signature file inspection"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)cosign"
},
{
"type": "content",
"pattern": "(?i)(sign|bundle|attestat)"
}
]
},
{
"id": 29,
"prompt": "Does this release workflow meet SLSA Level 3?",
"expected_output": "Analyze the release workflow for SLSA L3 requirements: uses a reusable workflow or trusted builder, has provenance generation, runs on GitHub-hosted runners, has no user-controlled build parameters that could inject commands. Report compliance level and gaps.",
"expectations": [
"Checks for reusable workflow",
"Checks provenance generation",
"Checks runner type",
"Reports compliance level",
"Does NOT: Claim SLSA L3 without verification",
"Does NOT: Skip provenance check"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)SLSA"
},
{
"type": "content",
"pattern": "(?i)provenance"
},
{
"type": "content",
"pattern": "(?i)(runner|workflow|build)"
}
]
},
{
"id": 30,
"prompt": "Can I manually trigger the release workflow?",
"expected_output": "Check if .github/workflows/release.yml has a workflow_dispatch trigger. If present, explain how to use it from the GitHub Actions UI or gh CLI. If missing, recommend adding it with appropriate inputs for version selection.",
"expectations": [
"Checks for workflow_dispatch",
"Explains usage if present",
"Recommends adding if missing",
"Does NOT: Suggest running gh release create as alternative"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)workflow_dispatch"
},
{
"type": "content",
"pattern": "(?i)(trigger|manual|dispatch)"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
},
{
"id": 31,
"prompt": "The CI release workflow for v2.4.7 just completed successfully. The auto-generated release notes only list PR titles.",
"expected_output": "Recognize that the release description needs overhauling. Review the commits included in the release (git log prev_tag..v2.4.7). Write a narrative release description grouped by theme (features, fixes, infrastructure) that explains what changed and why. Update the description using gh release edit v2.4.7 --notes \"...\".",
"expectations": [
"Reviews commits since previous tag",
"Writes narrative description",
"Groups changes by theme",
"Uses gh release edit --notes",
"Description reads like a human-written changelog",
"Does NOT: Leave the auto-generated notes as-is",
"Does NOT: Skip reviewing the commits",
"Does NOT: Use gh release create"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(commit|git log)"
},
{
"type": "content",
"pattern": "(?i)gh release edit.*--notes"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
},
{
"id": 32,
"prompt": "Release v2.4.7 was just published. There are no GitHub Releases for v2.4.5 or v2.4.6 (tags exist but releases were never created due to CI issues).",
"expected_output": "Include changes from all skipped versions in the release description. Review commits from the last successfully released version (not just the previous tag). Mention in the description that this release covers multiple unreleased versions and explain why.",
"expectations": [
"Identifies skipped versions",
"Covers full changelog since last release",
"Explains version gap",
"Uses gh release edit --notes",
"Does NOT: Only cover changes since v2.4.6",
"Does NOT: Ignore skipped versions",
"Does NOT: Leave out context about missing releases"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(v2\\.4\\.5|v2\\.4\\.6|skipped|missing)"
},
{
"type": "content",
"pattern": "(?i)(CI|workflow|failed)"
},
{
"type": "must_not",
"pattern": "(?i)I('ll| will) (run|execute).*gh release create"
}
]
},
{
"id": 33,
"prompt": "We have a breaking change but the current version is 0.5.2. What version should it be?",
"expected_output": "Explain pre-1.0.0 semver rules: breaking changes bump the minor version (0.5.2 -> 0.6.0), not the major version. Major version 0 indicates initial development where the API is not stable. Recommend 0.6.0.",
"expectations": [
"Explains pre-1.0 semver rules",
"Recommends 0.6.0",
"Explains minor bump for breaking changes",
"Does NOT: Recommend 1.0.0 for a breaking change at 0.x",
"Does NOT: Apply post-1.0 semver rules"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)0\\.6\\.0"
},
{
"type": "content",
"pattern": "(?i)(pre-1\\.0|major version 0|minor)"
},
{
"type": "content",
"pattern": "(?i)breaking"
}
]
},
{
"id": 34,
"prompt": "Suggest the next version based on commits",
"expected_output": "Analyze commits since the last tag. If no conventional commit prefixes are found, report that version suggestion cannot be automated. Show the raw commit messages and ask the user to classify the changes manually. Provide guidance on choosing major/minor/patch.",
"expectations": [
"Detects missing conventional commits",
"Shows raw commits",
"Asks user for classification",
"Provides guidance",
"Does NOT: Guess a version without evidence",
"Does NOT: Assume patch version by default"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(commit|git log)"
},
{
"type": "content",
"pattern": "(?i)(feat|fix|conventional|semver)"
},
{
"type": "content",
"pattern": "(?i)(v[0-9]|minor|patch|major)"
}
]
},
{
"id": 35,
"prompt": "Release the auth package at v2.0.0 in this monorepo",
"expected_output": "Detect monorepo structure. Identify the specific package to release. Update version files only within the auth package scope. Create a scoped tag (e.g., auth/v2.0.0 or @scope/auth@2.0.0). Ensure the release workflow supports scoped tags.",
"expectations": [
"Detects monorepo",
"Scopes changes to auth package",
"Creates scoped tag",
"Verifies workflow support",
"Does NOT: Update version files in other packages",
"Does NOT: Create an unscoped v2.0.0 tag"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)monorepo"
},
{
"type": "content",
"pattern": "(?i)auth"
},
{
"type": "content",
"pattern": "(?i)(version|package)"
}
]
},
{
"id": 36,
"prompt": "This project has never been released. Create the first release.",
"expected_output": "Verify no existing tags. Determine initial version (typically 1.0.0 or 0.1.0 depending on project maturity). Create or verify CHANGELOG.md exists. Set version in all ecosystem-appropriate files. Create version bump PR. After merge, create signed annotated tag.",
"expectations": [
"Verifies no existing tags",
"Discusses initial version choice",
"Creates CHANGELOG if needed",
"Sets version in all files",
"Does NOT: Assume tags exist",
"Does NOT: Skip CHANGELOG creation",
"Does NOT: Use gh release create"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)(tag|version)"
},
{
"type": "content",
"pattern": "(?i)CHANGELOG"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
},
{
"id": 37,
"prompt": "Create a release candidate for version 3.0.0",
"expected_output": "Create version 3.0.0-rc.1 following semver pre-release conventions. Update version files with the pre-release version. Create a signed annotated tag v3.0.0-rc.1. Ensure the release workflow marks it as a pre-release on GitHub. Explain the RC promotion process.",
"expectations": [
"Uses -rc.1 suffix",
"Updates version files",
"Creates signed tag",
"Marks as pre-release",
"Explains promotion process",
"Does NOT: Create a full 3.0.0 release",
"Does NOT: Skip pre-release flag",
"Does NOT: Use gh release create"
],
"assertions": [
{
"type": "content",
"pattern": "(?i)rc\\.1"
},
{
"type": "content",
"pattern": "(?i)(version|pre-release)"
},
{
"type": "content",
"pattern": "(?i)(signed|git tag -s)"
},
{
"type": "must_not",
"pattern": "(?i)gh release create"
}
]
}
]
}
CI Workflow Templates
TYPO3 Projects
TYPO3 extensions at Netresearch use shared CI workflows:
- Repository: netresearch/typo3-ci-workflows
- Usage: Reference via
uses: netresearch/typo3-ci-workflows/.github/workflows/release.yml@main - These workflows handle TER upload, documentation rendering, and release creation
Organization-Wide Workflows
Netresearch maintains org-level reusable workflows:
- Repository: netresearch/.github
- Contains shared release, CI, and quality workflows
- Projects should prefer org workflows over per-repo copies to reduce maintenance
Generic Release Workflow Structure
For projects that don't use shared workflows, use this template as a starting point:
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
id-token: write
attestations: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify tag is annotated and signed
run: |
TAG_TYPE=$(git cat-file -t "${GITHUB_REF_NAME}")
if [ "$TAG_TYPE" != "tag" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} is lightweight (type: $TAG_TYPE). Only annotated tags are allowed."
exit 1
fi
# Verify signature (fails if unsigned)
git tag -v "${GITHUB_REF_NAME}" 2>/dev/null || echo "::warning::Tag signature verification failed"
- name: Build artifacts
run: |
# Project-specific build steps here
echo "Build artifacts for ${GITHUB_REF_NAME}"
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
format: spdx-json
output-file: sbom.spdx.json
artifact-name: sbom
- name: Create draft release
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
files: |
dist/*
sbom.spdx.json
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: dist/*
- name: Attest SBOM
uses: actions/attest-sbom@v2
with:
subject-path: dist/*
sbom-path: sbom.spdx.json
- name: Sign with cosign
uses: sigstore/cosign-installer@v3
- run: |
for f in dist/*; do
cosign sign-blob --yes --oidc-issuer https://token.actions.githubusercontent.com "$f" > "${f}.sig"
doneRequired Permissions
| Permission | Why | Required For |
|---|---|---|
contents: write | Create releases, upload assets | softprops/action-gh-release |
id-token: write | OIDC token for Sigstore keyless signing | cosign sign-blob, SLSA provenance |
attestations: write | GitHub artifact attestations | actions/attest-build-provenance, actions/attest-sbom |
packages: write | Push to container registry | Container image releases only |
Triggers
Tag Push (Recommended)
on:
push:
tags:
- 'v*'This triggers on any tag matching v* (e.g., v1.0.0, v2.0.0-rc.1). This is the recommended trigger because:
- Only signed annotated tags should be pushed (enforced by workflow verification step)
- The tag commit is the exact commit that was reviewed and merged
- No ambiguity about what is being released
Manual Dispatch (Supplementary)
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to release'
required: trueUseful as a fallback when re-running a failed release workflow.
Draft-First Pattern
The key line in the workflow template is:
draft: trueThis ensures: 1. The release is created as a draft — mutable and not yet permanent 2. Artifacts are attached to the draft for review 3. A human reviews and publishes via the GitHub UI 4. Immutability only locks in when the human clicks "Publish"
Never set `draft: false` in automated workflows. The publish step is an intentional human gate that prevents accidental tag burning and ensures release quality.
Ecosystem-Specific Steps
PHP / Composer
- name: Validate composer.json
run: composer validate --strict
- name: Build (if applicable)
run: composer install --no-dev --optimize-autoloaderNode.js
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Build
run: npm ci && npm run buildGo
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Build binaries
run: |
GOOS=linux GOARCH=amd64 go build -o dist/app-linux-amd64
GOOS=darwin GOARCH=arm64 go build -o dist/app-darwin-arm64Rust
- uses: dtolnay/rust-toolchain@stable
- name: Build release binary
run: cargo build --releaseEcosystem Detection
Purpose
Identify which version files need updating based on the project's ecosystem. A project may span multiple ecosystems (e.g., a TYPO3 extension is both PHP/Composer and TYPO3-specific).
Detection Strategy
1. Check for ecosystem-specific files in priority order 2. A project can match multiple ecosystems — update ALL matching version files 3. Always check for generic files (CHANGELOG.md, VERSION) regardless of ecosystem
Ecosystem Patterns
TYPO3
Detection: ext_emconf.php exists in project root or extension directory.
| File | Field/Pattern | Example |
|---|---|---|
ext_emconf.php | 'version' => 'X.Y.Z' | 'version' => '13.8.1' |
composer.json | "version": "X.Y.Z" | "version": "13.8.1" |
Documentation/guides.xml | <guide version="X.Y.Z"> or version attribute | version="13.8.1" |
Documentation/**/*.rst | .. versionadded:: X.Y.Z | .. versionadded:: 13.8.0 |
Documentation/**/*.rst | .. versionchanged:: X.Y.Z | .. versionchanged:: 13.8.1 |
Note: RST versionadded/versionchanged directives should only be updated when they reference the current release being prepared, not historical entries.
PHP / Composer
Detection: composer.json exists (without ext_emconf.php for pure PHP).
| File | Field/Pattern | Example |
|---|---|---|
composer.json | "version": "X.Y.Z" | "version": "2.1.0" |
Note: Many Composer packages omit the version field entirely, relying on Git tags. Only update if the field already exists.
Node.js
Detection: package.json exists.
| File | Field/Pattern | Example |
|---|---|---|
package.json | "version": "X.Y.Z" | "version": "3.0.1" |
package-lock.json | "version": "X.Y.Z" (root) | "version": "3.0.1" |
Note: Update package-lock.json by running npm install --package-lock-only after bumping package.json, not by manual editing.
Go
Detection: go.mod exists.
| File | Field/Pattern | Example |
|---|---|---|
go.mod | Module path for major versions | module example.com/foo/v2 |
Note: Go is primarily tag-driven. Minor and patch releases require no file changes — only the Git tag. Major version bumps (v2+) require updating the module path in go.mod and all internal imports.
Python
Detection: pyproject.toml or setup.py or setup.cfg exists.
| File | Field/Pattern | Example |
|---|---|---|
pyproject.toml | version = "X.Y.Z" | version = "1.4.0" |
setup.py | version="X.Y.Z" | version="1.4.0" |
setup.cfg | version = X.Y.Z | version = 1.4.0 |
src/*/__init__.py | __version__ = "X.Y.Z" | __version__ = "1.4.0" |
*/__init__.py | __version__ = "X.Y.Z" | __version__ = "1.4.0" |
Rust
Detection: Cargo.toml exists.
| File | Field/Pattern | Example |
|---|---|---|
Cargo.toml | version = "X.Y.Z" (under [package]) | version = "0.5.2" |
Cargo.lock | Auto-updated | Run cargo check after bumping |
Note: Update Cargo.lock by running cargo check after bumping Cargo.toml, not by manual editing.
Skill Repositories
Detection: .claude-plugin/plugin.json or skills/*/SKILL.md exists.
| File | Field/Pattern | Example |
|---|---|---|
.claude-plugin/plugin.json | "version": "X.Y.Z" | "version": "0.1.0" |
skills/*/SKILL.md | version: "X.Y.Z" (in frontmatter) | version: "0.1.0" |
Generic (Always Check)
These files are ecosystem-independent and should always be checked:
| File | Field/Pattern | Example |
|---|---|---|
CHANGELOG.md | Add new ## [X.Y.Z] - YYYY-MM-DD section | ## [1.5.0] - 2026-04-10 |
VERSION | Plain text version string | 1.5.0 |
Multi-Ecosystem Projects
A single project may match multiple ecosystems. For example:
- TYPO3 extension: TYPO3 + PHP/Composer + Generic
- Node.js library with Rust bindings: Node.js + Rust + Generic
- Full-stack monorepo: May have Node.js frontend + Python backend + Generic
Update ALL matching version files. Report which files were updated in the PR description.
GitHub Immutable Releases
What Are Immutable Releases?
GitHub immutable releases became generally available in October 2025. Once a release is published, it becomes permanently immutable:
- The release cannot be deleted
- The release cannot be edited (title, body, assets are locked)
- The associated tag name is permanently burned
Immutability applies to all repositories on GitHub.com and GitHub Enterprise Cloud. Self-hosted GitHub Enterprise Server may have different behavior depending on version.
When Does Immutability Take Effect?
| Release State | Mutable? | Tag Name Burned? |
|---|---|---|
| Draft | Yes — can edit, delete, change assets | No — tag name is reserved but not burned |
| Published | No — fully immutable | Yes — permanently, no recovery |
| Pre-release (published) | No — fully immutable | Yes — permanently, no recovery |
Key distinction: draft releases are still mutable. This is why the draft-first pattern is critical.
Tag Name Burning
When a release is published against a tag name, that tag name is permanently consumed. This is the most dangerous aspect of immutable releases.
What "burned" means
- The tag name (e.g.,
v1.0.0) can never be used for another release on this repository - Deleting the Git tag (
git push --delete origin v1.0.0) does not free the name - Deleting the release (if it were possible) would not free the name
- GitHub Support cannot recover burned tag names — this is by design for supply chain integrity
The error message
When you attempt to create a release with a burned tag name:
422 Validation Failed: tag_name was used by an immutable release and cannot be reusedThis error is permanent and unrecoverable for that tag name in that repository.
How tag names get burned accidentally
1. `gh release create v1.0.0` — creates a lightweight tag AND publishes immediately (not as draft). The tag name is instantly burned. 2. Publishing too early — clicking "Publish" on a draft before verifying contents. Once published, there is no "unpublish." 3. CI workflow that auto-publishes — if the workflow creates a non-draft release, the tag is burned on first run. A failed re-run cannot reuse it.
Why gh release delete Doesn't Fix It
gh release delete can only delete draft releases. Published releases cannot be deleted due to immutability. Even if you could delete the release object, the tag name remains burned — the burning is tied to the publication event, not the release object's existence.
The Only Recovery: New Version Number
If a tag name is burned (whether by accident or by a flawed release):
1. Accept the loss — v1.0.0 is gone forever for this repository 2. Bump to the next version — release as v1.0.1 (or v1.1.0 depending on the situation) 3. Document the skip — note in CHANGELOG.md that a version was skipped and why 4. Fix the process — ensure CI uses draft-first pattern to prevent recurrence
See recovery-procedures.md for detailed recovery steps.
Implications for Release Workflows
Do
- Always create releases as drafts first
- Use CI to create draft releases — humans publish after review
- Use signed annotated tags (
git tag -s) — they carry author and signature metadata - Test the full release workflow on a non-production repository first
Do Not
- Never use
gh release createwithout--draftflag (and even then, prefer CI) - Never auto-publish releases in CI — always leave as draft for human review
- Never delete and recreate tags expecting to reuse the name
- Never assume a failed release can be "retried" with the same version number
When Moving a Tag IS Safe
Tag-name burning is tied to release publication, not to the tag push itself. A tag pushed to the remote is not automatically burned. Burning happens only when gh release create (or the equivalent REST/GraphQL API call, or the Release workflow's create-release step) actually creates the release object.
This means: if a release workflow fails before the create-release step runs — e.g., a broken reusable-workflow reference, a failing build, a failing SBOM step, a failing signing step — the tag name is still available to re-use. The workflow never reached the publication event, so the tag name is not burned.
Verify before moving
Always confirm the tag name is not burned before deleting and re-pushing. Burning is tied to publication — a draft release does not burn the tag name, so the check has to distinguish draft from published.
The safest programmatic check uses --json isDraft:
STATE=$(gh release view "vX.Y.Z" --json isDraft 2>/dev/null || echo "notfound")
if [[ "$STATE" == "notfound" ]] || [[ "$STATE" == *'"isDraft":true'* ]]; then
echo "Safe to move (no release OR draft only — tag name not burned)"
else
echo "BURNED — release is published; bump the version instead"
fiInterpretation:
notfound(gh returns non-zero, typically "release not found") → the tag
name is unburned and safe to move.
{"isDraft":true}→ a draft release exists. The tag name is
unburned (GitHub reserves the name but does not lock it until publication), so the tag is still safe to move. If you do move it, delete the stale draft first (gh release delete vX.Y.Z) so the re-triggered workflow can recreate it cleanly.
{"isDraft":false}→ a published release exists. The tag name is
burned. Do not move it; bump the version instead (see "The Only Recovery" above).
Safe move flow
If the verification step above reports the tag name is unburned (no release or draft only) and the tag needs to point at a corrected commit (typically the fix for whatever broke the workflow):
# 1. Delete the local tag
git tag -d vX.Y.Z
# 2. Delete the remote tag (pushing an empty ref)
git push origin :vX.Y.Z
# 3. Re-create the signed annotated tag at the corrected commit
git tag -s vX.Y.Z -m "vX.Y.Z" <new-sha>
# 4. Push the tag — this re-triggers the release workflow
git push origin vX.Y.ZThe re-push triggers the release workflow again against the corrected commit. If the workflow now succeeds, it creates the release and the tag name is burned from that point forward.
Hard rule
Never move a tag after a successful (published) release. Once gh release view vX.Y.Z --json isDraft returns {"isDraft":false}, the tag name is off-limits — see "The Only Recovery: New Version Number" above. A draft release ({"isDraft":true}) does not burn the tag name; the top of "When Moving a Tag IS Safe" explains why, and the verification step above tells you how to distinguish the two.
Real-world example: t3x-nr-vault v0.5.0
A production release session hit this exact situation:
1. Version bump PR merged on main. 2. Signed tag v0.5.0 pushed. 3. Release workflow failed immediately with "workflow file not found" — the release.yml referenced netresearch/skill-repo-skill/.github/workflows/slsa-provenance.yml@<sha> but that file had been consolidated into release.yml upstream. 4. Verification step:
$ gh release view v0.5.0
release not found5. Because the workflow never reached the create-release step, the tag name was unburned. Safe move flow applied: git tag -d v0.5.0 && git push origin :v0.5.0, fix the release.yml reference on main, re-sign v0.5.0 at the fix commit, re-push. 6. Workflow succeeded on the re-push. v0.5.0 was published — from that point forward the tag name is burned, as expected.
The mechanical checkpoint GR-12 (validate-reusable-workflows.sh) catches this class of failure before the tag is ever pushed.
Timeline
| Date | Event |
|---|---|
| 2025-06 | Immutable releases announced in beta |
| 2025-10 | General availability — all repos affected |
| 2025-10+ | Tag name burning enforced retroactively on all published releases |
Recovery Procedures
Burned Tag Name
Symptom: 422 Validation Failed: tag_name was used by an immutable release and cannot be reused
Cause: A release was published (not draft) against this tag name. The name is permanently consumed.
Recovery:
1. Accept the version number is lost — there is no technical recovery 2. Determine the next appropriate version:
- If
v1.0.0was burned: release asv1.0.1(orv1.1.0if changes warrant) - If a pre-release like
v2.0.0-rc.1was burned: usev2.0.0-rc.2
3. Update all version files to the new number 4. Add a CHANGELOG.md entry explaining the skip:
## [1.0.1] - 2026-04-10
Note: v1.0.0 was skipped due to a burned tag name from an immutable release.5. Follow the standard release flow with the new version number 6. Fix the root cause — ensure CI uses draft-first pattern going forward
Draft Release Stuck (CI Workflow Failed)
Symptom: Tag was pushed, but no draft release appeared (or draft is incomplete).
Cause: The CI release workflow failed or was not triggered.
Recovery:
1. Check workflow status:
gh run list --workflow=release.yml --limit=5
gh run view <run-id> --log-failed2. If the workflow failed mid-run:
gh run rerun <run-id>3. If the workflow was never triggered:
- Verify the workflow file exists and has correct
on: push: tags:trigger - Verify the tag was actually pushed:
git ls-remote --tags origin | grep vX.Y.Z - Manually trigger if the workflow supports
workflow_dispatch
4. If the draft exists but is incomplete:
- Re-run the failed workflow to re-attach artifacts
- Or manually upload artifacts to the draft via GitHub UI
5. User publishes: once the draft looks correct, the user publishes via GitHub UI
Important: The tag is NOT burned while the release is in draft state. If the draft is fundamentally broken, you can delete it and recreate.
Lightweight Tag Already Pushed
Symptom: git cat-file -t vX.Y.Z returns commit instead of tag (meaning it's lightweight, not annotated).
Cause: Someone ran git tag vX.Y.Z without -s or -a, or gh release create created it.
Recovery if no release was published against it:
1. Delete the remote tag:
git push --delete origin vX.Y.Z2. Delete the local tag:
git tag -d vX.Y.Z3. Create a proper signed annotated tag:
git tag -s vX.Y.Z -m "vX.Y.Z"4. Push the new tag:
git push origin vX.Y.ZRecovery if a release WAS published: The tag name is burned. Follow the "Burned Tag Name" procedure above.
Missing CI Release Workflow
Symptom: Tags are pushed but no release is ever created.
Cause: The repository has no release workflow configured.
Recovery:
1. Check for existing workflow:
ls .github/workflows/release.yml 2>/dev/null
gh workflow list2. If no workflow exists, scaffold one from the templates in ci-workflow-templates.md 3. Choose the appropriate template based on the project ecosystem 4. Commit the workflow to the default branch (it must be on main/master for tag triggers to work) 5. Test by creating a pre-release tag (e.g., v0.0.1-test.1)
Version File Drift
Symptom: Different version files show different version numbers, or version files don't match the latest Git tag.
Cause: Manual edits, partial bumps, or version bumps done outside the release process.
Detection:
# Compare Git tags to version files
git describe --tags --abbrev=0 # Latest tag
# Then check each ecosystem's version filesRecovery:
1. Determine the canonical version:
- If a release exists: use the released version
- If only tags exist: use the latest tag
- If tags and files disagree: the tag is authoritative (it's what consumers see)
2. Run ecosystem detection to identify all version files 3. Update all version files to match the canonical version 4. Commit: fix: align version files to vX.Y.Z 5. Do NOT create a new tag — this is a correction commit, not a release
Release Body Clobbered After Manual Edit
Symptom: You edited the release description via gh release edit vX.Y.Z --notes-file notes.md (overhaul step) to add a narrative summary, then re-ran the release workflow (to fix a downstream failure like a TER publish timeout), and the carefully-written notes got replaced with auto-generated ## Changes / commit-list content.
Cause: Many release workflows use softprops/action-gh-release with a body: input that regenerates the release description from the commit log. Re-running the workflow executes the Create Release step again, which detects the release already exists and patches it with the freshly regenerated body — overwriting the manual edit.
Prevention: After the manual overhaul step, do NOT re-run the release workflow. If a downstream sub-job failed (TER publish, artifact upload, etc.), re-run only that job, or trigger it via a separate dispatcher workflow that does NOT include the release-creation step. See ter-republish.md for the TYPO3-specific pattern using a workflow_dispatch-only caller.
Recovery (body already clobbered):
1. Re-apply the manual notes:
gh release edit vX.Y.Z --repo owner/repo --notes-file notes.md2. If the release body is the source for TER/Packagist/other downstream systems, re-trigger those publishes via their own dispatcher workflows — NOT by re-running the release workflow itself. 3. Add a note to the project's release checklist: "after editing release notes, re-run only downstream publishers, never the full release workflow."
Mis-Tagged SemVer Release (Scope Larger Than Version Bump Implies)
Symptom: A release was tagged (and published, and consumed by TER / Packagist / downstream pipelines) as e.g. v2.2.2 but actually contains new user-facing features, a major dependency bump, or behavioural changes that should have warranted a minor or major bump per SemVer.
Cause: The release was assembled from an accumulated [Unreleased] section over many months. The person cutting the release didn't audit the full scope before picking a version increment.
Recovery: The tag cannot be recalled — it's already immutable on GitHub and downstream consumers (Composer / npm / pip lockfiles, TER) already reference it. The only honest recovery is documentation:
1. Do NOT delete the tag. Consumers who pinned to it would get broken builds. Let the mis-tag stand.
2. Do NOT ship a "replacement" release at a higher number with the same content. Downstream consumers already on ^2.2 would see both 2.2.2 and 2.3.0 resolving to effectively identical code — they'd correctly pick the newer number and the old 2.2.2 would persist as a "zombie version" that nobody should use but is still there.
3. Rewrite the release notes and CHANGELOG entry to acknowledge the mis-tag. Lead with a prominent versioning note:
## [2.2.2]
> **Versioning note.** 2.2.2 is tagged as a patch but contains
> ~N commits since 2.2.1, including new user-facing features
> (list them) and a `$dep` v3 → v4 dependency bump. By SemVer
> this should have been 2.3.0. The tag is kept because 2.2.2 is
> already published on $registry and GitHub and cannot be
> recalled. Consumers pinning to `^2.2` receive all the changes
> below.4. Enumerate the full scope in Added / Changed / Fixed sections rather than hiding it behind a one-line "also contains other commits" disclaimer. Honesty beats a misleadingly small patch note.
5. Call out any behaviour changes prominently with an ### Upgrading block at the top of the section, especially for default-on features that previously weren't there (new auto-running event listeners, changed optimization pipelines, etc.). Provide a copy-paste opt-out snippet.
6. Update the GitHub release body via gh release edit vX.Y.Z --notes-file notes.md with the same content so readers landing on the release page see the correction.
7. Add a release-flow improvement for the future: before cutting a release, run git log --oneline <prev-tag>..HEAD --no-merges | wc -l and count feat / fix / BREAKING CHANGE commits. If the increment doesn't match the detected conventional-commit impact, stop and reconsider the version number before tagging.
Branch Protection Blocks the [RELEASE] Commit
Symptom: On a repository with signed-commits / required-review branch protection, git push origin main of the version-bump commit fails with push declined due to repository rule violations.
Cause: Branch protection requires all changes to main go through a PR — even the [RELEASE] vX.Y.Z commit authored by the release flow.
Recovery: Always route the version bump through a PR:
# From the just-committed local main
git reset --soft HEAD~1 # un-commit the bump, keep staged
git checkout -b release/vX.Y.Z # move to a release branch
git commit -S --signoff -m "[RELEASE] vX.Y.Z"
git push -u origin release/vX.Y.Z
gh pr create --base main --head release/vX.Y.Z \
--title "[RELEASE] vX.Y.Z" --body "Release PR"
# merge, then tag + push from the new mainUpdate the project's release scripts/commands to produce a PR by default rather than a direct push — branch protection should be the norm, not something the release flow gets surprised by.
Checklist: Pre-Release Health Check
Run these checks before starting any release:
- [ ] All version files agree on current version
- [ ] Latest Git tag matches version files
- [ ] Latest tag is annotated and signed:
git cat-file -t <tag>returnstag - [ ] CI release workflow exists and is functional
- [ ] No burned tag names blocking the target version
- [ ] CHANGELOG.md is up to date
- [ ] Default branch is clean (no uncommitted changes)
- [ ] The commit scope between the last tag and HEAD matches the
selected version increment. Count by conventional-commit type:
git log <prev-tag>..HEAD --no-merges --format='%s' \
| awk -F: '{print $1}' | sort | uniq -c | sort -rnRed flags for a patch bump:
- any
featentries at all (implies minor at minimum) - any
BREAKING CHANGEin the full message body:git log <prev-tag>..HEAD --grep='BREAKING CHANGE'(implies major) - total commit count significantly larger than previous patch releases on this project (harder to eyeball — useful as a "pause and re-read the log" signal)
Release Process
Overview
The complete flow from "create a release" to "release published on GitHub."
Why gh release create Is Forbidden
gh release create does the following harmful things:
1. Creates a lightweight tag if the tag doesn't exist — lightweight tags have no signature, no author metadata, and cannot be retroactively converted to annotated tags. 2. Burns the tag name permanently — since GitHub immutable releases (GA Oct 2025), once a release uses a tag name, that name can never be reused. Not even gh release delete followed by git push --delete origin vX.Y.Z recovers it. GitHub returns: "tag_name was used by an immutable release". 3. Bypasses CI — no provenance attestation, no SBOM, no artifact signing. The release is created directly with whatever you attach manually. 4. Skips version file bumps — source code still shows the old version.
The hooks in this repository block gh release create and gh release delete to prevent these outcomes. gh release edit is allowed only for --notes/--notes-file flags (release description overhaul).
The Correct Release Flow
Phase 1: Preparation
1. Detect ecosystem (see ecosystem-detection.md)
2. Determine next version number:
- From conventional commits (feat → minor, fix → patch, BREAKING CHANGE → major)
- From explicit user input ("bump to 2.0.0")
3. Create release branch:
git checkout -b release/vX.Y.Z
4. Bump all version files for the detected ecosystem
5. Update CHANGELOG.md with new section
6. Commit: "chore: prepare release vX.Y.Z"
7. Push branch and open PRPhase 2: Review and Merge
1. PR passes CI checks (lint, test, build)
2. Reviewer approves version bumps and changelog
3. PR is merged to main (squash or merge commit per project convention)Phase 3: Tag Creation
After the PR is merged into main:
1. git checkout main && git pull # advance to main's post-merge HEAD
2. git tag -s vX.Y.Z -m "vX.Y.Z" # tags main's HEAD
3. git push origin vX.Y.Z # release orchestrator picks it upThe tag MUST be:
- Annotated (
-aor-s), never lightweight - Signed (
-sfor GPG/SSH signing) — required for SLSA L1+ - On `main`'s HEAD after the PR merges — not on the
release/vX.Y.Zbranch tip, not on an older commit
Why `main`'s post-merge HEAD and not the release branch tip: depending on the project's merge strategy, main's HEAD after merge is one of:
- The original branch-tip commit, if the PR was fast-forwarded;
- A new squash commit, if the PR was squash-merged;
- A new merge commit, if the PR was merge-committed.
The tag must point to whatever is now the tip of main — that is what consumers will check out, what CI will build artifacts from, and what shows up as "the release commit" on the GitHub release page. With squash- or merge-commit strategies, the release/vX.Y.Z branch tip is not on main's first-parent history, so tagging it produces a tag that doesn't correspond to any commit on main.
In practice: do NOT git tag from inside the worktree on the release/vX.Y.Z branch. Switch to main, pull, then tag — the steps above already enforce this order.
Phase 4: CI Release Workflow
The tag push triggers the release workflow (e.g., .github/workflows/release.yml):
1. CI validates version tag matches version files
2. CI builds artifacts (binaries, archives, etc.)
3. CI generates checksums (SHA256SUMS.txt)
4. CI generates SBOM if configured (SPDX or CycloneDX)
5. CI creates provenance attestation if configured (SLSA)
6. CI publishes the GitHub Release with all artifacts and auto-generated release notesPhase 5: Release Description Overhaul
After CI publishes the release, the agent overhauls the auto-generated description into a narrative format:
1. Wait for CI release workflow to complete successfully
2. Review the commits included in the release (git log prev_tag..new_tag)
3. Write a narrative release description covering:
- What changed and why it matters
- Context for skipped versions or notable decisions
- Grouped by theme (features, fixes, infrastructure), not by commit
4. Update via: gh release edit vX.Y.Z --notes "..."The auto-generated notes (PR titles, contributor lists) are a starting point, not the final product. The agent's description should read like a changelog entry written for humans.
Narrative over implementation details
Release notes are for the people deciding whether to upgrade — users, admins, integrators — not for developers reading the diff. Lead with the user-facing story, then brief feature sections.
Don't list:
- Internal types, DTOs, enums, service-class names
- File paths or class paths touched by the release
- i18n unit counts or translation-bundle diffs
- Refactor details that don't change behavior
Do describe:
- What a user can now do that they couldn't before
- The configuration levels / option values a feature exposes
- Breaking-change surfaces with migration notes
Bad example (diff-focused):
-EnforcementLevelenum,EnforcementStatusDTO,EnforcementService,AdoptionStatsService
- 47 new i18n units in locallang_db.xlf- Refactored UserController::indexAction into 3 helper methodsGood example (user-focused):
Per-group passkey enforcement with four levels: Off, Encourage, Required, Enforced. Admins can now configure whether a group's members may log in with passwords, are nudged toward passkeys, must enroll at least one, or must use one for every sign-in.
--latest=false for non-default-branch releases
*GitHub marks the most recently created release as "Latest" — by timestamp, not by SemVer.*
Creating a backport release (say v11.0.17) AFTER a newer release on a higher branch (v13.5.0) steals the "Latest" badge from v13.5.0, and users who click "Latest release" then get the old major.
Rule: this guidance does not override the policy above. On repositories guarded by this skill's hooks (including every Netresearch repo with a release workflow), manual gh release create stays blocked — CI creates releases from signed tags, not the agent. The rest of this subsection applies only to the rare unguarded case: repos WITHOUT a release workflow, where manual gh release create is the only path. In that case, pass --latest=false for non-default-branch releases:
# Backport release on TYPO3_11 branch while main is on v13
gh release create v11.0.17 \
--latest=false \
--title "v11.0.17" \
--notes "Backport: CVE-2026-XXXX fix"Default-branch (highest-version) releases keep the Latest badge; backports publish without stealing it.
For the CI-driven flow (the common case) — the release workflow, not the agent, creates the release, so the analogous setting is make_latest: false on the softprops/action-gh-release step (or the equivalent on whatever action publishes the release). Release workflows typically trigger on tag push (on.push.tags), so github.ref_name holds the tag (e.g. v1.2.3), not a branch name — branch-name comparisons will never match on that trigger. Drive make_latest from an explicit source of truth instead:
# Combined trigger: tag push (normal case) + workflow_dispatch with explicit
# tag + make_latest inputs (for manual backport publishes).
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (must already exist)'
required: true
make_latest:
type: boolean
default: true
jobs:
publish:
steps:
- uses: actions/checkout@... # pinned SHA
with:
# CRITICAL on workflow_dispatch: ref_name is the branch the dispatch
# was launched from (e.g. 'main'), not the tag. Without this, the job
# builds assets from the wrong commit and publishes them to the tag.
# On push.tags the expression below resolves to ref_name (the tag)
# which is equivalent to the default checkout.
# Use github.event.inputs.* (not inputs.*) so the expression stays
# safe on push.tags runs — github.event.inputs resolves to an empty
# string on non-dispatch events, while inputs.* is only defined
# under workflow_dispatch / workflow_call.
ref: ${{ github.event.inputs.tag || github.ref_name }}
fetch-tags: true
# ... build assets here ...
- uses: softprops/action-gh-release@... # pinned SHA
with:
# push.tags: ref_name IS the tag; workflow_dispatch: use the input.
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
# Default to Latest on tag push; honor the boolean input on dispatch.
# GitHub Actions expressions have no ternary — this is the idiomatic
# and/or chain. fromJSON() parses the 'true'/'false' string from
# github.event.inputs into an actual boolean for the `&&` short-circuit.
make_latest: ${{ github.event_name == 'workflow_dispatch' && (fromJSON(github.event.inputs.make_latest || 'true') && 'true' || 'false') || 'true' }}For dispatch-only publishes (no tag-push trigger), drop the push: block; the combined expressions above still work, and you can simplify if you like. For tag-push-only workflows, drop the workflow_dispatch: block and always use github.ref_name with a fixed make_latest — but note that the fixed approach can't express "backport, don't steal Latest" without the dispatch input.
For repos using the shared release workflow template at skills/github-release/templates/release-generic.yml, file a patch there to expose a make_latest input (keep the name underscored to match GitHub's own action parameter; hyphenated names would force bracket-expression access, which is easy to get wrong) rather than forking per-repo.
When CI Fails
If the release workflow fails:
1. Workflow failed mid-run: Re-run the workflow. If a release already exists, the workflow should handle idempotent creation. 2. Artifacts are wrong: Fix the issue and re-run the workflow. 3. Startup failure: Check that the caller workflow grants all permissions required by the reusable workflow (e.g., contents: write, pull-requests: write).
Version Tag Format
- Always use
vprefix:v1.0.0,v2.3.1 - Follow SemVer 2.0.0:
vMAJOR.MINOR.PATCH - Pre-releases:
v1.0.0-rc.1,v2.0.0-beta.3 - No build metadata in tags (build metadata is not sortable)
Supply Chain Security
Overview
Modern releases require more than just a tarball. Supply chain security ensures consumers can verify the provenance, integrity, and composition of released artifacts. This reference covers the key components; delegate detailed implementation to the enterprise-readiness skill.
SLSA Provenance Levels
SLSA (Supply-chain Levels for Software Artifacts) defines four levels of increasing assurance:
| Level | Requirements | What It Proves |
|---|---|---|
| L0 | No provenance | Nothing — the default |
| L1 | Provenance exists and is signed | The build process generated provenance metadata |
| L2 | Hosted build service, signed provenance | A specific build service produced the artifact |
| L3 | Hardened build platform, non-falsifiable provenance | The build was isolated and tamper-resistant |
GitHub Actions and SLSA
GitHub Actions natively supports SLSA L1 via actions/attest-build-provenance. L2+ requires the slsa-framework/slsa-github-generator reusable workflows that run in isolated, hardened runners.
Sigstore / Cosign Keyless Signing
Sigstore enables keyless signing — no long-lived signing keys to manage.
How it works
1. CI authenticates via OIDC (GitHub Actions identity token) 2. Sigstore issues a short-lived certificate bound to the workflow identity 3. The artifact is signed with the ephemeral key 4. The signature and certificate are recorded in the Rekor transparency log 5. Consumers verify against the transparency log — no key distribution needed
Signing in CI
- uses: sigstore/cosign-installer@v3
- run: |
cosign sign-blob --yes \
--oidc-issuer https://token.actions.githubusercontent.com \
--bundle artifact.tar.gz.sigstore.json \
artifact.tar.gzOutput extension matters for OSSF Scorecard
Use `.sigstore.json` for the cosign bundle output, NOT cosign's default `.bundle`.
OSSF Scorecard's Signed-Releases check pattern-matches release-asset filenames against a fixed allowlist of signature extensions:
.sig.asc.minisig.sigstore.sigstore.json.intoto.jsonl
The .bundle extension that cosign sign-blob --bundle writes by default is not on that list, so cosign-signed releases that use .bundle are reported as unsigned (Signed-Releases score 0/10).
The bytes inside the file are identical — cosign's bundle format IS the Sigstore bundle JSON. Only the filename matters for tooling detection. cosign verify-blob --bundle file.sigstore.json works exactly the same as --bundle file.bundle.
Concrete fix in a workflow:
# Wrong — Scorecard sees this as unsigned
cosign sign-blob --yes "$file" --bundle "${file}.bundle"
# Right — Scorecard recognizes this as signed
cosign sign-blob --yes "$file" --bundle "${file}.sigstore.json"Past releases cannot be retroactively fixed. GitHub releases are immutable once assets are attached, so renaming or replacing assets on already-published releases is not possible. Only future releases benefit from the fix. Scorecard averages the Signed-Releases score over the last 4 releases, so the score climbs gradually as new releases ship.
Reference upstream change for the netresearch shared workflows: netresearch/typo3-ci-workflows#84 — applied to both release.yml and release-typo3-extension.yml.
Verification
cosign verify-blob \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "github.com/org/repo" \
--bundle artifact.tar.gz.sigstore.json \
artifact.tar.gzGitHub Artifact Attestations
GitHub's native attestation system using actions/attest@v4:
- uses: actions/attest-build-provenance@v2
with:
subject-path: dist/*.tar.gz
- uses: actions/attest-sbom@v2
with:
subject-path: dist/*.tar.gz
sbom-path: sbom.spdx.jsonVerification
gh attestation verify artifact.tar.gz --owner org-nameThis checks the Sigstore transparency log for attestations matching the artifact digest and the expected source repository.
SBOM Generation
Software Bill of Materials (SBOM) documents all components in a release.
Formats
| Format | Standard | Tooling |
|---|---|---|
| SPDX | ISO/IEC 5962:2021 | anchore/sbom-action, syft |
| CycloneDX | OWASP standard | anchore/sbom-action, cdxgen |
Generation in CI
- uses: anchore/sbom-action@v0
with:
format: spdx-json
output-file: sbom.spdx.json
artifact-name: sbomWhat SBOMs contain
- All direct and transitive dependencies with versions
- Package URLs (purls) for each component
- License information per component
- Relationship graph (dependency tree)
Required Workflow Permissions
Release workflows need specific permissions for attestation and signing:
permissions:
contents: write # Create releases, push tags
id-token: write # OIDC token for Sigstore keyless signing
attestations: write # GitHub artifact attestations
packages: write # Container registry (if applicable)Security note: These permissions should only be granted to the release workflow, not to all workflows. Use permissions at the job level, not the workflow level, to minimize exposure.
Verification Commands Reference
| What to Verify | Command |
|---|---|
| GitHub attestation | gh attestation verify <artifact> --owner <org> |
| Cosign blob signature | cosign verify-blob --certificate-oidc-issuer <issuer> --certificate-identity-regexp <pattern> <artifact> |
| Container signature | cosign verify <image> --certificate-oidc-issuer <issuer> --certificate-identity-regexp <pattern> |
| SBOM contents | syft <artifact> or grype sbom:sbom.spdx.json (for vulnerability scan) |
| SLSA provenance | slsa-verifier verify-artifact <artifact> --source-uri github.com/org/repo |
Delegation
For detailed implementation of supply chain security measures, delegate to the enterprise-readiness skill which covers:
- Full SLSA compliance assessment and remediation
- Sigstore integration setup
- SBOM pipeline configuration
- OpenSSF Scorecard and Best Practices Badge
TER Re-Publishing Without Re-Tagging
This reference covers TYPO3-specific recovery when the TYPO3 Extension Repository (TER) needs a re-upload but the Git tag is fine as-is.
When to Use
You need to re-push to TER but NOT re-tag when:
- Release notes were edited after the initial publish — TER's upload
comment is built from the GitHub release body, so editing the release on GitHub doesn't automatically refresh TER. A re-publish does.
- The initial `tailor ter:publish` failed transiently (idle timeout,
TER API hiccup) but the tag is already pushed and the GitHub release is already created correctly.
- A TER-only fix (e.g. a broken upload comment from a previous
release workflow version) without introducing a new version.
Re-tagging (delete tag, re-create, push) is NOT appropriate in any of these cases — the tag itself is already correct and immutable releases will refuse a second publish attempt on the same tag name anyway.
The workflow_dispatch-Only Caller Pattern
Add a dedicated manual-trigger caller alongside the normal tag-triggered release.yml. Pattern used across netresearch TYPO3 extensions (t3x-nr-llm, t3x-nr-mcp-agent, t3x-nr-image-optimize):
# .github/workflows/ter-publish.yml
name: Publish to TER (manual)
on:
workflow_dispatch:
permissions: {}
jobs:
publish-to-ter:
uses: netresearch/typo3-ci-workflows/.github/workflows/publish-to-ter.yml@main
permissions:
contents: read
secrets:
TYPO3_TER_ACCESS_TOKEN: ${{ secrets.TYPO3_TER_ACCESS_TOKEN }}That is the whole file. No inputs, no outputs.
A note on `TYPO3_EXTENSION_KEY`. Older caller workflows (including the release-typo3.yml template shipped alongside this reference) pass both TYPO3_EXTENSION_KEY and TYPO3_TER_ACCESS_TOKEN. The shared publish-to-ter.yml in netresearch/typo3-ci-workflows marks TYPO3_EXTENSION_KEY as required: false with the description Deprecated: extension key is auto-resolved from composer.json. The downstream "Resolve extension key" step reads extra.typo3/cms.extension-key from composer.json and hard-errors if it's missing, so the composer.json entry is the single source of truth. New callers should omit the forward; existing callers can keep it but it's a no-op.
Triggered via:
gh workflow run ter-publish.yml --repo owner/ext --ref main
# or --ref TYPO3_12 on a maintenance branchWhy --ref <branch>, not --ref <tag>
GitHub's workflow_dispatch API rejects --ref <tag> with HTTP 422 if the workflow file does not exist at that tag. Common situation: the ter-publish.yml caller was added after older tags were published, so they don't have the file.
Dispatching against the branch (main, TYPO3_12) always works. The shared publish-to-ter.yml reusable workflow in netresearch/typo3-ci-workflows reads the version from ext_emconf.php (single source of truth), then derives the matching release tag by trying v${VERSION} first and falling back to bare ${VERSION}. That lets it look up the GitHub release body for the TER upload comment even when invoked from a branch ref.
What Gets Re-Published
The shared workflow produces identical output between the tag-triggered release.yml and the manual ter-publish.yml:
1. Reads ext_emconf.php version (say 1.1.1) 2. Finds matching release — v1.1.1 or 1.1.1 3. Fetches the release body via gh release view 4. Strips HTML, truncates to ~1900 chars using codepoint-aware slicing 5. Calls tailor ter:publish --comment "$COMMENT" "$VERSION"
TER accepts re-uploads of the same version number — the upload comment simply gets overwritten. This is the documented behaviour of the POST /extension/{key}/{version} endpoint.
Triggering From the CLI
# Main branch — re-push the current version on main
gh workflow run ter-publish.yml --repo owner/ext --ref main
# Maintenance branch — re-push the current version on TYPO3_12
gh workflow run ter-publish.yml --repo owner/ext --ref TYPO3_12
# Watch until done
RUN=$(gh run list --repo owner/ext --workflow=ter-publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$RUN" --repo owner/ext --exit-statusCodepoint-Safe Comment Truncation
TER has a ~2000 character limit on upload comments. Byte-based truncation with head -c 1900 splits multi-byte UTF-8 sequences (emoji, em-dashes, accented characters) mid-codepoint and produces invalid UTF-8 that TER rejects or mis-renders.
The shared workflow uses python3 for truncation because:
- Guaranteed codepoint-level slicing via Python's
strindexing - No locale assumptions beyond
LANG=*.UTF-8(always set on
ubuntu-latest)
- Independent of whether the runner's
awkisgawkormawk
(ubuntu-latest historically ships both and codepoint support differs)
- No
RS=""paragraph-mode side effects — preserves blank lines,
leading/trailing whitespace, and the original line-break structure up to the truncation boundary
- Installed on every GitHub Actions runner
COMMENT=$(
printf '%s' "$RELEASE_BODY" \
| sed 's/<[^>]*>//g' \
| python3 -c 'import sys; sys.stdout.write(sys.stdin.read()[:1900])'
)Caveat on the `sed` step. The HTML-stripping regex <[^>]*> is deliberately dumb: it also removes non-HTML content wrapped in angle brackets. If your release notes contain any of the following, they will be silently dropped from the TER comment:
- GFM autolinks —
<https://github.com/...>vanishes - Markdown placeholder syntax —
<prev-tag>,<version>,<name> - Generic type examples in code fences —
<T>,<UserResponse> - Literal angle-bracket content in prose —
<noreply@github.com>
If your project routinely uses any of these in release notes, either:
1. Switch to a whitelist-based sanitizer (e.g. strip only specific known-unsafe HTML tags), or 2. Skip HTML stripping entirely and rely on TER's own rendering.
The full GitHub release body is always available via the release page link appended to the comment, so the tradeoff between safety and readability is mostly cosmetic.
Note: use printf '%s', not echo. echo "$BODY" interprets release bodies starting with -n / -e / -- as options rather than emitting them literally.
Tag Format Compatibility
Historic TYPO3 extensions used bare-version tags (1.0.3, 1.1.0); modern signed-tag convention uses a v prefix (v2.2.2, v1.1.1). Both forms are valid Git tags and both should be accepted by release tooling.
The shared publish-to-ter.yml regex is:
^refs/tags/v?[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$And the version variable strips both prefixes:
TAG="${GITHUB_REF#refs/tags/}"
VERSION="${TAG#v}"If a custom per-project caller has its own tag-check or version-resolve logic, apply the same pattern. See the netresearch/typo3-ci-workflows/.github/workflows/publish-to-ter.yml reference implementation.
Related
typo3-ter-publishing.md— initial-publish gotchas (tag/ext_emconf.php
version match, v-prefix handling in custom workflows)
recovery-procedures.md— generic release recoveryrelease-process.md— the standard release flowimmutable-releases.md— why we can't just re-tag
TYPO3 TER Publishing Gotchas
This reference covers TYPO3-specific failure modes when publishing an extension for the first time on a given version (initial publish, not re-publish — for re-publishing without re-tagging, see ter-republish.md).
Version Match Required Between Tag and ext_emconf.php
tailor ter:publish validates that the version it is uploading matches the 'version' key in ext_emconf.php. If they disagree it aborts with:
configured version does not matchext_emconf.php is the single source of truth for this validation. Bump it before tagging (e.g. 'version' => '0.6.0' for tag v0.6.0).
Documentation/guides.xml (version= and release= attributes on the <project> element) should also be kept in sync — not because TER validates against it, but because docs.typo3.org renders the wrong version banner if it drifts. Same release branch, same commit, same PR.
The Git tag (v0.6.0) is the second source of truth that must agree with ext_emconf.php at the moment CI runs tailor ter:publish. The release-prep PR pattern documented in release-process.md (Phase 1) already enforces this ordering — bump version files in the release branch, merge the PR, then tag the merge commit. The reason that ordering exists, beyond clean history, is that any other ordering produces a tag pointing at commits with stale version files and TER refuses the upload.
Don't tag first, bump second. A signed tag at the wrong commit is not free to fix: deleting and recreating a signed tag burns the GPG signature on the new tag (different SHA), invalidates any provenance attestation that referenced the old SHA, and on GitHub immutable releases (GA Oct 2025) burns the tag name permanently if a release was already created against it.
v Prefix Mismatch in Custom Publish Workflows
Git tags conventionally use a v prefix (v0.6.0); ext_emconf.php stores the bare version (0.6.0). A workflow that compares ${GITHUB_REF#refs/tags/} directly against the ext_emconf.php value will compare v0.6.0 against 0.6.0 and silently fail validation.
The fix is to derive the bare version in a run: step (GitHub Actions env: blocks do not perform shell parameter expansion — ${TAG#v} in env: is taken literally), and pass github.ref straight to actions/checkout:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.ref }} # refs/tags/v0.6.0 — finds the tag
- name: Resolve version
run: |
TAG="${GITHUB_REF#refs/tags/}" # v0.6.0 — for human-facing logs
VERSION="${TAG#v}" # 0.6.0 — for ext_emconf.php compare + tailor
echo "TAG=$TAG" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Publish
run: |
test "$VERSION" = "$(php -r '$EM_CONF=[]; include "ext_emconf.php"; echo $EM_CONF[basename(__DIR__)]["version"];')" \
|| { echo "::error::tag $TAG vs ext_emconf.php mismatch"; exit 1; }
tailor ter:publish --comment "..." "$VERSION"actions/checkout wants the raw ref so it can find the tag; the ext_emconf.php comparison and the tailor ter:publish argument want the bare version. Conflating them produces the same configured version does not match failure as section 1 above, but for a different reason — the comparison is wrong, not the file content.
If you use the shared reusable workflow you get this for free. netresearch/typo3-ci-workflows's publish-to-ter.yml already implements the strip pattern (see the regex and VERSION="${TAG#v}" snippet in ter-republish.md § Tag Format Compatibility). Both templates/release-typo3.yml and templates/ter-publish.yml in this skill repo wire that workflow up correctly.
This gotcha only bites if you write your own per-project publish workflow that bypasses the shared reusable workflow. If you do, mirror the three-variable pattern.
Related
ter-republish.md— re-publishing without re-tagging; tag-format
compatibility regex
release-process.md— the version-bump-then-tag ordering (Phase 1
through Phase 3) that prevents the version-match failure
templates/release-typo3.yml— tag-triggered TYPO3 release callertemplates/ter-publish.yml—workflow_dispatchre-publish caller
#!/usr/bin/env python3
"""check-changelog-links.py - Verify Keep-a-Changelog reference-style links.
Keep-a-Changelog projects commonly use reference-style section headers:
## [0.5.0] - 2026-04-19
...and define the link target at the bottom of the file:
[Unreleased]: https://github.com/owner/repo/compare/v0.5.0...HEAD
[0.5.0]: https://github.com/owner/repo/compare/v0.4.0...v0.5.0
When a new release is added it is easy to add the header without adding the
matching footer link (or to forget updating the `[Unreleased]` compare range).
The rendered CHANGELOG then shows a broken `[0.5.0]` link — a regression that
Copilot review tends to catch late in the PR cycle instead of the skill
catching it locally.
This script parses CHANGELOG.md and reports:
1. Any `## [X.Y.Z]` (or `## [X.Y.Z-prerelease]`) header without a matching
footer link definition.
2. A stale `[Unreleased]: .../compare/<previous>...HEAD` range when the
newest release is not `<previous>`.
Exits:
0 - all references resolve / file absent / no reference-style headers used
1 - a CHANGELOG error was detected. Either:
* one or more reference-style headers lack a footer link, or
* the `[Unreleased]: .../compare/<from>...HEAD` range is stale
(i.e., `<from>` is not the newest released version in the
header list).
2 - environment error (file unreadable, etc.)
Usage: check-changelog-links.py [path-to-changelog]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
HEADER_RE = re.compile(
r"^##\s+\[(?P<version>[^\]]+)\](?:\s*[-\u2013]\s*[0-9A-Za-z.\- ]+)?\s*$"
)
# Matches footer definitions like: [0.5.0]: https://...
FOOTER_RE = re.compile(r"^\[(?P<key>[^\]]+)\]:\s*(?P<url>\S.*)\s*$")
# Matches an Unreleased compare range ending in HEAD. The capture group must
# allow dots so it can match common forms like `v0.5.0...HEAD` or `0.5.0...HEAD`
# (the previous `[^./]+` class excluded dots and therefore missed semver
# versions entirely — it would only match refs like `main` or `abc1234`).
UNRELEASED_COMPARE_RE = re.compile(r"/compare/(?P<from>[^/]+?)\.\.\.HEAD\s*$")
def main(argv: list[str]) -> int:
path = Path(argv[1]) if len(argv) > 1 else Path("CHANGELOG.md")
if not path.exists():
# No CHANGELOG is a separate concern; do not flag here.
return 0
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
return 2
header_versions: list[str] = []
footer_keys: dict[str, str] = {}
for raw_line in text.splitlines():
line = raw_line.rstrip()
match = HEADER_RE.match(line)
if match:
header_versions.append(match.group("version"))
continue
match = FOOTER_RE.match(line)
if match:
footer_keys[match.group("key")] = match.group("url")
if not header_versions:
# Plain headers (no reference-style) — nothing to validate.
return 0
# Only enforce if the project is actually using reference-style links.
# Detect that by the presence of at least one footer key that matches any
# header version or the literal "Unreleased".
uses_reference_style = (
any(key in footer_keys for key in header_versions)
or "Unreleased" in footer_keys
)
if not uses_reference_style:
return 0
missing: list[str] = []
for version in header_versions:
# Skip the textual "Unreleased" entry from header scan — tracked
# separately below. `## [Unreleased]` is valid without a date.
if version.lower() == "unreleased":
continue
if version not in footer_keys:
missing.append(version)
warnings: list[str] = []
# Check Unreleased compare range points at the newest released version.
# Newest release = first non-Unreleased header (Keep-a-Changelog orders
# releases newest-first).
newest_release = next(
(v for v in header_versions if v.lower() != "unreleased"),
None,
)
unreleased_url = footer_keys.get("Unreleased")
if newest_release and unreleased_url:
compare_match = UNRELEASED_COMPARE_RE.search(unreleased_url)
if compare_match:
compare_from = compare_match.group("from")
expected = f"v{newest_release}"
# Accept either `vX.Y.Z` or bare `X.Y.Z` in the compare range.
if compare_from not in {expected, newest_release}:
warnings.append(
f"[Unreleased] compare range starts at {compare_from!r} "
f"but newest release is {newest_release!r} "
f"(expected {expected!r})"
)
if not missing and not warnings:
return 0
print(f"CHANGELOG link-reference issues in {path}:")
for version in missing:
print(
f" MISSING footer link for [{version}] — "
f"add `[{version}]: <compare-URL>` at the bottom of the file"
)
for warning in warnings:
print(f" STALE {warning}")
if missing:
print("")
print(
"Add the missing footer link(s) and update the "
"`[Unreleased]: .../compare/vX.Y.Z...HEAD` range to the newest "
"released version."
)
return 1
# Only warnings -> still signal non-zero so the mechanical check
# registers, but the checkpoint severity is `warning` so the skill
# surfaces it as a suggestion rather than a hard stop.
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
#!/usr/bin/env bash
#
# check-release-workflow.sh - Check if the project has a proper release workflow.
#
# Examines .github/workflows/release.yml for required features and reports
# PRESENT/MISSING per item.
#
set -euo pipefail
WORKFLOW=".github/workflows/release.yml"
present_count=0
missing_count=0
report() {
local status="$1" label="$2" detail="${3:-}"
if [[ "$status" == "PRESENT" ]]; then
((present_count++)) || true
printf " PRESENT %s" "$label"
else
((missing_count++)) || true
printf " MISSING %s" "$label"
fi
if [[ -n "$detail" ]]; then
printf " (%s)" "$detail"
fi
printf "\n"
}
echo "Release workflow analysis"
echo "========================="
echo ""
# ---------------------------------------------------------------------------
# 1. Workflow file exists
# ---------------------------------------------------------------------------
if [[ ! -f "$WORKFLOW" ]]; then
report "MISSING" "Release workflow file" "$WORKFLOW not found"
echo ""
echo "========================="
echo "Results: 0 present, 1 missing"
echo ""
echo "OVERALL: No release workflow found"
exit 1
fi
report "PRESENT" "Release workflow file" "$WORKFLOW"
workflow_content=$(cat "$WORKFLOW")
# ---------------------------------------------------------------------------
# 2. Triggers on push tags v*
# ---------------------------------------------------------------------------
echo ""
echo "Triggers:"
if echo "$workflow_content" | grep -qE "tags:[[:space:]]*\[?['\"]?v\*" 2>/dev/null || \
echo "$workflow_content" | grep -qE "^[[:space:]]+-[[:space:]]+['\"]?v\*" 2>/dev/null; then
report "PRESENT" "Push tag trigger (v*)"
else
report "MISSING" "Push tag trigger (v*)"
fi
if echo "$workflow_content" | grep -q 'workflow_dispatch' 2>/dev/null; then
report "PRESENT" "workflow_dispatch trigger"
else
report "MISSING" "workflow_dispatch trigger"
fi
# ---------------------------------------------------------------------------
# 3. Permissions
# ---------------------------------------------------------------------------
echo ""
echo "Permissions:"
if echo "$workflow_content" | grep -qE 'id-token[[:space:]]*:[[:space:]]*write' 2>/dev/null; then
report "PRESENT" "id-token: write"
else
report "MISSING" "id-token: write"
fi
if echo "$workflow_content" | grep -qE 'attestations[[:space:]]*:[[:space:]]*write' 2>/dev/null; then
report "PRESENT" "attestations: write"
else
report "MISSING" "attestations: write"
fi
if echo "$workflow_content" | grep -qE 'contents[[:space:]]*:[[:space:]]*write' 2>/dev/null; then
report "PRESENT" "contents: write"
else
report "MISSING" "contents: write"
fi
# ---------------------------------------------------------------------------
# 4. Reusable workflows
# ---------------------------------------------------------------------------
echo ""
echo "Reusable workflows:"
if echo "$workflow_content" | grep -qE 'netresearch/typo3-ci-workflows' 2>/dev/null; then
report "PRESENT" "netresearch/typo3-ci-workflows"
elif echo "$workflow_content" | grep -qE 'netresearch/\.github' 2>/dev/null; then
report "PRESENT" "netresearch/.github reusable workflows"
else
report "MISSING" "Netresearch reusable workflows" "netresearch/typo3-ci-workflows or netresearch/.github"
fi
# ---------------------------------------------------------------------------
# 5. SBOM generation
# ---------------------------------------------------------------------------
echo ""
echo "Supply chain security:"
if echo "$workflow_content" | grep -qE 'anchore/sbom-action|syft|cyclonedx|spdx' 2>/dev/null; then
match=$(echo "$workflow_content" | grep -oE 'anchore/sbom-action|syft|cyclonedx|spdx' | head -1)
report "PRESENT" "SBOM generation" "$match"
else
report "MISSING" "SBOM generation" "anchore/sbom-action or similar"
fi
# ---------------------------------------------------------------------------
# 6. Signing
# ---------------------------------------------------------------------------
if echo "$workflow_content" | grep -qE 'cosign-installer|cosign|sigstore' 2>/dev/null; then
match=$(echo "$workflow_content" | grep -oE 'cosign-installer|cosign|sigstore' | head -1)
report "PRESENT" "Signing" "$match"
else
report "MISSING" "Signing" "cosign-installer or similar"
fi
# ---------------------------------------------------------------------------
# 7. Attestation
# ---------------------------------------------------------------------------
if echo "$workflow_content" | grep -qE 'actions/attest-build-provenance|actions/attest' 2>/dev/null; then
match=$(echo "$workflow_content" | grep -oE 'actions/attest-build-provenance|actions/attest' | head -1)
report "PRESENT" "Attestation" "$match"
else
report "MISSING" "Attestation" "actions/attest-build-provenance or similar"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "========================="
total=$((present_count + missing_count))
echo "Results: ${present_count} present, ${missing_count} missing (${total} checks)"
#!/usr/bin/env bash
#
# detect-ecosystem.sh - Scan current directory and report detected ecosystems and version files.
#
# Output format (one line per finding):
# ecosystem:<name>
# version-file:<path>:<version>
# docs-version-ref:<path>:<directive>::<version>
# changelog:<path>
#
set -euo pipefail
# Extract a JSON field value using grep/sed (no jq dependency)
# Usage: json_field <file> <field>
json_field() {
local file="$1" field="$2"
sed -n "s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$file" 2>/dev/null | head -1 || true
}
# Extract version from ext_emconf.php
emconf_version() {
sed -n "s/.*'version'[[:space:]]*=>[[:space:]]*'\([^']*\)'.*/\1/p" "$1" 2>/dev/null | head -1 || true
}
# Extract version from a Cargo.toml (top-level only, before first [dependencies] etc.)
cargo_version() {
sed -n '/^\[package\]/,/^\[/{/^version\s*=/p}' "$1" 2>/dev/null \
| sed -n 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' || true
}
# Extract version from pyproject.toml [project] section
pyproject_version() {
sed -n '/^\[project\]/,/^\[/{/^version\s*=/p}' "$1" 2>/dev/null \
| sed -n 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' || true
}
# Extract version from setup.py
setup_py_version() {
sed -n 's/.*version[[:space:]]*=[[:space:]]*['"'"'"]\([^'"'"'"]*\).*/\1/p' "$1" 2>/dev/null | head -1 || true
}
# Extract version from pom.xml (first occurrence, project-level)
pom_version() {
# Grab version outside of <parent> and <dependency> blocks - simplified: first <version> child of <project>
sed -n '/<project/,/<\/project>/{/<parent>/,/<\/parent>/d; s/.*<version>\(.*\)<\/version>.*/\1/p;}' "$1" 2>/dev/null \
| head -1 || true
}
# Extract version from go.mod module line (Go modules don't carry semver in go.mod itself,
# but the module path may contain /v2 etc.)
go_mod_version() {
sed -n 's/^module[[:space:]]\{1,\}.*\/v\([0-9]\{1,\}\).*/\1/p' "$1" 2>/dev/null | head -1 || true
}
# Extract version from guides.xml
guides_xml_version() {
sed -n 's/.*<release>[[:space:]]*\([^<[:space:]]\{1,\}\).*/\1/p' "$1" 2>/dev/null | head -1 \
|| sed -n 's/.*release="\([^"]*\)".*/\1/p' "$1" 2>/dev/null | head -1 \
|| true
}
# ---------------------------------------------------------------------------
# TYPO3
# ---------------------------------------------------------------------------
if [[ -f ext_emconf.php ]]; then
echo "ecosystem:typo3"
ver=$(emconf_version ext_emconf.php)
echo "version-file:ext_emconf.php:${ver}"
# composer.json (may or may not have version for TYPO3 extensions)
if [[ -f composer.json ]]; then
cver=$(json_field composer.json version)
echo "version-file:composer.json:${cver}"
fi
# Documentation/guides.xml
if [[ -f Documentation/guides.xml ]]; then
gver=$(guides_xml_version Documentation/guides.xml)
echo "version-file:Documentation/guides.xml:${gver}"
fi
# Scan Documentation/**/*.rst for versionadded / versionchanged directives
if [[ -d Documentation ]]; then
while IFS= read -r rstfile; do
grep -E '^\.\.[[:space:]]+(versionadded|versionchanged)::' "$rstfile" 2>/dev/null | while IFS= read -r match; do
directive=$(echo "$match" | grep -oE '(versionadded|versionchanged)' || true)
dver=$(echo "$match" | sed -n 's/.*::[[:space:]]*\([^[:space:]]\{1,\}\).*/\1/p' || true)
if [[ -n "$directive" ]]; then
echo "docs-version-ref:${rstfile}:${directive}::${dver}"
fi
done || true
done < <(find Documentation -name '*.rst' -type f 2>/dev/null)
fi
fi
# ---------------------------------------------------------------------------
# Skill repo (.claude-plugin/plugin.json)
# ---------------------------------------------------------------------------
if [[ -f .claude-plugin/plugin.json ]]; then
echo "ecosystem:skill"
sver=$(json_field .claude-plugin/plugin.json version)
echo "version-file:.claude-plugin/plugin.json:${sver}"
# skills/*/SKILL.md - extract version from YAML frontmatter or version: line
for skillmd in skills/*/SKILL.md; do
[[ -f "$skillmd" ]] || continue
mdver=$(sed -n 's/^version:[[:space:]]*\([^[:space:]]\{1,\}\).*/\1/p' "$skillmd" 2>/dev/null | head -1 || true)
echo "version-file:${skillmd}:${mdver}"
done
fi
# ---------------------------------------------------------------------------
# PHP (composer.json with version, but not already reported under TYPO3)
# ---------------------------------------------------------------------------
if [[ -f composer.json ]] && [[ ! -f ext_emconf.php ]]; then
cver=$(json_field composer.json version)
if [[ -n "$cver" ]]; then
echo "ecosystem:php"
echo "version-file:composer.json:${cver}"
fi
fi
# ---------------------------------------------------------------------------
# Node.js
# ---------------------------------------------------------------------------
if [[ -f package.json ]]; then
is_private=$(json_field package.json private)
pkg_ver=$(json_field package.json version)
if [[ "$is_private" != "true" ]] || [[ -n "$pkg_ver" ]]; then
echo "ecosystem:nodejs"
echo "version-file:package.json:${pkg_ver}"
if [[ -f package-lock.json ]]; then
lock_ver=$(json_field package-lock.json version)
echo "version-file:package-lock.json:${lock_ver}"
fi
fi
fi
# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------
if [[ -f go.mod ]]; then
echo "ecosystem:go"
gover=$(go_mod_version go.mod)
echo "version-file:go.mod:${gover}"
fi
# ---------------------------------------------------------------------------
# Python
# ---------------------------------------------------------------------------
if [[ -f pyproject.toml ]]; then
echo "ecosystem:python"
pyver=$(pyproject_version pyproject.toml)
echo "version-file:pyproject.toml:${pyver}"
elif [[ -f setup.py ]]; then
echo "ecosystem:python"
pyver=$(setup_py_version setup.py)
echo "version-file:setup.py:${pyver}"
fi
# ---------------------------------------------------------------------------
# Rust
# ---------------------------------------------------------------------------
if [[ -f Cargo.toml ]]; then
echo "ecosystem:rust"
rver=$(cargo_version Cargo.toml)
echo "version-file:Cargo.toml:${rver}"
fi
# ---------------------------------------------------------------------------
# Java / Maven
# ---------------------------------------------------------------------------
if [[ -f pom.xml ]]; then
echo "ecosystem:java"
jver=$(pom_version pom.xml)
echo "version-file:pom.xml:${jver}"
fi
# ---------------------------------------------------------------------------
# Common files (always check)
# ---------------------------------------------------------------------------
if [[ -f CHANGELOG.md ]]; then
echo "changelog:CHANGELOG.md"
fi
if [[ -f VERSION ]]; then
vver=$(head -1 VERSION 2>/dev/null | tr -d '[:space:]')
echo "version-file:VERSION:${vver}"
fi
#!/usr/bin/env python3
"""
PreToolUse hook that blocks dangerous GitHub release operations.
Blocks:
- gh release create/delete (always)
- gh release edit (unless only --notes or --notes-file flags are used)
- gh api calls to release endpoints with mutating HTTP methods
Allows:
- gh release view/list/download (read-only)
- gh release edit --notes "..." (release description overhaul)
- gh release edit --notes-file ... (release description overhaul)
- gh run commands (workflow management)
- Any non-release gh commands
Exit codes:
0 = allow the command
2 = block the command
"""
import json
import re
import sys
def parse_command(input_data: str) -> str:
"""Extract the command string from hook input (JSON via stdin)."""
if not input_data:
return ""
try:
data = json.loads(input_data)
return data.get("command", "")
except (json.JSONDecodeError, TypeError):
return input_data
def block(reason: str, suggestion: str) -> None:
"""Print a YAML-formatted block reason to stderr and exit 2."""
print(
f"""---
blocked: true
reason: |
{reason}
suggestion: |
{suggestion}
---""",
file=sys.stderr,
)
sys.exit(2)
# Regex that matches "gh release <subcommand>" with flexible whitespace.
# We capture the subcommand to decide allow vs block.
GH_RELEASE_RE = re.compile(
r"""
(?:^|[;&|]\s*|&&\s*|\|\|\s*) # start of string or command separator
gh\s+release\s+(\S+) # gh release <subcommand>
""",
re.VERBOSE,
)
# Read-only subcommands that are safe.
ALLOWED_RELEASE_SUBCOMMANDS = {"view", "list", "download"}
# gh api calls to release endpoints with mutating methods.
# Matches patterns like:
# gh api repos/owner/repo/releases -X POST
# gh api /repos/owner/repo/releases --method DELETE
# gh api repos/owner/repo/releases/123 -X PATCH
GH_API_RELEASE_RE = re.compile(
r"""
(?:^|[;&|]\s*|&&\s*|\|\|\s*) # start or separator
gh\s+api\s+ # gh api
(?:(?:-\w+|--\w[\w-]*)(?:\s+(?:"[^"]*"|'[^']*'|\S+))?\s+)* # optional flags (e.g. -X POST, -H "...")
/?repos/[^\s]+/releases # release endpoint path
""",
re.VERBOSE,
)
MUTATING_METHOD_RE = re.compile(
r"""
(?:-X|--method)\s*(POST|PUT|PATCH|DELETE)
""",
re.VERBOSE | re.IGNORECASE,
)
# Flags for gh release edit that modify metadata other than notes.
# See: gh release edit --help
# Uses \b word boundaries to avoid prefix collisions with future flags.
_DANGEROUS_EDIT_FLAGS = re.compile(
r"""
(?:^|\s)
(?:
--draft\b
|--prerelease\b
|--latest\b
|--tag\b
|--target\b
|--title\b
|-t\b
|--discussion-category\b
|--verify-tag\b
)
""",
re.VERBOSE,
)
def _is_notes_only_edit(args: str) -> bool:
"""Return True if gh release edit args only modify notes."""
# Truncate at shell separators so chained commands don't pollute the check.
# e.g. "v1.0.0 --notes '...' ; other-cmd --draft" → "v1.0.0 --notes '...'"
args = re.split(r"\s*(?:;|&&|\|\|)\s*", args)[0]
# Strip quoted strings to avoid false positives from notes content.
# e.g. --notes "Changed --draft behavior" should not trigger --draft block.
clean_args = re.sub(r'"[^"]*"|\'[^\']*\'', "", args)
has_notes = bool(
re.search(r"(?:^|\s)(?:--notes\b|--notes-file\b|-n\b|-F\b)", clean_args)
)
has_dangerous = bool(_DANGEROUS_EDIT_FLAGS.search(clean_args))
return has_notes and not has_dangerous
def check_command(command: str) -> None:
"""Check the command and block if it is a dangerous release operation."""
# Normalise for easier matching (collapse multiple spaces).
cmd = " ".join(command.split())
# --- Check gh release <subcommand> ---
for match in GH_RELEASE_RE.finditer(cmd):
subcommand = match.group(1).lower()
if subcommand in ALLOWED_RELEASE_SUBCOMMANDS:
continue
if subcommand == "create":
block(
"Direct 'gh release create' bypasses the CI release pipeline. "
"Releases MUST be created by the CI/CD workflow to ensure "
"proper provenance, signing, and artifact generation.",
"Push a version tag (git tag -s vX.Y.Z && git push origin vX.Y.Z) "
"to trigger the release workflow, or run the release workflow "
"manually via 'gh workflow run'.",
)
elif subcommand == "delete":
block(
"Deleting a GitHub release is a destructive, irreversible operation. "
"Published releases are immutable artifacts that downstream consumers "
"may depend on.",
"If a release contains a critical defect, create a new patch release "
"instead (vX.Y.Z+1). If you must deprecate a release, edit its notes "
"to mark it as deprecated via the CI pipeline.",
)
elif subcommand == "edit":
# Allow notes-only edits for release description overhaul.
# Extract the portion of the command after "gh release edit".
edit_args = cmd[match.end() :]
if _is_notes_only_edit(edit_args):
continue
block(
"Editing a GitHub release outside of notes overhaul bypasses audit "
"controls. Only --notes and --notes-file are permitted.",
"Use 'gh release edit vX.Y.Z --notes \"...\"' to overhaul the "
"release description. Other release metadata should be managed "
"through the CI release workflow.",
)
else:
# Unknown subcommand -- block to be safe.
block(
f"Unknown 'gh release {subcommand}' subcommand. Only read-only "
f"operations (view, list, download) are permitted outside CI.",
"Use 'gh release view' or 'gh release list' for read-only access. "
"All mutating release operations must go through the CI pipeline.",
)
# --- Check gh api calls to release endpoints ---
if GH_API_RELEASE_RE.search(cmd):
# If no explicit method flag, gh api defaults to GET for bare calls,
# but POST when -f/--field or --input is present. We block if a
# mutating method is specified OR if data-sending flags are present.
has_mutating_method = MUTATING_METHOD_RE.search(cmd)
has_data_flags = re.search(r"\s(-f|--field|-F|--json-field|--input)\s", cmd)
if has_mutating_method or has_data_flags:
method = ""
if has_mutating_method:
method = has_mutating_method.group(1).upper()
block(
f"Direct API call to release endpoint{' with ' + method + ' method' if method else ''} "
f"bypasses the CI release pipeline. All mutating operations on "
f"releases must go through CI.",
"Use the CI release workflow to create or modify releases. "
"For read-only queries, use 'gh api' without mutating methods "
"or data flags.",
)
def main() -> None:
try:
input_data = sys.stdin.read()
except Exception:
sys.exit(0)
command = parse_command(input_data)
if not command:
sys.exit(0)
# Quick pre-check: skip if command does not mention gh at all.
if "gh" not in command.lower():
sys.exit(0)
check_command(command)
# If we get here, the command is allowed.
sys.exit(0)
if __name__ == "__main__":
main()
#!/usr/bin/env bash
# Pre-push hook: reject lightweight version tags
while read local_ref local_sha remote_ref remote_sha; do
if [[ "$remote_ref" == refs/tags/v* ]]; then
tag="${remote_ref#refs/tags/}"
obj_type=$(git cat-file -t "$tag" 2>/dev/null)
if [[ "$obj_type" != "tag" ]]; then
echo "ERROR: Tag $tag is lightweight (unsigned)."
echo "Use: git tag -s $tag -m \"$tag\""
exit 1
fi
fi
done