diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c744f0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The semrel Authors + +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{html,tmpl,py,go}] +indent_style = tab +indent_size = 4 + +[*.{yml,yaml,toml,json,css,less,js,vue,rb,nix}] +indent_style = space +indent_size = 2 + +[*.sh] +indent_style = space +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6a887e0..9c10fe9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,11 +1,11 @@ name: Bug Report -description: Report a bug in {{PROJECT_NAME}} +description: Report a bug in updater-python labels: ["type: bug", "needs triage"] body: - type: markdown attributes: value: | - Thank you for reporting a bug. For security vulnerabilities, use private reporting via {{SECURITY_ADVISORY_URL}}. + Thank you for reporting a bug. For security vulnerabilities, use private reporting via https://github.com/SemRels/updater-python/security/advisories/new. - type: input id: version attributes: diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 21be3c6..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: 2026 The plugin-template Authors - -version: 2 -updates: - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - - package-ecosystem: gomod - directory: "/" - schedule: - interval: weekly diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..fa68310 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>SemRels/.github//renovate/preset" + ] +} diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..9486683 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,136 @@ +--- +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The semrel Authors +# yaml-language-server: $schema=http://json-schema.org/draft-07/schema# +repository: + name: updater-python + description: Semrel updater plugin that updates the Python package version for the selected packaging backend + topics: semrel, semrel-plugin, updater, python + + private: false + has_issues: true + has_wiki: false + has_downloads: false + + default_branch: main + + allow_merge_commit: false + allow_squash_merge: true + allow_rebase_merge: true + + allow_update_branch: true + allow_auto_merge: true + delete_branch_on_merge: true + enable_automated_security_fixes: true + enable_vulnerability_alerts: true + +rulesets: + - name: prevent destruction + target: branch + enforcement: active + conditions: + ref_name: + include: + - "~DEFAULT_BRANCH" + exclude: [] + rules: + - type: deletion + - type: non_fast_forward + + - name: check verification + target: branch + enforcement: active + conditions: + ref_name: + include: + - "~DEFAULT_BRANCH" + exclude: [] + rules: + - type: required_status_checks + parameters: + strict_required_status_checks_policy: true + required_status_checks: + - context: "Build, test, lint, and scan" + integration_id: 15368 + - context: "Build and Release Containers" + integration_id: 15368 + bypass_actors: + - actor_id: 1 + actor_type: OrganizationAdmin + bypass_mode: always + + - name: require reviewing + target: branch + enforcement: active + conditions: + ref_name: + include: + - "~DEFAULT_BRANCH" + exclude: [] + rules: + - type: pull_request + parameters: + allowed_merge_methods: + - squash + - rebase + dismiss_stale_reviews_on_push: false + require_code_owner_review: false + require_last_push_approval: false + required_approving_review_count: 1 + required_review_thread_resolution: false + bypass_actors: + - actor_id: 1 + actor_type: OrganizationAdmin + bypass_mode: always + +labels: + - name: bug + color: "fc2929" + description: Something isn't working + - name: dependencies + color: "0366d6" + description: Pull requests that update a dependency file + - name: documentation + color: "0075ca" + description: Improvements or additions to documentation + - name: duplicate + color: "cfd3d7" + description: This issue or pull request already exists + - name: enhancement + color: "a2eeef" + description: New feature or request + - name: github_actions + color: "000000" + description: Pull requests that update GitHub Actions code + - name: go + color: "16e2e2" + description: Pull requests that update go code + - name: good first issue + color: "7057ff" + description: Good for newcomers + - name: help wanted + color: "008672" + description: Extra attention is needed + - name: implementation + color: "5319e7" + description: Implementation task + - name: invalid + color: "e4e669" + description: This doesn't seem right + - name: mvp + color: "fbca04" + description: Minimum viable plugin delivery + - name: plugin + color: "0e8a16" + description: Plugin-related work + - name: question + color: "d876e3" + description: Further information is requested + - name: wontfix + color: "ffffff" + description: This will not be worked on + - name: renovate + color: "1d76db" + description: Automated action from Renovate + +... diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index d677f9f..571fe94 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,26 +1,28 @@ -# This code has been fully generated by an AI. +--- # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: 2026 The semrel Authors +# This code has been fully generated by an AI. +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json name: Automatically label pull requests on: pull_request_target: - types: [opened, reopened, synchronize, edited] + types: + - opened + - reopened + - synchronize + - edited permissions: issues: write pull-requests: write -concurrency: - group: auto-label-${{ github.event.pull_request.number }} - cancel-in-progress: true - jobs: label: runs-on: ubuntu-latest steps: - name: Apply labels based on the title and changed files - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const pullRequest = context.payload.pull_request; @@ -56,4 +58,6 @@ jobs: issue_number: pullRequest.number, labels: missing, }); - } \ No newline at end of file + } + +... diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml new file mode 100644 index 0000000..821d955 --- /dev/null +++ b/.github/workflows/binaries.yml @@ -0,0 +1,55 @@ +--- +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The semrel Authors +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +name: Binaries + +on: + workflow_dispatch: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + packages: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + binaries: + name: Build and Release Binaries + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools with Mise + uses: jdx/mise-action@8418fedb1110381a0d3ba51a7a415d7c1b884550 # v5 + + - name: Build release binary + run: task build:all + + - name: Generate checksum manifest + run: task checksums + + - name: Upload GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 + with: + name: ${{ github.ref_name }} + files: dist/* + generate_release_notes: false + + - name: Trigger registry sync + if: startsWith(github.ref, 'refs/tags/') + env: + REGISTRY_SYNC_TOKEN: ${{ secrets.REGISTRY_SYNC_TOKEN }} + REGISTRY_SYNC_WEBHOOK: ${{ secrets.REGISTRY_SYNC_WEBHOOK }} + PLUGIN_TAG: ${{ github.ref_name }} + run: task sync:registry + +... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7016de5..020cc5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,16 @@ +--- +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The semrel Authors +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json name: CI on: push: - branches: [main] + branches: + - main pull_request: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + branches: + - main permissions: contents: read @@ -17,48 +19,38 @@ jobs: ci: name: Build, test, lint, and scan runs-on: ubuntu-latest - continue-on-error: ${{ matrix.go == '1.27rc1' }} - strategy: - fail-fast: false - matrix: - go: ["1.25", "1.26", "1.27"] steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: ${{ matrix.go == '1.27rc1' && '1.27.0-rc.1' || format('{0}.x', matrix.go) }} - allow-prereleases: true - cache: true + - name: Install tools with Mise + uses: jdx/mise-action@8418fedb1110381a0d3ba51a7a415d7c1b884550 # v5 - name: Download dependencies run: go mod download - name: Run golangci-lint - if: matrix.go != '1.27' - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 - with: - version: v2.12.2 + run: task lint - name: Run tests - run: go test -v -coverprofile=coverage.txt ./... + run: task test - name: Show coverage summary - run: go tool cover -func=coverage.txt | tail -1 + run: go tool cover -func=coverage.out | tail -1 - name: Upload coverage report - if: matrix.go == '1.26' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: coverage-report - path: coverage.txt + path: coverage.out retention-days: 30 - name: Build plugin binary - run: go build -v ./cmd/plugin + run: task build + + - name: Run end-to-end tests + run: task e2e - name: Run Trivy filesystem scan uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 @@ -69,3 +61,5 @@ jobs: exit-code: '1' ignore-unfixed: true severity: CRITICAL,HIGH + +... diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000..6945262 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,126 @@ +--- +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The semrel Authors +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +name: Container + +on: + workflow_dispatch: + push: + branches: + - main + tags: + - 'v*.*.*' + pull_request: + branches: + - main + +permissions: + contents: read + packages: write + id-token: write + attestations: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + container: + name: Build and Release Containers + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install tools with Mise + uses: jdx/mise-action@8418fedb1110381a0d3ba51a7a415d7c1b884550 # v5 + + - name: Set version metadata + id: version + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + ver="${GITHUB_REF_NAME}" + else + ver="${{ github.event.inputs.version || 'dev' }}" + fi + echo "version=${ver}" >> "$GITHUB_OUTPUT" + echo "version_short=${ver#v}" >> "$GITHUB_OUTPUT" + + - name: Set lowercase repository name + id: repo + run: | + echo "lower=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + images: ghcr.io/${{ steps.repo.outputs.lower }} + labels: | + org.opencontainers.image.licenses=Apache-2.0 + org.opencontainers.image.title=semrel-plugin-updater-python + org.opencontainers.image.url=https://semrel.io + org.opencontainers.image.vendor=SemRels + org.opencontainers.image.version=${{ steps.version.outputs.version_short }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=${{ steps.version.outputs.version_short }},enable=true + + - name: Set up Docker QEMU + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multiarch image + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true + build-args: | + VERSION=${{ steps.version.outputs.version }} + + - name: Sign images with cosign + if: github.event_name != 'pull_request' + env: + COSIGN_EXPERIMENTAL: "1" + run: | + echo "${{ steps.meta.outputs.tags }}" | while read -r TAG; do + cosign sign --yes ghcr.io/${{ steps.repo.outputs.lower }}@${{ steps.build.outputs.digest }} + done + + - name: Run Trivy vulnerability scanner + if: github.event_name != 'pull_request' + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ghcr.io/${{ steps.repo.outputs.lower }}:${{ steps.version.outputs.version_short }} + format: table + exit-code: '1' + ignore-unfixed: true + severity: CRITICAL,HIGH + +... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a169b24..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,736 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*.*.*' - workflow_dispatch: - inputs: - version: - description: Release version (SemVer 2.0, optional v; may be omitted on a tag ref) - required: false - type: string - -permissions: - contents: read - -jobs: - version: - name: Validate release version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - version_short: ${{ steps.version.outputs.version_short }} - image_version: ${{ steps.version.outputs.image_version }} - exact_key: ${{ steps.version.outputs.exact_key }} - tag: ${{ steps.version.outputs.tag }} - stable: ${{ steps.version.outputs.stable }} - image: ${{ steps.version.outputs.image }} - build_date: ${{ steps.source.outputs.build_date }} - source_sha: ${{ steps.source.outputs.source_sha }} - steps: - - name: Resolve and validate version - id: version - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - REF_NAME: ${{ github.ref_name }} - REF_TYPE: ${{ github.ref_type }} - run: | - set -euo pipefail - if [[ "${EVENT_NAME}" == "push" ]]; then - candidate="${REF_NAME}" - elif [[ -n "${INPUT_VERSION}" ]]; then - candidate="${INPUT_VERSION}" - elif [[ "${REF_TYPE}" == "tag" ]]; then - candidate="${REF_NAME}" - else - printf 'A version is required unless workflow_dispatch uses a tag ref.\n' >&2 - exit 1 - fi - - if [[ "${candidate}" == *$'\r'* || "${candidate}" == *$'\n'* ]]; then - printf 'Version must not contain CR or LF characters.\n' >&2 - exit 1 - fi - - semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' - if [[ ! "${candidate}" =~ ${semver_re} ]]; then - printf 'Version must be valid SemVer 2.0 with an optional leading v.\n' >&2 - exit 1 - fi - - version_short="${candidate#v}" - without_build="${version_short%%+*}" - if [[ "${without_build}" == *-* ]]; then - prerelease="${without_build#*-}" - IFS='.' read -r -a identifiers <<< "${prerelease}" - for identifier in "${identifiers[@]}"; do - if [[ "${identifier}" =~ ^0[0-9]+$ ]]; then - printf 'Numeric prerelease identifiers must not contain leading zeros.\n' >&2 - exit 1 - fi - done - fi - - stable=true - if [[ "${without_build}" == *-* ]]; then - stable=false - fi - { - printf 'version=v%s\n' "${version_short}" - printf 'version_short=%s\n' "${version_short}" - # SemVer forbids underscores, so replacing '+' is collision-free and Docker-safe. - printf 'image_version=%s\n' "${version_short//+/_}" - # Actions concurrency groups are case-insensitive; hash the case-sensitive exact SemVer. - printf 'exact_key=%s\n' "$(printf '%s' "${version_short}" | sha256sum | cut -d' ' -f1)" - printf 'tag=v%s\n' "${version_short}" - printf 'stable=%s\n' "${stable}" - printf 'image=ghcr.io/%s\n' "${GITHUB_REPOSITORY,,}" - } >> "${GITHUB_OUTPUT}" - - - name: Check out exact release source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ steps.version.outputs.tag }} - - - name: Resolve reproducible source metadata - id: source - shell: bash - run: | - set -euo pipefail - source_sha=$(git rev-parse HEAD) - source_epoch=$(git show -s --format=%ct HEAD) - [[ "${source_sha}" =~ ^[0-9a-f]{40}$ ]] - [[ "${source_epoch}" =~ ^[0-9]+$ ]] - { - printf 'source_sha=%s\n' "${source_sha}" - printf 'build_date=%s\n' "$(date -u -d "@${source_epoch}" +%Y-%m-%dT%H:%M:%SZ)" - } >> "${GITHUB_OUTPUT}" - - build: - name: Build ${{ matrix.goos }}-${{ matrix.goarch }} - needs: version - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - goos: linux - goarch: amd64 - ext: '' - - goos: linux - goarch: arm64 - ext: '' - - goos: darwin - goarch: amd64 - ext: '' - - goos: darwin - goarch: arm64 - ext: '' - - goos: windows - goarch: amd64 - ext: '.exe' - - goos: windows - goarch: arm64 - ext: '.exe' - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.version.outputs.source_sha }} - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - cache: true - - - name: Build release binary - shell: bash - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: '0' - run: | - set -euo pipefail - mkdir -p dist - binary="plugin-${GOOS}-${GOARCH}${{ matrix.ext }}" - go build -trimpath -ldflags='-s -w' -o "dist/${binary}" ./cmd/plugin - (cd dist && sha256sum "${binary}" > "${binary}.sha256") - - - name: Upload release artifacts - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-${{ matrix.goos }}-${{ matrix.goarch }} - path: dist/* - if-no-files-found: error - - docker-validate: - name: Validate image (${{ matrix.platform.arch }}) - needs: version - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - platform: - - name: linux/amd64 - arch: amd64 - - name: linux/arm64 - arch: arm64 - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.version.outputs.source_sha }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - with: - platforms: arm64 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - - name: Build immutable OCI validation archive - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - platforms: ${{ matrix.platform.name }} - outputs: type=oci,dest=${{ runner.temp }}/plugin-image-${{ matrix.platform.arch }}.tar - tags: validation - labels: | - org.opencontainers.image.created=${{ needs.version.outputs.build_date }} - org.opencontainers.image.revision=${{ needs.version.outputs.source_sha }} - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.version=${{ needs.version.outputs.version }} - build-args: | - VERSION=${{ needs.version.outputs.version }} - BUILD_DATE=${{ needs.version.outputs.build_date }} - VCS_REF=${{ needs.version.outputs.source_sha }} - cache-from: type=gha,scope=validation-${{ matrix.platform.arch }} - cache-to: type=gha,mode=max,scope=validation-${{ matrix.platform.arch }} - provenance: true - sbom: true - - - name: Record validation archive checksum - shell: bash - env: - ARCH: ${{ matrix.platform.arch }} - run: | - set -euo pipefail - cd "${RUNNER_TEMP}" - sha256sum "plugin-image-${ARCH}.tar" > "plugin-image-${ARCH}.tar.sha256" - - - name: Extract immutable OCI layout for scanning - id: extract - shell: bash - env: - ARCH: ${{ matrix.platform.arch }} - run: | - set -euo pipefail - scan_dir=$(mktemp -d "${RUNNER_TEMP}/plugin-image-${ARCH}-layout.XXXXXX") - tar -xf "${RUNNER_TEMP}/plugin-image-${ARCH}.tar" -C "${scan_dir}" - test -f "${scan_dir}/index.json" - test -f "${scan_dir}/oci-layout" - printf 'path=%s\n' "${scan_dir}" >> "${GITHUB_OUTPUT}" - - - name: Scan immutable extracted OCI layout - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - env: - TRIVY_PLATFORM: ${{ matrix.platform.name }} - with: - input: ${{ steps.extract.outputs.path }} - format: table - exit-code: '1' - ignore-unfixed: true - severity: CRITICAL,HIGH - - - name: Upload scanned OCI validation archive - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: plugin-image-${{ matrix.platform.arch }} - path: | - ${{ runner.temp }}/plugin-image-${{ matrix.platform.arch }}.tar - ${{ runner.temp }}/plugin-image-${{ matrix.platform.arch }}.tar.sha256 - if-no-files-found: error - compression-level: 0 - retention-days: 1 - - docker: - name: Publish validated multiarch image - needs: [version, build, docker-validate] - runs-on: ubuntu-latest - concurrency: - group: exact-image-${{ needs.version.outputs.exact_key }} - cancel-in-progress: false - permissions: - contents: read - packages: write - id-token: write - attestations: write - steps: - - name: Download scanned OCI validation archives - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: plugin-image-* - path: ${{ runner.temp }}/docker-images - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - - name: Install ORAS - uses: oras-project/setup-oras@005458ad77f1c8facd38a094e4af2e69e5607ff4 # v2.0.2 - with: - version: 1.3.1 - - - name: Install cosign - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Calculate final image tags - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ${{ needs.version.outputs.image }} - flavor: latest=false - tags: | - type=raw,value=${{ needs.version.outputs.image_version }} - - - name: Promote scanned platform images - id: promote - shell: bash - env: - ARCHIVE_DIR: ${{ runner.temp }}/docker-images - IMAGE: ${{ needs.version.outputs.image }} - run: | - set -euo pipefail - cd "${ARCHIVE_DIR}" - test "$(find . -maxdepth 1 -type f -name 'plugin-image-*.tar' | wc -l)" -eq 2 - test "$(find . -maxdepth 1 -type f -name 'plugin-image-*.tar.sha256' | wc -l)" -eq 2 - sha256sum --check -- plugin-image-*.tar.sha256 - for arch in amd64 arm64; do - archive="plugin-image-${arch}.tar" - layout_dir=$(mktemp -d) - tar -xf "${archive}" -C "${layout_dir}" - # Read the layout's sole top-level entry directly from index.json - # instead of resolving it by tag through oras. Docker silently - # rewrites a bare --tag like "validation" to "validation:latest" - # and only records the tag component ("latest") as the OCI ref - # annotation, which made oras' ":" ref parsing unable - # to find a match — jq sidesteps that ambiguity entirely. - source_digest=$(jq -r '.manifests[0].digest' "${layout_dir}/index.json") - [[ "${source_digest}" =~ ^sha256:[0-9a-f]{64}$ ]] - platform_digest=$(jq -r --arg arch "${arch}" \ - '.manifests[] | select(.platform.architecture == $arch and .platform.os == "linux") | .digest' \ - "${layout_dir}/blobs/sha256/${source_digest#sha256:}") - [[ "${platform_digest}" =~ ^sha256:[0-9a-f]{64}$ ]] - # Registry deletion is manifest-scoped, so retain this run-scoped tag to protect the final index children. - staging_tag="scan-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${arch}" - oras cp --from-oci-layout "${layout_dir}@${source_digest}" "${IMAGE}:${staging_tag}" - pushed_digest=$(oras resolve "${IMAGE}:${staging_tag}") - pushed_platform_digest=$(oras resolve --platform "linux/${arch}" "${IMAGE}:${staging_tag}") - [[ "${pushed_digest}" == "${source_digest}" ]] - [[ "${pushed_platform_digest}" == "${platform_digest}" ]] - printf '%s=%s\n' "${arch}" "${pushed_digest}" >> "${GITHUB_OUTPUT}" - printf '%s_image=%s\n' "${arch}" "${platform_digest}" >> "${GITHUB_OUTPUT}" - done - - - name: Create final multiarch manifest from scanned digests - id: manifest - shell: bash - env: - AMD64_DIGEST: ${{ steps.promote.outputs.amd64 }} - AMD64_IMAGE_DIGEST: ${{ steps.promote.outputs.amd64_image }} - ARM64_DIGEST: ${{ steps.promote.outputs.arm64 }} - ARM64_IMAGE_DIGEST: ${{ steps.promote.outputs.arm64_image }} - IMAGE: ${{ needs.version.outputs.image }} - SOURCE_SHA: ${{ needs.version.outputs.source_sha }} - TAGS: ${{ steps.meta.outputs.tags }} - VERSION: ${{ needs.version.outputs.version }} - run: | - set -euo pipefail - resolve_registry_ref() { - local ref=$1 - local attempts=4 - local attempt output status - for ((attempt = 1; attempt <= attempts; attempt++)); do - if output=$(oras resolve "${ref}" 2>&1); then - if [[ ! "${output}" =~ ^sha256:[0-9a-f]{64}$ ]]; then - printf 'Registry returned an invalid digest for %s: %s\n' "${ref}" "${output}" >&2 - return 1 - fi - printf '%s\n' "${output}" - return 0 - else - status=$? - fi - if ((attempt < attempts)); then - sleep "$((attempt * 2))" - fi - done - if grep -Eiq '(^|[^0-9])(401|403|408|409|425|429|5[0-9][0-9])([^0-9]|$)|unauthorized|denied|forbidden|too many requests|timed? out|connection|tls|eof|server error|service unavailable|temporar' <<< "${output}"; then - printf 'Registry authorization or transient failure resolving %s after %d attempts (exit %d): %s\n' \ - "${ref}" "${attempts}" "${status}" "${output}" >&2 - return 1 - fi - if grep -Eiq '(^|[^0-9])404([^0-9]|$)|manifest[ _]unknown|name[ _]unknown|(^|[^[:alpha:]])not found([^[:alpha:]]|$)' <<< "${output}"; then - return 4 - fi - printf 'Non-definitive registry failure resolving %s after %d attempts (exit %d): %s\n' \ - "${ref}" "${attempts}" "${status}" "${output}" >&2 - return 1 - } - verify_exact_image() { - local ref=$1 - local manifest image_config platform_digest - manifest=$(docker buildx imagetools inspect --raw "${ref}") - jq -e \ - --arg amd64 "${AMD64_IMAGE_DIGEST}" \ - --arg arm64 "${ARM64_IMAGE_DIGEST}" \ - '([.manifests[] | - select(.platform.os == "linux" and - (.platform.architecture == "amd64" or .platform.architecture == "arm64"))] | - length == 2) and - any(.manifests[]; .digest == $amd64 and .platform.os == "linux" and .platform.architecture == "amd64") and - any(.manifests[]; .digest == $arm64 and .platform.os == "linux" and .platform.architecture == "arm64")' \ - <<< "${manifest}" >/dev/null - for platform_digest in "${AMD64_IMAGE_DIGEST}" "${ARM64_IMAGE_DIGEST}"; do - image_config=$(docker buildx imagetools inspect \ - "${IMAGE}@${platform_digest}" --format '{{json .Image}}') - jq -e \ - --arg revision "${SOURCE_SHA}" \ - --arg version "${VERSION}" \ - '.config.Labels["org.opencontainers.image.revision"] == $revision and - .config.Labels["org.opencontainers.image.version"] == $version' \ - <<< "${image_config}" >/dev/null - done - } - [[ "${AMD64_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] - [[ "${AMD64_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] - [[ "${ARM64_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] - [[ "${ARM64_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] - [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]] - mapfile -t final_tags < <(printf '%s\n' "${TAGS}" | sed '/^[[:space:]]*$/d') - ((${#final_tags[@]} == 1)) - exact_tag="${final_tags[0]}" - [[ "${exact_tag}" == "${IMAGE}:"* ]] - if existing_digest=$(resolve_registry_ref "${exact_tag}"); then - verify_exact_image "${exact_tag}" - digest="${existing_digest}" - else - resolve_status=$? - if ((resolve_status != 4)); then - exit "${resolve_status}" - fi - candidate_tag="${IMAGE}:manifest-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - docker buildx imagetools create \ - --tag "${candidate_tag}" \ - "${IMAGE}@${AMD64_DIGEST}" \ - "${IMAGE}@${ARM64_DIGEST}" - if ! digest=$(resolve_registry_ref "${candidate_tag}"); then - printf 'Candidate manifest %s could not be resolved; refusing publication.\n' "${candidate_tag}" >&2 - exit 1 - fi - oras tag "${IMAGE}@${digest}" "${exact_tag#"${IMAGE}:"}" - fi - if ! published_digest=$(resolve_registry_ref "${exact_tag}"); then - printf 'Exact tag %s could not be verified after publication.\n' "${exact_tag}" >&2 - exit 1 - fi - if [[ "${published_digest}" != "${digest}" ]]; then - printf 'Refusing exact-tag conflict for %s (%s != %s).\n' \ - "${exact_tag}" "${published_digest}" "${digest}" >&2 - exit 1 - fi - verify_exact_image "${exact_tag}" - printf 'digest=%s\n' "${digest}" >> "${GITHUB_OUTPUT}" - - - name: Sign published image digest - shell: bash - env: - COSIGN_EXPERIMENTAL: '1' - DIGEST: ${{ steps.manifest.outputs.digest }} - IMAGE: ${{ needs.version.outputs.image }} - run: cosign sign --yes "${IMAGE}@${DIGEST}" - - publish: - name: Publish GitHub release and notify registry - needs: [version, build, docker] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Download release artifacts - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: dist - merge-multiple: true - - - name: Validate artifacts and generate checksum manifest - shell: bash - run: | - set -euo pipefail - test "$(find dist -maxdepth 1 -type f ! -name '*.sha256' | wc -l)" -eq 6 - test "$(find dist -maxdepth 1 -type f -name '*.sha256' | wc -l)" -eq 6 - (cd dist && sha256sum --check -- *.sha256) - ( - cd dist - find . -maxdepth 1 -type f ! -name '*.sha256' ! -name 'checksums.txt' -printf '%f\n' | sort | while read -r file; do - sha256sum "${file}" - done - ) > "${RUNNER_TEMP}/checksums.txt" - mv "${RUNNER_TEMP}/checksums.txt" dist/checksums.txt - - - name: Create GitHub release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 - with: - tag_name: ${{ needs.version.outputs.tag }} - target_commitish: ${{ needs.version.outputs.source_sha }} - files: dist/* - generate_release_notes: true - name: ${{ needs.version.outputs.tag }} - prerelease: ${{ needs.version.outputs.stable != 'true' }} - - - name: Trigger registry sync - shell: bash - env: - REGISTRY_SYNC_TOKEN: ${{ secrets.REGISTRY_SYNC_TOKEN }} - REGISTRY_SYNC_WEBHOOK: ${{ secrets.REGISTRY_SYNC_WEBHOOK }} - PLUGIN_OWNER: ${{ github.repository_owner }} - PLUGIN_REPO: ${{ github.event.repository.name }} - PLUGIN_TAG: ${{ needs.version.outputs.tag }} - run: | - set -euo pipefail - if [[ -n "${REGISTRY_SYNC_TOKEN}" ]]; then - payload=$(printf '{"event_type":"sync-plugins","client_payload":{"source_owner":"%s","source_repo":"%s","tag":"%s"}}' "${PLUGIN_OWNER}" "${PLUGIN_REPO}" "${PLUGIN_TAG}") - curl --fail --silent --show-error \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${REGISTRY_SYNC_TOKEN}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/SemRels/semrel-registry/dispatches \ - -d "${payload}" - elif [[ -n "${REGISTRY_SYNC_WEBHOOK}" ]]; then - payload=$(printf '{"repository":"%s","owner":"%s","tag":"%s"}' "${PLUGIN_REPO}" "${PLUGIN_OWNER}" "${PLUGIN_TAG}") - curl --fail --silent --show-error \ - -X POST \ - -H "Content-Type: application/json" \ - "${REGISTRY_SYNC_WEBHOOK}" \ - -d "${payload}" - else - echo "No registry sync secret configured; skipping dispatch." - fi - - reconcile: - name: Reconcile mutable image tags - needs: [version, docker, publish] - if: needs.version.outputs.stable == 'true' - runs-on: ubuntu-latest - concurrency: - group: mutable-image-tags - cancel-in-progress: false - permissions: - contents: read - packages: write - steps: - - name: Install ORAS - uses: oras-project/setup-oras@005458ad77f1c8facd38a094e4af2e69e5607ff4 # v2.0.2 - with: - version: 1.3.1 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Select globally greatest published stable versions - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMAGE: ${{ needs.version.outputs.image }} - run: | - set -euo pipefail - release_tags="${RUNNER_TEMP}/published-release-tags.txt" - resolved_images="${RUNNER_TEMP}/resolved-exact-images.tsv" - desired="${RUNNER_TEMP}/desired-mutable-tags.tsv" - gh api --paginate --jq \ - '.[] | select(.draft == false and .published_at != null) | .tag_name' \ - "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "${release_tags}" - : > "${resolved_images}" - resolve_registry_ref() { - local ref=$1 - local attempts=4 - local attempt output status - for ((attempt = 1; attempt <= attempts; attempt++)); do - if output=$(oras resolve "${ref}" 2>&1); then - if [[ ! "${output}" =~ ^sha256:[0-9a-f]{64}$ ]]; then - printf 'Registry returned an invalid digest for %s: %s\n' "${ref}" "${output}" >&2 - return 1 - fi - printf '%s\n' "${output}" - return 0 - else - status=$? - fi - if ((attempt < attempts)); then - sleep "$((attempt * 2))" - fi - done - if grep -Eiq '(^|[^0-9])(401|403|408|409|425|429|5[0-9][0-9])([^0-9]|$)|unauthorized|denied|forbidden|too many requests|timed? out|connection|tls|eof|server error|service unavailable|temporar' <<< "${output}"; then - printf 'Registry authorization or transient failure resolving %s after %d attempts (exit %d): %s\n' \ - "${ref}" "${attempts}" "${status}" "${output}" >&2 - return 1 - fi - if grep -Eiq '(^|[^0-9])404([^0-9]|$)|manifest[ _]unknown|name[ _]unknown|(^|[^[:alpha:]])not found([^[:alpha:]]|$)' <<< "${output}"; then - return 4 - fi - printf 'Non-definitive registry failure resolving %s after %d attempts (exit %d): %s\n' \ - "${ref}" "${attempts}" "${status}" "${output}" >&2 - return 1 - } - semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' - while IFS= read -r published_tag; do - [[ "${published_tag}" != *$'\r'* && "${published_tag}" != *$'\n'* ]] || continue - [[ "${published_tag}" =~ ${semver_re} ]] || continue - normalized="${published_tag#v}" - without_build="${normalized%%+*}" - [[ "${without_build}" != *-* ]] || continue - exact="${normalized//+/_}" - if digest=$(resolve_registry_ref "${IMAGE}:${exact}"); then - printf '%s\t%s\n' "${exact}" "${digest}" >> "${resolved_images}" - else - resolve_status=$? - if ((resolve_status != 4)); then - printf 'Aborting reconcile before alias mutation because %s:%s was not definitively absent.\n' \ - "${IMAGE}" "${exact}" >&2 - exit "${resolve_status}" - fi - fi - done < "${release_tags}" - python3 - "${release_tags}" "${resolved_images}" "${desired}" <<'PY' - import re - import sys - from collections import defaultdict - - release_path, resolved_path, output_path = sys.argv[1:] - semver = re.compile( - r"^v?(?P0|[1-9][0-9]*)\." - r"(?P0|[1-9][0-9]*)\." - r"(?P0|[1-9][0-9]*)" - r"(?:-(?P
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
-              r"(?:\+(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
-          )
-          image_digests = {}
-          for raw in open(resolved_path, encoding="utf-8"):
-              exact, digest = raw.rstrip("\n").split("\t")
-              if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
-                  raise SystemExit(f"invalid resolved digest for {exact}")
-              image_digests[exact] = digest
-          candidates = {}
-          for raw in open(release_path, encoding="utf-8"):
-              raw = raw.rstrip("\n")
-              match = semver.fullmatch(raw)
-              if not match:
-                  continue
-              prerelease = match.group("pre")
-              if prerelease:
-                  if any(part.isdigit() and len(part) > 1 and part[0] == "0"
-                         for part in prerelease.split(".")):
-                      continue
-                  continue
-              normalized = raw[1:] if raw.startswith("v") else raw
-              exact = normalized.replace("+", "_")
-              if exact not in image_digests:
-                  continue
-              candidates[normalized] = (
-                  int(match.group("major")),
-                  int(match.group("minor")),
-                  int(match.group("patch")),
-                  normalized,
-                  exact,
-                  image_digests[exact],
-              )
-
-          if not candidates:
-              raise SystemExit("no published stable SemVer release has an exact image tag")
-
-          # Build metadata is absent from the precedence tuple. The normalized
-          # full version is the stable ASCII tie-break for equal precedence.
-          def winner(values):
-              return max(values, key=lambda item: (item[0], item[1], item[2], item[3]))
-
-          values = list(candidates.values())
-          by_major = defaultdict(list)
-          by_minor = defaultdict(list)
-          for item in values:
-              by_major[item[0]].append(item)
-              by_minor[(item[0], item[1])].append(item)
-
-          desired = [("latest", winner(values))]
-          desired.extend(
-              (str(major), winner(group))
-              for major, group in sorted(by_major.items())
-          )
-          desired.extend(
-              (f"{major}.{minor}", winner(group))
-              for (major, minor), group in sorted(by_minor.items())
-          )
-          with open(output_path, "w", encoding="utf-8", newline="\n") as output:
-              for alias, item in desired:
-                  output.write(f"{alias}\t{item[4]}\t{item[3]}\t{item[5]}\n")
-          PY
-          test -s "${desired}"
-
-      - name: Point mutable tags at selected immutable digests
-        shell: bash
-        env:
-          IMAGE: ${{ needs.version.outputs.image }}
-        run: |
-          set -euo pipefail
-          resolve_registry_ref() {
-            local ref=$1
-            local attempts=4
-            local attempt output status
-            for ((attempt = 1; attempt <= attempts; attempt++)); do
-              if output=$(oras resolve "${ref}" 2>&1); then
-                if [[ ! "${output}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
-                  printf 'Registry returned an invalid digest for %s: %s\n' "${ref}" "${output}" >&2
-                  return 1
-                fi
-                printf '%s\n' "${output}"
-                return 0
-              else
-                status=$?
-              fi
-              if ((attempt < attempts)); then
-                sleep "$((attempt * 2))"
-              fi
-            done
-            printf 'Unable to verify mutable tag %s after %d attempts (exit %d): %s\n' \
-              "${ref}" "${attempts}" "${status}" "${output}" >&2
-            return 1
-          }
-          desired="${RUNNER_TEMP}/desired-mutable-tags.tsv"
-          while IFS=$'\t' read -r alias exact version digest; do
-            [[ "${alias}" == "latest" || "${alias}" =~ ^(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ ]]
-            [[ "${exact}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(_[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]
-            [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]
-            oras tag "${IMAGE}@${digest}" "${alias}"
-            if ! alias_digest=$(resolve_registry_ref "${IMAGE}:${alias}"); then
-              exit 1
-            fi
-            [[ "${alias_digest}" == "${digest}" ]]
-            printf '%s -> %s (%s)\n' "${alias}" "${exact}" "${version}" >> "${GITHUB_STEP_SUMMARY}"
-          done < "${desired}"
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 96d7c08..fb1f0ba 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -1,37 +1,34 @@
+---
 # SPDX-License-Identifier: Apache-2.0
 # SPDX-FileCopyrightText: 2026 The semrel Authors
-
+# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json
 name: Scorecard
 
 on:
+  workflow_dispatch:
   push:
-    branches: [main]
+    branches:
+      - main
   schedule:
     - cron: "30 1 * * 1"
-  workflow_dispatch:
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
 
 permissions:
+  security-events: write
+  id-token: write
   contents: read
+  actions: read
 
 jobs:
   analysis:
-    name: Scorecard analysis
+    name: Scorecard Analysis
     runs-on: ubuntu-latest
-    permissions:
-      security-events: write
-      id-token: write
-      contents: read
-      actions: read
+
     steps:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
         with:
           persist-credentials: false
 
-      - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
+      - uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
         with:
           results_file: results.sarif
           results_format: sarif
@@ -43,6 +40,8 @@ jobs:
           path: results.sarif
           retention-days: 5
 
-      - uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
+      - uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
         with:
           sarif_file: results.sarif
+
+...
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index b91b439..a2be0d4 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -1,36 +1,46 @@
+---
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The semrel Authors
+# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json
 name: Security
 
 on:
-  pull_request:
-    branches: [main]
   push:
-    branches: [main]
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
   schedule:
     - cron: '0 4 * * 1'
 
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: true
-
 permissions:
   contents: read
 
 jobs:
   dependency-review:
     if: ${{ github.event_name == 'pull_request' }}
-    name: Dependency review
+    name: Dependency Review
     runs-on: ubuntu-latest
+
     steps:
       - name: Check out repository
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
       - name: Review dependency changes
-        uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+        uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5
+        with:
+          warn-only: true
 
   reuse:
-    name: REUSE compliance
+    name: REUSE Compliance
     runs-on: ubuntu-latest
+
     steps:
       - name: Check out repository
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
       - name: Run REUSE check
-        uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0
+        uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6
+
+...
diff --git a/.github/workflows/semrel-release.yaml b/.github/workflows/semrel-release.yaml
deleted file mode 100644
index 06ae3d7..0000000
--- a/.github/workflows/semrel-release.yaml
+++ /dev/null
@@ -1,150 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: 2026 The semrel Authors
-#
-# semrel-release — uses semrel to version this plugin.
-# Flow: push to main → semrel creates tag → release.yml validates and publishes it.
-name: Semrel Release
-
-on:
-  push:
-    branches: [main]
-  workflow_dispatch:
-
-concurrency:
-  # Serialize version calculation only. Exact release.yml runs have no shared
-  # concurrency group, so a dispatched version can never occupy this slot.
-  group: semrel-orchestrator-${{ github.ref }}
-  cancel-in-progress: false
-
-permissions:
-  contents: write
-  actions: write
-
-jobs:
-  release:
-    name: Run semrel
-    runs-on: ubuntu-latest
-    outputs:
-      released: ${{ steps.semrel.outputs.released }}
-      tag: ${{ steps.semrel.outputs.tag }}
-    steps:
-      - name: Check out repository
-        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-        with:
-          fetch-depth: 0
-          token: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Set up Go
-        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
-        with:
-          go-version: stable
-          cache: false
-
-      - name: Download semrel binary
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run: |
-          set -euo pipefail
-          version=$(gh release view --repo SemRels/semrel --json tagName -q .tagName)
-          gh release download "${version}" --repo SemRels/semrel \
-            --pattern "semrel_*_linux_amd64.tar.gz" \
-            --pattern "semrel-*-linux-amd64.tar.gz" --dir "${RUNNER_TEMP}"
-          archive=$(find "${RUNNER_TEMP}" -maxdepth 1 -type f -name "*.tar.gz" -print -quit)
-          test -n "$archive"
-          tar -xzf "$archive" -C "${RUNNER_TEMP}"
-          chmod +x "${RUNNER_TEMP}/semrel"
-          "${RUNNER_TEMP}/semrel" --version
-
-      - name: Install plugins from source
-        run: |
-          set -euo pipefail
-          plugin_dir="${HOME}/.semrel/plugins"
-          mkdir -p "${plugin_dir}"
-          go install github.com/SemRels/condition-github-actions/cmd/plugin@v0.2.2
-          mv "$(go env GOPATH)/bin/plugin" "${plugin_dir}/semrel-plugin-condition-github-actions"
-
-      - name: Configure git identity
-        run: |
-          git config user.name "semrel-bot"
-          git config user.email "semrel-bot@users.noreply.github.com"
-
-      - name: Run semrel release
-        id: semrel
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run: "${RUNNER_TEMP}/semrel release --github-output"
-
-      - name: Push tag to GitHub
-        if: steps.semrel.outputs.released == 'true'
-        env:
-          TAG: ${{ steps.semrel.outputs.tag }}
-        run: |
-          set -euo pipefail
-          if [[ "${TAG}" == *$'\r'* || "${TAG}" == *$'\n'* ]]; then
-            printf 'Tag must not contain CR or LF characters.\n' >&2
-            exit 1
-          fi
-          semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
-          [[ "${TAG}" =~ ${semver_re} ]] || {
-            printf 'semrel returned an invalid SemVer tag: %s\n' "${TAG}" >&2
-            exit 1
-          }
-          without_build="${TAG#v}"
-          without_build="${without_build%%+*}"
-          if [[ "${without_build}" == *-* ]]; then
-            IFS='.' read -r -a identifiers <<< "${without_build#*-}"
-            for identifier in "${identifiers[@]}"; do
-              [[ ! "${identifier}" =~ ^0[0-9]+$ ]] || {
-                printf 'Numeric prerelease identifiers must not contain leading zeros.\n' >&2
-                exit 1
-              }
-            done
-          fi
-          if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
-            echo "Tag ${TAG} already exists on remote; skipping."
-          else
-            git push origin "refs/tags/${TAG}"
-          fi
-
-      - name: Trigger validated binary and image release
-        if: steps.semrel.outputs.released == 'true'
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TAG: ${{ steps.semrel.outputs.tag }}
-        run: |
-          set -euo pipefail
-          if [[ "${TAG}" == *$'\r'* || "${TAG}" == *$'\n'* ]]; then
-            printf 'Tag must not contain CR or LF characters.\n' >&2
-            exit 1
-          fi
-          semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
-          [[ "${TAG}" =~ ${semver_re} ]] || {
-            printf 'semrel returned an invalid SemVer tag: %s\n' "${TAG}" >&2
-            exit 1
-          }
-          without_build="${TAG#v}"
-          without_build="${without_build%%+*}"
-          if [[ "${without_build}" == *-* ]]; then
-            IFS='.' read -r -a identifiers <<< "${without_build#*-}"
-            for identifier in "${identifiers[@]}"; do
-              [[ ! "${identifier}" =~ ^0[0-9]+$ ]] || {
-                printf 'Numeric prerelease identifiers must not contain leading zeros.\n' >&2
-                exit 1
-              }
-            done
-          fi
-          echo "Triggering release.yml for tag ${TAG}"
-          gh workflow run release.yml --ref "${TAG}" --repo "${GITHUB_REPOSITORY}" \
-            --field "version=${TAG}"
-
-      - name: Summary
-        env:
-          RELEASED: ${{ steps.semrel.outputs.released }}
-          TAG: ${{ steps.semrel.outputs.tag }}
-          BUMP: ${{ steps.semrel.outputs.bump }}
-        run: |
-          if [[ "${RELEASED}" == "true" ]]; then
-            printf '### Released %s 🚀\n**Bump:** %s\n' "${TAG}" "${BUMP}" >> "${GITHUB_STEP_SUMMARY}"
-          else
-            echo "### No release — no releasable commits found." >> "${GITHUB_STEP_SUMMARY}"
-          fi
diff --git a/.github/workflows/semrel-release.yml b/.github/workflows/semrel-release.yml
new file mode 100644
index 0000000..9948776
--- /dev/null
+++ b/.github/workflows/semrel-release.yml
@@ -0,0 +1,83 @@
+---
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The semrel Authors
+# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json
+name: Semrel Release
+
+on:
+  workflow_dispatch:
+  push:
+    branches:
+      - main
+
+permissions:
+  contents: write
+  actions: write
+
+concurrency:
+  group: ${{ github.workflow }}
+  cancel-in-progress: false
+
+jobs:
+  release:
+    name: Run Semrel
+    runs-on: ubuntu-latest
+
+    outputs:
+      released: ${{ steps.semrel.outputs.released }}
+      tag: ${{ steps.semrel.outputs.tag }}
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          fetch-depth: 0
+          token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Install tools with Mise
+        uses: jdx/mise-action@8418fedb1110381a0d3ba51a7a415d7c1b884550 # v5
+
+      - name: Configure git identity
+        run: |
+          git config user.name  "semrel-bot"
+          git config user.email "semrel-bot@users.noreply.github.com"
+
+      - name: Run semrel release
+        id: semrel
+        run: semrel release --github-output
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Push tag to GitHub
+        if: steps.semrel.outputs.released == 'true'
+        run: |
+          TAG="${{ steps.semrel.outputs.tag }}"
+          if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
+            echo "Tag $TAG already exists on remote — skipping"
+          else
+            git push origin "refs/tags/$TAG"
+          fi
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Trigger binary build
+        if: steps.semrel.outputs.released == 'true'
+        run: |
+          TAG="${{ steps.semrel.outputs.tag }}"
+          echo "Triggering binaries.yml for tag $TAG"
+          gh workflow run binaries.yml --ref "$TAG" --repo "${{ github.repository }}"
+          echo "Triggering container.yml for tag $TAG"
+          gh workflow run container.yml --ref "$TAG" --repo "${{ github.repository }}"
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Summary
+        run: |
+          if [ "${{ steps.semrel.outputs.released }}" = "true" ]; then
+            echo "### Released ${{ steps.semrel.outputs.tag }} 🚀" >> $GITHUB_STEP_SUMMARY
+            echo "**Bump:** ${{ steps.semrel.outputs.bump }}" >> $GITHUB_STEP_SUMMARY
+          else
+            echo "### No release — no releasable commits found." >> $GITHUB_STEP_SUMMARY
+          fi
+
+...
diff --git a/.gitignore b/.gitignore
index 5e7bb87..d8ce187 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
 # SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: 2026 The plugin-template Authors
+# SPDX-FileCopyrightText: 2026 The semrel Authors
 
 # Operating system files
 .DS_Store
@@ -26,3 +26,7 @@ build/
 
 # Docker
 .docker/
+
+# Semrel
+.semrel/
+.semrel-analytics.jsonl
diff --git a/.golangci.yml b/.golangci.yml
index 0ced420..b2321fc 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,8 +1,9 @@
+---
 # SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: 2026 The plugin-template Authors
+# SPDX-FileCopyrightText: 2026 The semrel Authors
+# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
 version: "2"
 
-
 run:
   timeout: 5m
 
@@ -13,3 +14,5 @@ linters:
     - ineffassign
     - staticcheck
     - unused
+
+...
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..a7547cc
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,18 @@
+---
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The semrel Authors
+# yaml-language-server: $schema=https://www.schemastore.org/pre-commit-config.json
+default_stages:
+  - pre-commit
+
+repos:
+  - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
+    rev: v9.26.0
+    hooks:
+      - id: commitlint
+        stages:
+          - commit-msg
+        additional_dependencies:
+          - "@commitlint/config-conventional"
+
+...
diff --git a/.reuse/dep5 b/.reuse/dep5
index 17e7657..4cd2f07 100644
--- a/.reuse/dep5
+++ b/.reuse/dep5
@@ -1,21 +1,21 @@
 Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
-Upstream-Name: {{PROJECT_NAME}}
-Upstream-Contact: The {{PROJECT_NAME}} Authors 
-Source: {{REPO_URL}}
+Upstream-Name: updater-python
+Upstream-Contact: The semrel Authors 
+Source: https://github.com/SemRels/updater-python
 
 # ----------------------------------------------------------------------------
 # Documentation and community health files
 # ----------------------------------------------------------------------------
 Files: *.md
  .github/**/*.md
-Copyright: {{COPYRIGHT_YEAR}} The {{PROJECT_NAME}} Authors
+Copyright: 2026 The semrel Authors
 License: Apache-2.0
 
 # ----------------------------------------------------------------------------
 # Build and tooling configuration
 # ----------------------------------------------------------------------------
-Files: Makefile
- Makefile.template
+Files: Taskfile.yml
+ Taskfile.yaml
  Dockerfile
  go.mod
  go.sum
@@ -23,15 +23,18 @@ Files: Makefile
  *.template
  .gitignore
  .gitignore.template
+ .pre-commit-config.yaml
+ .github/renovate.json
  .github/**/*.yml
  .github/**/*.yaml
  .github/**/*.json
  .github/**/*.template
  .reuse/dep5
+ commitlint.config.js
  cmd/**/*.go
  internal/**/*.go
  proto/**
-Copyright: {{COPYRIGHT_YEAR}} The {{PROJECT_NAME}} Authors
+Copyright: 2026 The semrel Authors
 License: Apache-2.0
 
 # ----------------------------------------------------------------------------
@@ -40,10 +43,10 @@ License: Apache-2.0
 Files: LICENSE
  LICENSES/Apache-2.0.txt
  NOTICE
-Copyright: {{COPYRIGHT_YEAR}} The {{PROJECT_NAME}} Authors
+Copyright: 2026 The semrel Authors
 License: Apache-2.0
 
 # JSON Schema files
 Files: schema/*.json
-Copyright: The updater-python Authors
+Copyright: The semrel Authors
 License: Apache-2.0
diff --git a/.semrel.yaml b/.semrel.yaml
index 2e86c5e..9c67d07 100644
--- a/.semrel.yaml
+++ b/.semrel.yaml
@@ -1,21 +1,52 @@
+---
 # SPDX-License-Identifier: Apache-2.0
 # SPDX-FileCopyrightText: 2026 The semrel Authors
 # yaml-language-server: $schema=https://semrel.io/schema/v1.json
-#
+schemaVersion: 1
+
+tagPrefix: "v"
+commit_changelog: true
+tag_exists_strategy: update-changelog
+
 # semrel uses itself to manage this plugin's releases.
 # version_ceiling keeps releases in 0.x.x until deliberately promoted to 1.0.0.
 # To release 1.0.0: remove version_ceiling and commit.
 
-tagPrefix: "v"
 version_ceiling: "1.0.0"
 ceiling_strategy: clamp
-tag_exists_strategy: update-changelog
 
 branches:
   - name: main
 
+rules:
+    - type: feat
+      bump: minor
+    - type: fix
+      bump: patch
+    - type: perf
+      bump: patch
+    - type: revert
+      bump: patch
+    - type: major
+      bump: major
+    - type: minor
+      bump: minor
+    - type: patch
+      bump: patch
+    - type: deps
+      bump: patch
+
 plugins:
-  # condition-github-actions verifies we are running inside a legitimate
-  # GitHub Actions CI environment before any git work is done.
-  - uses: condition-github-actions
+  - uses: "@semrel/condition-github-actions"
     phase: condition
+  - uses: "@semrel/generator-changelog-md"
+    phase: pre-tag
+    args:
+      keep_releases: "10"
+  - uses: "@semrel/provider-github"
+    phase: release
+    args:
+      draft: "false"
+      prerelease: "false"
+
+...
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index e833251..9a09b70 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -1,6 +1,6 @@
 # Code of Conduct
 
-updater-python is committed to providing a welcoming and harassment-free community.
+`updater-python` is committed to providing a welcoming and harassment-free community.
 
 ## Our Pledge
 
diff --git a/Dockerfile b/Dockerfile
index 8f568e0..553c02b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,31 +1,45 @@
 # SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: 2026 The updater-python Authors
+# SPDX-FileCopyrightText: 2026 The semrel Authors
 
 # ── build stage ────────────────────────────────────────────────────────────────
-FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build
+FROM --platform=$BUILDPLATFORM debian:13-slim@sha256:62bd423aeee912639ca8ee681c2b762cb3e25137e0a92b20be455898a9f034ad AS build
+
+RUN apt-get update && \
+    apt-get -y --no-install-recommends install ca-certificates curl git build-essential && \
+    rm -rf /var/lib/apt/lists/*
+
+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+ENV PATH="/root/.local/bin:/mise/shims:$PATH" \
+    MISE_DATA_DIR="/mise" \
+    MISE_CONFIG_DIR="/mise" \
+    MISE_CACHE_DIR="/mise/cache" \
+    MISE_INSTALL_PATH="/usr/local/bin/mise"
+
+RUN curl --proto '=https' --proto-redir '=https' \
+    --fail --show-error --silent --location https://mise.run | sh
+
+COPY mise.toml ./
+RUN --mount=type=cache,target=/mise/cache \
+   mise trust && mise install
+
+WORKDIR /src
+
+COPY go.mod go.sum* ./
+RUN --mount=type=cache,target=/root/go/pkg \
+    go mod download -x
+
+COPY . .
 
 ARG TARGETOS
 ARG TARGETARCH
 ARG VERSION=dev
 
-WORKDIR /src
-RUN apk add --no-cache ca-certificates git
-COPY go.mod go.sum ./
-RUN go mod download
-COPY . .
-RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
-    go build -trimpath -ldflags="-s -w" -o /out/plugin ./cmd/plugin
+RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
+    task build
 
 # ── distroless release image ───────────────────────────────────────────────────
-FROM gcr.io/distroless/static-debian12:nonroot
-
-# NOTE: intentionally no repo-specific `org.opencontainers.image.*` LABEL block
-# here. sync-template.yml only substitutes the "updater-python Authors" line
-# in this file when propagating it to plugin repos (see `sed` step) — it does
-# NOT rewrite LABEL title/description/source values. Adding those here would
-# get copied verbatim (and wrongly) into every plugin's Dockerfile. Add labels
-# in each plugin's own Dockerfile after copying this template, not here.
+FROM gcr.io/distroless/static-debian13:nonroot@sha256:d62067879de890a1def9da0e7766b7d20810a5e5555cef8506b752437d7a3e88
 
-COPY --from=build /out/plugin /usr/local/bin/plugin
+COPY --from=build /src/bin/plugin /usr/local/bin/plugin
 USER nonroot
 ENTRYPOINT ["/usr/local/bin/plugin"]
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index e11f666..89aa3cb 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -2,17 +2,20 @@
 
 ## Overview
 
-{{PROJECT_NAME}} is an open source project governed by its maintainers and community contributors. This document describes how decisions are made and how to participate.
+`updater-python` is an open source project governed by its maintainers and community contributors. This document describes how decisions are made and how to participate.
 
 ## Project Roles
 
 ### Contributor
+
 Anyone who opens an issue, submits a PR, improves documentation, or participates in discussions. No formal process is required beyond following [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md).
 
 ### Reviewer
+
 An experienced contributor trusted to review changes in a specific area. Reviewers can approve changes but should not merge without maintainer approval unless the repo explicitly grants that permission.
 
 ### Maintainer
+
 Maintainers have merge rights and are responsible for the overall health of the repository. The current list of maintainers is in [MAINTAINERS.md](MAINTAINERS.md).
 
 **To become a Maintainer:**
diff --git a/LICENSE b/LICENSE
index aaaa23a..cf3fa9b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -165,7 +165,7 @@
 
    END OF TERMS AND CONDITIONS
 
-   Copyright 2026 The go-semrel Authors
+   Copyright 2026 The semrel Authors
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt
index aaaa23a..cf3fa9b 100644
--- a/LICENSES/Apache-2.0.txt
+++ b/LICENSES/Apache-2.0.txt
@@ -165,7 +165,7 @@
 
    END OF TERMS AND CONDITIONS
 
-   Copyright 2026 The go-semrel Authors
+   Copyright 2026 The semrel Authors
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index 94dc0c4..b0ace2b 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -4,8 +4,8 @@
 
 | Name | GitHub | Affiliation | Areas |
 | --- | --- | --- | --- |
-| {{MAINTAINER_NAME_1}} | [@{{MAINTAINER_GITHUB_1}}](https://github.com/{{MAINTAINER_GITHUB_1}}) | {{AFFILIATION_1}} | {{AREAS_1}} |
-| {{MAINTAINER_NAME_2}} | [@{{MAINTAINER_GITHUB_2}}](https://github.com/{{MAINTAINER_GITHUB_2}}) | {{AFFILIATION_2}} | {{AREAS_2}} |
+| Markus Waldheim | [@mwaldheim](https://github.com/mwaldheim) | Independent | Core, Plugin Architecture, CI |
+| Thomas Boerger | [@tboerger](https://github.com/tboerger) | Independent | Core, Plugin Architecture, Gitea Integration |
 
 ## Responsibilities
 
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 5b36335..0000000
--- a/Makefile
+++ /dev/null
@@ -1,35 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: 2026 The plugin-template Authors
-
-.PHONY: build test lint coverage release build-all-platforms clean
-
-PLUGIN_NAME ?= plugin
-DIST_DIR ?= dist
-
-build:
-	mkdir -p bin
-	go build -o bin/$(PLUGIN_NAME) ./cmd/plugin
-
-test:
-	go test -v ./...
-
-lint:
-	golangci-lint run
-
-coverage:
-	go test -cover ./...
-
-build-all-platforms:
-	mkdir -p $(DIST_DIR)
-	GOOS=linux GOARCH=amd64 go build -o $(DIST_DIR)/$(PLUGIN_NAME)-linux-amd64 ./cmd/plugin
-	GOOS=linux GOARCH=arm64 go build -o $(DIST_DIR)/$(PLUGIN_NAME)-linux-arm64 ./cmd/plugin
-	GOOS=darwin GOARCH=amd64 go build -o $(DIST_DIR)/$(PLUGIN_NAME)-darwin-amd64 ./cmd/plugin
-	GOOS=darwin GOARCH=arm64 go build -o $(DIST_DIR)/$(PLUGIN_NAME)-darwin-arm64 ./cmd/plugin
-	GOOS=windows GOARCH=amd64 go build -o $(DIST_DIR)/$(PLUGIN_NAME)-windows-amd64.exe ./cmd/plugin
-	GOOS=windows GOARCH=arm64 go build -o $(DIST_DIR)/$(PLUGIN_NAME)-windows-arm64.exe ./cmd/plugin
-
-release:
-	@echo "Build all release artifacts locally with 'make build-all-platforms' and push a v*.*.* tag to trigger .github/workflows/release.yml"
-
-clean:
-	go clean
diff --git a/README.md b/README.md
index b8be328..0d5ccdf 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,10 @@
 # updater-python
 
 [![Latest Release](https://img.shields.io/github/v/release/SemRels/updater-python?label=version&color=blue)](https://github.com/SemRels/updater-python/releases/latest)
+[![CI](https://github.com/SemRels/updater-python/actions/workflows/ci.yml/badge.svg)](https://github.com/SemRels/updater-python/actions/workflows/ci.yml)
+[![License](https://img.shields.io/github/license/SemRels/updater-python)](LICENSE)
+[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/SemRels/updater-python/badge)](https://scorecard.dev/viewer/?uri=github.com/SemRels/updater-python)
+[![REUSE status](https://api.reuse.software/badge/github.com/SemRels/updater-python)](https://api.reuse.software/info/github.com/SemRels/updater-python)
 
 Updates the Python package version in the selected packaging backend.
 
@@ -26,7 +30,7 @@ Images are signed with [cosign](https://github.com/sigstore/cosign) and include
 
 ```bash
 cosign verify ghcr.io/semrels/updater-python:latest \
-  --certificate-identity-regexp 'https://github.com/SemRels/updater-python/.github/workflows/release.yml.*' \
+  --certificate-identity-regexp 'https://github.com/SemRels/updater-python/.github/workflows/container.yml.*' \
   --certificate-oidc-issuer https://token.actions.githubusercontent.com
 ```
 
@@ -35,19 +39,21 @@ cosign verify ghcr.io/semrels/updater-python:latest \
 
 ```yaml
 plugins:
-  - name: updater-python
-    path: ~/.semrel/plugins/semrel-plugin-updater-python
-    env:
-      SEMREL_PLUGIN_FILE: "pyproject.toml"
-      SEMREL_PLUGIN_BACKEND: "pyproject"
+  - uses: "@semrel/updater-python"
+    phase: pre-tag
+    args:
+      file: "pyproject.toml"
+      backend: "pyproject"
 ```
 
-## `SEMREL_PLUGIN_*` variables
+## Options
 
-| Name | Required | Description | Default |
-| --- | --- | --- | --- |
-| `SEMREL_PLUGIN_FILE` | Optional | Path to the Python packaging file to update. | pyproject.toml |
-| `SEMREL_PLUGIN_BACKEND` | Optional | Backend format used to locate and update the version. | pyproject |
+Every option can be set as an entry under `args` in `.semrel.yaml`. semrel passes each entry to the plugin as the environment variable shown in the table, so you can also set that variable directly (for example in your CI environment) instead of using `args`.
+
+| Argument | Environment variable | Required | Description | Default |
+| --- | --- | --- | --- | --- |
+| `file` | `SEMREL_PLUGIN_FILE` | Optional | Path to the Python packaging file to update. | pyproject.toml |
+| `backend` | `SEMREL_PLUGIN_BACKEND` | Optional | Backend format used to locate and update the version. | pyproject |
 
 ## `SEMREL_*` release context used
 
diff --git a/SECURITY.md b/SECURITY.md
index 4632a3c..b4e0ee5 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,17 +4,17 @@
 
 | Version | Supported |
 | --- | --- |
-| `main` / `{{DEFAULT_BRANCH}}` | ? |
+| `main` (pre-release) | ✅ |
 
-Update this table once stable releases exist.
+Once stable releases are published this table will list supported version ranges.
 
 ## Reporting a Vulnerability
 
-**Do not open a public issue for security vulnerabilities.**
+**Please do not open a public GitHub Issue for security vulnerabilities.**
 
-Please report vulnerabilities through **[GitHub Security Advisories]({{SECURITY_ADVISORY_URL}})**.
+Report security issues privately via **[GitHub Security Advisories](https://github.com/SemRels/updater-python/security/advisories/new)**.
 
-Alternative contact: {{SECURITY_CONTACT}}
+You can also reach the maintainers at the addresses listed in [MAINTAINERS.md](MAINTAINERS.md).
 
 ## What To Include
 
@@ -23,7 +23,7 @@ Alternative contact: {{SECURITY_CONTACT}}
 - Affected versions, tags, or commits
 - Proposed mitigations or workarounds, if known
 
-## Response Targets
+## Response SLA
 
 | Action | Target |
 | --- | --- |
diff --git a/Taskfile.yaml b/Taskfile.yaml
new file mode 100644
index 0000000..c38e2ac
--- /dev/null
+++ b/Taskfile.yaml
@@ -0,0 +1,143 @@
+---
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The semrel Authors
+# yaml-language-server: $schema=https://taskfile.dev/schema.json
+version: "3.40"
+
+tasks:
+  clean:
+    desc: Remove all temporary build artifacts
+    cmds:
+      - go clean -i ./...
+      - rm -rf dist/ bin/ coverage.out
+
+  fmt:
+    desc: Run standard formatter
+    vars:
+      SOURCES:
+        sh: find . -name "*.go" -type f | xargs echo
+    cmds:
+      - gofmt -s -w {{ .SOURCES }}
+
+  vet:
+    desc: Run vet linting
+    cmds:
+      - go vet ./...
+
+  lint:
+    desc: Run golangci linter
+    cmds:
+      - golangci-lint run
+
+  test:
+    desc: Run unit tests
+    vars:
+      PACKAGES:
+        sh: go list ./... | xargs echo
+    cmds:
+      - go test -v -coverprofile=coverage.out ./...
+
+  e2e:
+    desc: Run end-to-end tests
+    cmds:
+      - go test -tags e2e -v ./cmd/plugin/...
+
+  build:
+    desc: Build the plugin for the current platform
+    cmds:
+      - go build -v -trimpath -ldflags="-s -w" -o bin/plugin{{if eq .GOOS "windows"}}.exe{{end}} ./cmd/plugin
+    env:
+      CGO_ENABLED: "0"
+      GOOS: "{{ .GOOS }}"
+      GOARCH: "{{ .GOARCH }}"
+    vars:
+      GOOS:
+        sh: go env GOOS
+      GOARCH:
+        sh: go env GOARCH
+
+  build:dist:
+    desc: Build the plugin for the current platform
+    cmds:
+      - go build -v -trimpath -ldflags="-s -w" -o dist/plugin{{if eq .GOOS "windows"}}.exe{{end}} ./cmd/plugin
+      - sha256sum "dist/plugin{{if eq .GOOS "windows"}}.exe{{end}}" > "dist/plugin{{if eq .GOOS "windows"}}.exe{{end}}.sha256"
+    env:
+      CGO_ENABLED: "0"
+      GOOS: "{{ .GOOS }}"
+      GOARCH: "{{ .GOARCH }}"
+    vars:
+      GOOS:
+        sh: go env GOOS
+      GOARCH:
+        sh: go env GOARCH
+
+  build:all:
+    desc: Build the plugin for all release platforms
+    cmds:
+      - for:
+          - linux/amd64
+          - linux/arm64
+          - darwin/amd64
+          - darwin/arm64
+          - windows/amd64
+          - windows/arm64
+        cmd: >-
+          GOOS={{ index (splitList "/" .ITEM) 0 }}
+          GOARCH={{ index (splitList "/" .ITEM) 1 }}
+          go build -o dist/plugin-{{ index (splitList "/" .ITEM) 0 }}-{{ index (splitList "/" .ITEM) 1 }}{{if eq (index (splitList "/" .ITEM) 0) "windows"}}.exe{{end}}
+          ./cmd/plugin
+      - for:
+          - linux/amd64
+          - linux/arm64
+          - darwin/amd64
+          - darwin/arm64
+          - windows/amd64
+          - windows/arm64
+        cmd: >-
+          cd dist &&
+          sha256sum plugin-{{ index (splitList "/" .ITEM) 0 }}-{{ index (splitList "/" .ITEM) 1 }}{{if eq (index (splitList "/" .ITEM) 0) "windows"}}.exe{{end}}
+          > plugin-{{ index (splitList "/" .ITEM) 0 }}-{{ index (splitList "/" .ITEM) 1 }}{{if eq (index (splitList "/" .ITEM) 0) "windows"}}.exe{{end}}.sha256
+    env:
+      CGO_ENABLED: "0"
+
+  checksums:
+    desc: Generate checksums for all release artifacts
+    cmds:
+      - |
+        cd dist && find . -maxdepth 1 -type f ! -name '*.sha256' ! -name checksums.txt | sed 's|^\./||' | sort | while read -r file; do
+          sha256sum "${file}"
+        done > checksums.txt
+
+  sync:registry:
+    desc: Trigger a plugin sync in the semrel registry for a release tag
+    silent: true
+    vars:
+      PLUGIN_OWNER: SemRels
+      PLUGIN_REPO: SemRels/updater-python
+      PLUGIN_TAG:
+        sh: if [ -n "{{ .PLUGIN_TAG }}" ]; then echo "{{ .PLUGIN_TAG }}"; else git describe --tags --abbrev=0; fi
+    env:
+      PLUGIN_TAG: "{{ .PLUGIN_TAG }}"
+    cmds:
+      - |
+        if [ -n "${REGISTRY_SYNC_TOKEN:-}" ]; then
+          payload=$(printf '{"event_type":"sync-plugins","client_payload":{"source_owner":"{{ .PLUGIN_OWNER }}","source_repo":"{{ .PLUGIN_REPO }}","tag":"%s"}}' "${PLUGIN_TAG}")
+          curl --fail --silent --show-error \
+            -X POST \
+            -H "Accept: application/vnd.github+json" \
+            -H "Authorization: Bearer ${REGISTRY_SYNC_TOKEN}" \
+            -H "X-GitHub-Api-Version: 2022-11-28" \
+            https://api.github.com/repos/SemRels/semrel-registry/dispatches \
+            -d "${payload}"
+        elif [ -n "${REGISTRY_SYNC_WEBHOOK:-}" ]; then
+          payload=$(printf '{"repository":"{{ .PLUGIN_REPO }}","owner":"{{ .PLUGIN_OWNER }}","tag":"%s"}' "${PLUGIN_TAG}")
+          curl --fail --silent --show-error \
+            -X POST \
+            -H "Content-Type: application/json" \
+            "${REGISTRY_SYNC_WEBHOOK}" \
+            -d "${payload}"
+        else
+          echo "No registry sync secret configured; skipping dispatch."
+        fi
+
+...
diff --git a/commitlint.config.js b/commitlint.config.js
new file mode 100644
index 0000000..afd2f32
--- /dev/null
+++ b/commitlint.config.js
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2026 The semrel Authors
+// yaml-language-server: $schema=https://json.schemastore.org/commitlintrc.json
+module.exports = {
+  extends: ["@commitlint/config-conventional"],
+  rules: {
+    "signed-off-by": [
+      2,
+      "always",
+      "Signed-off-by:",
+    ],
+    "type-enum": [
+      2,
+      "always",
+      [
+        "feat",
+        "fix",
+        "docs",
+        "style",
+        "refactor",
+        "perf",
+        "test",
+        "build",
+        "ci",
+        "chore",
+        "revert",
+        "major",
+        "minor",
+        "patch",
+        "deps",
+      ],
+    ],
+  },
+};
diff --git a/go.mod b/go.mod
index 40ecd96..2ee0a6d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,3 @@
 module github.com/SemRels/updater-python
 
-go 1.25
-
-toolchain go1.25.0
+go 1.27.2
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..73e429a
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,14 @@
+
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The semrel Authors
+#:schema https://mise.jdx.dev/schema/mise.json
+
+[tools]
+cosign = "3.1.4"
+go = "1.27.3"
+node = "24"
+prek = "0.5.7"
+task = "3.54.0"
+
+"github:SemRels/semrel" = "0.28.2"
+"go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint" = "2.15.0"