From aff525c000f873050943284900e1279cbb0c26ef Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 2 Oct 2026 13:16:04 -0400 Subject: [PATCH 1/6] ci: migrate releases to release-please and lint PR titles --- .github/dependabot.yml | 4 + .github/scripts/release-provenance.mjs | 196 ----------- .github/scripts/release-registry-guard.mjs | 115 ------- .github/workflows/prek_autoupdate.yml | 1 + .github/workflows/release-please.yml | 27 ++ .github/workflows/release.yml | 325 +----------------- .github/workflows/semantic-pull-request.yml | 39 +++ .release-please-manifest.json | 3 + AGENTS.md | 13 +- release-please-config.json | 74 ++++ ui/release_provenance_test.js | 354 -------------------- version.txt | 1 + 12 files changed, 178 insertions(+), 974 deletions(-) delete mode 100644 .github/scripts/release-provenance.mjs delete mode 100644 .github/scripts/release-registry-guard.mjs create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/semantic-pull-request.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json delete mode 100644 ui/release_provenance_test.js create mode 100644 version.txt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 27da71e..9417564 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,8 @@ updates: interval: "weekly" cooldown: default-days: 7 + commit-message: + prefix: "deps" open-pull-requests-limit: 10 - package-ecosystem: "github-actions" @@ -14,4 +16,6 @@ updates: interval: "weekly" cooldown: default-days: 7 + commit-message: + prefix: "deps" open-pull-requests-limit: 10 diff --git a/.github/scripts/release-provenance.mjs b/.github/scripts/release-provenance.mjs deleted file mode 100644 index a79d09a..0000000 --- a/.github/scripts/release-provenance.mjs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (C) 2026 Snuffy2 -// SPDX-License-Identifier: AGPL-3.0-only - -import { execFileSync } from "node:child_process"; - -const numericIdentifier = "(?:0|[1-9][0-9]*)"; -const prereleaseIdentifier = `(?:${numericIdentifier}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)`; -const semverTagPattern = new RegExp( - `^v?(?${numericIdentifier}\\.${numericIdentifier}\\.${numericIdentifier}` + - `(?:-${prereleaseIdentifier}(?:\\.${prereleaseIdentifier})*)?)$`, - "u", -); -const versionLikeTagPattern = /^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-.].*)?$/u; -const dockerTagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u; -const commitPattern = /^[0-9a-f]{40}$/u; - -function fail(message) { - throw new Error(`Refusing Docker publication: ${message}`); -} - -export function versionFromTag(tag) { - const match = semverTagPattern.exec(tag); - return match?.groups?.version ?? null; -} - -export function resolveReleaseSource(event, git) { - if (!commitPattern.test(event.eventSHA)) { - fail(`event SHA ${event.eventSHA} is not a full lowercase commit SHA`); - } - const eventCommit = git.commit(event.eventSHA); - if (event.releaseTarget !== event.defaultBranch) { - fail(`release target ${event.releaseTarget} is not ${event.defaultBranch}`); - } - const version = versionFromTag(event.releaseTag); - if (version === null) { - fail(`release tag ${event.releaseTag} is not a supported semantic version`); - } - const tagCommit = git.tagCommit(event.releaseTag); - if (tagCommit !== eventCommit) { - fail( - `release tag ${event.releaseTag} resolves to ${tagCommit}, not event commit ${eventCommit}`, - ); - } - if (!git.isAncestor(eventCommit, event.defaultBranch)) { - fail( - `event commit ${eventCommit} is not an ancestor of ${event.defaultBranch}`, - ); - } - const tagIdentity = git.tagIdentity(event.releaseTag); - return { - imageTag: version, - immutableVersion: true, - releaseRefOID: tagIdentity.oid, - releaseRefType: tagIdentity.type, - sourceSHA: eventCommit, - }; -} - -export function resolveWorkflowSource(event, git) { - if (!commitPattern.test(event.eventSHA)) { - fail(`event SHA ${event.eventSHA} is not a full lowercase commit SHA`); - } - const defaultRef = `refs/heads/${event.defaultBranch}`; - if (event.eventRef !== defaultRef) { - fail(`event ref ${event.eventRef} is not ${defaultRef}`); - } - if (git.branchCommit(event.defaultBranch) !== event.eventSHA) { - fail( - `event commit ${event.eventSHA} is not the tip of ${event.defaultBranch}`, - ); - } - if (event.eventName === "push") { - if (event.inputTag) fail("main push unexpectedly supplied an image tag"); - return { - imageTag: "edge", - immutableVersion: false, - releaseRefOID: "", - releaseRefType: "", - sourceSHA: event.eventSHA, - }; - } - if (event.eventName !== "workflow_dispatch") { - fail(`unsupported workflow event ${event.eventName}`); - } - const imageTag = event.inputTag?.trim() ?? ""; - if (imageTag !== event.inputTag || !dockerTagPattern.test(imageTag)) { - fail("manual workflow dispatch supplied an invalid Docker image tag"); - } - if (imageTag === "edge" || imageTag === "latest") { - fail(`manual workflow dispatch may not publish ${imageTag}`); - } - if (versionLikeTagPattern.test(imageTag)) { - fail("manual workflow dispatch may not publish version tags"); - } - return { - imageTag, - sourceSHA: event.eventSHA, - immutableVersion: false, - releaseRefOID: "", - releaseRefType: "", - }; -} - -function runGit(args) { - return execFileSync("git", args, { encoding: "utf8" }).trim(); -} - -function gitForRelease(defaultBranch, releaseTag) { - runGit([ - "fetch", - "--force", - "--no-tags", - "origin", - `refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`, - `refs/tags/${releaseTag}:refs/tags/${releaseTag}`, - ]); - return { - commit(value) { - return runGit(["rev-parse", "--verify", `${value}^{commit}`]); - }, - tagCommit(tag) { - return runGit(["rev-parse", "--verify", `${tag}^{commit}`]); - }, - tagIdentity(tag) { - const oid = runGit(["rev-parse", "--verify", `refs/tags/${tag}`]); - return { oid, type: runGit(["cat-file", "-t", oid]) }; - }, - branchCommit(branch) { - return runGit(["rev-parse", "--verify", `origin/${branch}^{commit}`]); - }, - isAncestor(commit, branch) { - try { - runGit(["merge-base", "--is-ancestor", commit, `origin/${branch}`]); - return true; - } catch { - return false; - } - }, - }; -} - -function gitForBranch(defaultBranch) { - runGit([ - "fetch", - "--force", - "--no-tags", - "origin", - `refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`, - ]); - return { - branchCommit(branch) { - return runGit(["rev-parse", "--verify", `origin/${branch}^{commit}`]); - }, - }; -} - -function writeOutput(result) { - process.stdout.write( - [ - `source_sha=${result.sourceSHA}`, - `image_tag=${result.imageTag}`, - `immutable_version=${result.immutableVersion}`, - `release_ref_oid=${result.releaseRefOID}`, - `release_ref_type=${result.releaseRefType}`, - ].join("\n") + "\n", - ); -} - -if (process.argv[1] === new URL(import.meta.url).pathname) { - const event = { - defaultBranch: process.env.DEFAULT_BRANCH, - eventSHA: process.env.EVENT_SHA, - eventRef: process.env.EVENT_REF, - inputTag: process.env.INPUT_TAG, - releaseTag: process.env.RELEASE_TAG, - releaseTarget: process.env.RELEASE_TARGET, - }; - if (process.env.EVENT_NAME === "release") { - if (!event.defaultBranch || !event.releaseTag || !event.releaseTarget) { - fail("release event is missing immutable provenance fields"); - } - writeOutput( - resolveReleaseSource( - event, - gitForRelease(event.defaultBranch, event.releaseTag), - ), - ); - } else { - writeOutput( - resolveWorkflowSource( - { ...event, eventName: process.env.EVENT_NAME }, - gitForBranch(event.defaultBranch), - ), - ); - } -} diff --git a/.github/scripts/release-registry-guard.mjs b/.github/scripts/release-registry-guard.mjs deleted file mode 100644 index 76d83ab..0000000 --- a/.github/scripts/release-registry-guard.mjs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (C) 2026 Snuffy2 -// SPDX-License-Identifier: AGPL-3.0-only - -const digestPattern = /^sha256:[0-9a-f]{64}$/u; - -function fail(message) { - throw new Error(`Refusing Docker publication: ${message}`); -} - -function assertDigest(digest, name) { - if (!digestPattern.test(digest)) { - fail(`${name} is not a sha256 OCI manifest digest`); - } -} - -export function resolveImmutableTag({ expectedDigest, publishedDigest }) { - assertDigest(expectedDigest, "verified OCI archive index digest"); - if (publishedDigest === undefined || publishedDigest === "") return "absent"; - assertDigest(publishedDigest, "published registry manifest digest"); - if (publishedDigest !== expectedDigest) { - fail( - `immutable image version names ${publishedDigest}, not verified archive ${expectedDigest}`, - ); - } - return "matching"; -} - -export function tagsToCopy({ tags, immutableTag, immutableState }) { - if ( - !Array.isArray(tags) || - tags.some((tag) => typeof tag !== "string" || !tag) - ) { - fail("metadata action returned invalid image tags"); - } - if (immutableState === "matching") { - if (typeof immutableTag !== "string" || immutableTag.length === 0) { - fail("matching immutable version is missing its image tag"); - } - return tags.filter((tag) => tag !== immutableTag); - } - if (immutableState === "" || immutableState === "absent") return tags; - fail(`unknown immutable image state ${immutableState}`); -} - -export function assertPlatformIndex(index) { - if (!Array.isArray(index?.manifests)) { - fail("verified OCI archive is missing a manifest index"); - } - const imageDescriptors = []; - const attestationSubjects = new Set(); - for (const descriptor of index.manifests) { - if (!descriptor?.platform || !digestPattern.test(descriptor.digest)) { - fail("verified OCI archive contains an invalid manifest descriptor"); - } - const { architecture, os } = descriptor.platform; - if (os === "unknown" || architecture === "unknown") { - const annotations = descriptor.annotations; - const subject = annotations?.["vnd.docker.reference.digest"]; - if ( - os !== "unknown" || - architecture !== "unknown" || - annotations?.["vnd.docker.reference.type"] !== "attestation-manifest" || - !digestPattern.test(subject) - ) { - fail("verified OCI archive contains an invalid attestation descriptor"); - } - attestationSubjects.add(subject); - continue; - } - imageDescriptors.push(descriptor); - } - const platforms = imageDescriptors - .map(({ platform }) => `${platform.os}/${platform.architecture}`) - .sort(); - if ( - platforms.length !== 2 || - platforms[0] !== "linux/amd64" || - platforms[1] !== "linux/arm64" - ) { - fail( - `verified OCI archive platforms are ${platforms.join(", ") || "empty"}, not linux/amd64 and linux/arm64`, - ); - } - const imageDigests = new Set(imageDescriptors.map(({ digest }) => digest)); - if ( - imageDigests.size !== 2 || - [...attestationSubjects].some((digest) => !imageDigests.has(digest)) || - [...imageDigests].some((digest) => !attestationSubjects.has(digest)) - ) { - fail("verified OCI archive attestations do not match its image manifests"); - } -} - -if (process.argv[1] === new URL(import.meta.url).pathname) { - if (process.env.MODE === "validate-platforms") { - assertPlatformIndex(JSON.parse(process.env.MANIFEST_INDEX)); - } else { - const state = - process.env.IMMUTABLE_VERSION === "true" - ? resolveImmutableTag({ - expectedDigest: process.env.EXPECTED_DIGEST, - publishedDigest: process.env.PUBLISHED_DIGEST, - }) - : ""; - const tags = tagsToCopy({ - tags: process.env.TAGS.split("\n").filter(Boolean), - immutableTag: process.env.IMMUTABLE_TAG, - immutableState: state, - }); - process.stdout.write( - `immutable_state=${state}\ntags_to_copy<<__RELEASE_TAGS__\n` + - `${tags.join("\n")}\n__RELEASE_TAGS__\n`, - ); - } -} diff --git a/.github/workflows/prek_autoupdate.yml b/.github/workflows/prek_autoupdate.yml index 596d094..98e11b8 100644 --- a/.github/workflows/prek_autoupdate.yml +++ b/.github/workflows/prek_autoupdate.yml @@ -29,4 +29,5 @@ jobs: with: token: ${{ secrets.PREK_AUTOUPDATE_TOKEN }} auto-merge: true + commit-message: "deps: update prek hooks" update-day: "1" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..4a8468a --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,27 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release-please: + if: github.event.repository.fork == false + runs-on: ubuntu-latest + steps: + - name: Create or update the release pull request + id: release + uses: googleapis/release-please-action@v5 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b743d46..b135238 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,118 +5,21 @@ on: branches: [main] release: types: [published] - workflow_dispatch: - inputs: - tag_name: - description: Docker image tag to publish - required: true - type: string permissions: contents: read concurrency: - # Release and manual publishers share mutable tags such as `latest`, while - # edge-only main pushes are independent. - group: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && format('{0}-main-edge', github.workflow) || format('{0}-release-publishers', github.workflow) }} - cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + group: ${{ github.event_name == 'push' && format('{0}-main-edge', github.workflow) || format('{0}-release-publishers', github.workflow) }} + cancel-in-progress: ${{ github.event_name == 'push' }} env: REGISTRY: ghcr.io IMAGE_NAME: snuffy2/shellport - OCI_ARTIFACT_NAME: shellport-oci-${{ github.run_id }} - OCI_ARTIFACT_PATH: shellport.oci.tar - OCI_ARTIFACT_MAX_BYTES: "2147483648" jobs: - provenance: - if: github.event.repository.fork == false - runs-on: ubuntu-latest - permissions: - contents: read - packages: read - outputs: - source_sha: ${{ steps.source.outputs.source_sha }} - image_tag: ${{ steps.source.outputs.image_tag }} - immutable_version: ${{ steps.source.outputs.immutable_version }} - release_ref_oid: ${{ steps.source.outputs.release_ref_oid }} - release_ref_type: ${{ steps.source.outputs.release_ref_type }} - steps: - - uses: actions/checkout@v7 - with: - # Policy code comes from the current trusted default branch. The - # separately resolved event SHA remains the only image build source. - ref: ${{ github.event.repository.default_branch }} - fetch-depth: 0 - persist-credentials: false - - id: source - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - EVENT_NAME: ${{ github.event_name }} - EVENT_REF: ${{ github.ref }} - EVENT_SHA: ${{ github.sha }} - INPUT_TAG: ${{ inputs.tag_name }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_TARGET: ${{ github.event.release.target_commitish }} - run: node .github/scripts/release-provenance.mjs >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v7 - with: - name: release-registry-control-${{ github.run_id }} - path: | - .github/scripts/release-provenance.mjs - .github/scripts/release-registry-guard.mjs - include-hidden-files: true - if-no-files-found: error - compression-level: 0 - retention-days: 1 - build: - needs: provenance - runs-on: ubuntu-latest - outputs: - artifact_sha256: ${{ steps.artifact.outputs.sha256 }} - artifact_bytes: ${{ steps.artifact.outputs.bytes }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.provenance.outputs.source_sha }} - persist-credentials: false - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - uses: docker/build-push-action@v7 - with: - context: . - platforms: linux/amd64,linux/arm64 - outputs: type=oci,dest=${{ runner.temp }}/${{ env.OCI_ARTIFACT_PATH }} - provenance: mode=max - sbom: true - labels: | - org.opencontainers.image.source=https://github.com/Snuffy2/shellport - org.opencontainers.image.revision=${{ needs.provenance.outputs.source_sha }} - org.opencontainers.image.version=${{ needs.provenance.outputs.image_tag }} - build-args: | - SHELLPORT_VERSION=${{ needs.provenance.outputs.image_tag }} - SHELLPORT_SOURCE_URL=https://github.com/Snuffy2/shellport/archive/${{ needs.provenance.outputs.source_sha }}.tar.gz - - id: artifact - env: - OCI_ARCHIVE: ${{ runner.temp }}/${{ env.OCI_ARTIFACT_PATH }} - run: | - set -euo pipefail - test -s "$OCI_ARCHIVE" - bytes="$(wc -c < "$OCI_ARCHIVE" | tr -d '[:space:]')" - [[ "$bytes" =~ ^[0-9]+$ ]] && (( bytes > 0 && bytes <= OCI_ARTIFACT_MAX_BYTES )) - printf 'bytes=%s\nsha256=%s\n' "$bytes" "$(sha256sum "$OCI_ARCHIVE" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" - printf 'OCI artifact: %s (%s bytes)\n' "$OCI_ARTIFACT_PATH" "$bytes" >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v7 - with: - name: ${{ env.OCI_ARTIFACT_NAME }} - path: ${{ runner.temp }}/${{ env.OCI_ARTIFACT_PATH }} - if-no-files-found: error - compression-level: 0 - retention-days: 1 - publish: if: github.event.repository.fork == false - needs: [provenance, build] runs-on: ubuntu-latest permissions: contents: read @@ -124,57 +27,10 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event.repository.default_branch }} - fetch-depth: 0 + ref: ${{ github.event.release.tag_name || github.sha }} persist-credentials: false - - uses: actions/download-artifact@v8 - with: - name: ${{ env.OCI_ARTIFACT_NAME }} - path: ${{ runner.temp }}/release-image - - uses: actions/download-artifact@v8 - with: - name: release-registry-control-${{ github.run_id }} - path: ${{ runner.temp }}/release-control - - name: Verify OCI artifact continuity - id: artifact - env: - OCI_ARCHIVE: ${{ runner.temp }}/release-image/${{ env.OCI_ARTIFACT_PATH }} - EXPECTED_BYTES: ${{ needs.build.outputs.artifact_bytes }} - EXPECTED_SHA256: ${{ needs.build.outputs.artifact_sha256 }} - OCI_INDEX: ${{ runner.temp }}/oci-index.json - OCI_LAYOUT_METADATA: ${{ runner.temp }}/oci-layout.json - PLATFORM_INDEX: ${{ runner.temp }}/platform-index.json - run: | - set -euo pipefail - actual_bytes="$(wc -c < "$OCI_ARCHIVE" | tr -d '[:space:]')" - actual_sha256="$(sha256sum "$OCI_ARCHIVE" | cut -d ' ' -f 1)" - [[ "$actual_bytes" == "$EXPECTED_BYTES" && "$actual_bytes" =~ ^[0-9]+$ ]] && (( actual_bytes > 0 && actual_bytes <= OCI_ARTIFACT_MAX_BYTES )) - [[ "$actual_sha256" == "$EXPECTED_SHA256" && "$actual_sha256" =~ ^[0-9a-f]{64}$ ]] - tar --extract --to-stdout --file "$OCI_ARCHIVE" index.json > "$OCI_INDEX" - tar --extract --to-stdout --file "$OCI_ARCHIVE" oci-layout > "$OCI_LAYOUT_METADATA" - jq -e '.imageLayoutVersion == "1.0.0"' "$OCI_LAYOUT_METADATA" - index_digest="$(jq -er '.manifests | if length == 1 then .[0].digest else empty end' "$OCI_INDEX")" - [[ "$index_digest" =~ ^sha256:[0-9a-f]{64}$ ]] - index_blob="${index_digest#sha256:}" - tar --extract --to-stdout --file "$OCI_ARCHIVE" "blobs/sha256/$index_blob" > "$PLATFORM_INDEX" - [[ "sha256:$(sha256sum "$PLATFORM_INDEX" | cut -d ' ' -f 1)" == "$index_digest" ]] - MODE=validate-platforms MANIFEST_INDEX="$(cat "$PLATFORM_INDEX")" node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" - printf 'index_digest=%s\n' "$index_digest" >> "$GITHUB_OUTPUT" - - name: Authorize current stable release alias - id: latest - if: github.event_name == 'release' && !github.event.release.prerelease - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - set -euo pipefail - latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" - if [[ "$latest_release_tag" == "$RELEASE_TAG" ]]; then - printf 'publish_latest=true\n' >> "$GITHUB_OUTPUT" - else - printf 'publish_latest=false\n' >> "$GITHUB_OUTPUT" - printf 'Skipping latest: current stable release is %s, not %s.\n' "$latest_release_tag" "$RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY" - fi + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 - id: meta uses: docker/metadata-action@v6 with: @@ -184,166 +40,21 @@ jobs: tags: | type=edge,branch=main,enable=${{ github.event_name == 'push' }} type=semver,pattern={{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} - type=raw,value=latest,enable=${{ steps.latest.outputs.publish_latest == 'true' }} - type=raw,value=${{ inputs.tag_name }},enable=${{ github.event_name == 'workflow_dispatch' }} - - name: Look up immutable image version - id: lookup - if: needs.provenance.outputs.immutable_version == 'true' - env: - IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} - REGISTRY_USERNAME: ${{ github.actor }} - REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - response_dir="$RUNNER_TEMP/immutable-version" - mkdir "$response_dir" - token_status="$(curl --silent --show-error --output "$response_dir/token" --write-out '%{http_code}' --user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --get --data-urlencode "service=$REGISTRY" --data-urlencode "scope=repository:$IMAGE_NAME:pull" "https://$REGISTRY/token")" - [[ "$token_status" == "200" ]] - bearer="$(jq -er '.token // .access_token' "$response_dir/token")" - test -n "$bearer" - status="$(curl --silent --show-error --output "$response_dir/manifest" --dump-header "$response_dir/headers" --write-out '%{http_code}' --header "Authorization: Bearer $bearer" --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' "https://$REGISTRY/v2/$IMAGE_NAME/manifests/$IMAGE_TAG")" - published_digest="" - case "$status" in - 200) - published_digest="$(grep -i '^docker-content-digest:' "$response_dir/headers" | tail -n 1 | sed -E 's/^[^:]+:[[:space:]]*//' | tr -d '\r')" - [[ "$published_digest" =~ ^sha256:[0-9a-f]{64}$ ]] - [[ "sha256:$(sha256sum "$response_dir/manifest" | cut -d ' ' -f 1)" == "$published_digest" ]] - ;; - 404) ;; - *) - printf 'immutable registry lookup returned HTTP %s\n' "$status" >&2 - exit 1 - ;; - esac - printf 'published_digest=%s\n' "$published_digest" >> "$GITHUB_OUTPUT" - - name: Plan registry writes - id: plan - env: - EXPECTED_DIGEST: ${{ steps.artifact.outputs.index_digest }} - IMMUTABLE_TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.provenance.outputs.image_tag }} - IMMUTABLE_VERSION: ${{ needs.provenance.outputs.immutable_version }} - PUBLISHED_DIGEST: ${{ steps.lookup.outputs.published_digest }} - TAGS: ${{ steps.meta.outputs.tags }} - run: node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" >> "$GITHUB_OUTPUT" - - name: Revalidate publication source - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - EVENT_NAME: ${{ github.event_name }} - EVENT_REF: ${{ github.ref }} - EVENT_SHA: ${{ github.sha }} - INPUT_TAG: ${{ inputs.tag_name }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_TARGET: ${{ github.event.release.target_commitish }} - EXPECTED_SOURCE_SHA: ${{ needs.provenance.outputs.source_sha }} - EXPECTED_IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} - EXPECTED_IMMUTABLE_VERSION: ${{ needs.provenance.outputs.immutable_version }} - EXPECTED_RELEASE_REF_OID: ${{ needs.provenance.outputs.release_ref_oid }} - EXPECTED_RELEASE_REF_TYPE: ${{ needs.provenance.outputs.release_ref_type }} - run: | - set -euo pipefail - expected="$RUNNER_TEMP/expected-source" - actual="$RUNNER_TEMP/actual-source" - printf '%s\n' \ - "source_sha=$EXPECTED_SOURCE_SHA" \ - "image_tag=$EXPECTED_IMAGE_TAG" \ - "immutable_version=$EXPECTED_IMMUTABLE_VERSION" \ - "release_ref_oid=$EXPECTED_RELEASE_REF_OID" \ - "release_ref_type=$EXPECTED_RELEASE_REF_TYPE" > "$expected" - node "$RUNNER_TEMP/release-control/release-provenance.mjs" > "$actual" - diff --unified "$expected" "$actual" + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Upload verified OCI archive without rebuilding - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - EVENT_NAME: ${{ github.event_name }} - EVENT_REF: ${{ github.ref }} - EVENT_SHA: ${{ github.sha }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - INPUT_TAG: ${{ inputs.tag_name }} - OCI_ARCHIVE: ${{ runner.temp }}/release-image/${{ env.OCI_ARTIFACT_PATH }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_TARGET: ${{ github.event.release.target_commitish }} - TAGS: ${{ steps.plan.outputs.tags_to_copy }} - REGISTRY_USERNAME: ${{ github.actor }} - REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - revalidate_source() { - node "$RUNNER_TEMP/release-control/release-provenance.mjs" > "$RUNNER_TEMP/current-source" - diff --unified "$RUNNER_TEMP/expected-source" "$RUNNER_TEMP/current-source" - } - revalidate_source - if [[ -z "$TAGS" ]]; then - exit 0 - fi - while IFS= read -r tag; do - test -n "$tag" - revalidate_source - if [[ "$tag" == "$REGISTRY/$IMAGE_NAME:latest" ]]; then - current_latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" - if [[ "$current_latest_release_tag" != "$RELEASE_TAG" ]]; then - printf 'Refusing latest: current stable release is %s, not %s.\n' "$current_latest_release_tag" "$RELEASE_TAG" >&2 - exit 1 - fi - fi - docker run --rm -v "$OCI_ARCHIVE:/work/image.oci:ro" quay.io/skopeo/stable@sha256:8d25aabcf965e267b6a6ad02ff8da5512f77de1490063625093ff564797e88bc copy --all --preserve-digests --dest-creds "$REGISTRY_USERNAME:$REGISTRY_TOKEN" oci-archive:/work/image.oci docker://"$tag" - done <<< "$TAGS" - - name: Verify every published tag - env: - EXPECTED_DIGEST: ${{ steps.artifact.outputs.index_digest }} - REGISTRY_USERNAME: ${{ github.actor }} - REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAGS: ${{ steps.meta.outputs.tags }} - run: | - set -euo pipefail - response_dir="$RUNNER_TEMP/published-tags" - mkdir "$response_dir" - token_status="$(curl --silent --show-error --output "$response_dir/token" --write-out '%{http_code}' --user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --get --data-urlencode "service=$REGISTRY" --data-urlencode "scope=repository:$IMAGE_NAME:pull" "https://$REGISTRY/token")" - [[ "$token_status" == "200" ]] - bearer="$(jq -er '.token // .access_token' "$response_dir/token")" - test -n "$bearer" - while IFS= read -r tag; do - test -n "$tag" - image_tag="${tag##*:}" - status="$(curl --silent --show-error --output "$response_dir/manifest" --dump-header "$response_dir/headers" --write-out '%{http_code}' --header "Authorization: Bearer $bearer" --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' "https://$REGISTRY/v2/$IMAGE_NAME/manifests/$image_tag")" - [[ "$status" == "200" ]] - published_digest="$(grep -i '^docker-content-digest:' "$response_dir/headers" | tail -n 1 | sed -E 's/^[^:]+:[[:space:]]*//' | tr -d '\r')" - [[ "$published_digest" == "$EXPECTED_DIGEST" ]] - [[ "sha256:$(sha256sum "$response_dir/manifest" | cut -d ' ' -f 1)" == "$published_digest" ]] - done <<< "$TAGS" - - name: Confirm publication source remains current - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - EVENT_NAME: ${{ github.event_name }} - EVENT_REF: ${{ github.ref }} - EVENT_SHA: ${{ github.sha }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - INPUT_TAG: ${{ inputs.tag_name }} - PUBLISH_LATEST: ${{ steps.latest.outputs.publish_latest }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_TARGET: ${{ github.event.release.target_commitish }} - EXPECTED_SOURCE_SHA: ${{ needs.provenance.outputs.source_sha }} - EXPECTED_IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} - EXPECTED_IMMUTABLE_VERSION: ${{ needs.provenance.outputs.immutable_version }} - EXPECTED_RELEASE_REF_OID: ${{ needs.provenance.outputs.release_ref_oid }} - EXPECTED_RELEASE_REF_TYPE: ${{ needs.provenance.outputs.release_ref_type }} - run: | - set -euo pipefail - expected="$RUNNER_TEMP/expected-source" - actual="$RUNNER_TEMP/final-source" - printf '%s\n' \ - "source_sha=$EXPECTED_SOURCE_SHA" \ - "image_tag=$EXPECTED_IMAGE_TAG" \ - "immutable_version=$EXPECTED_IMMUTABLE_VERSION" \ - "release_ref_oid=$EXPECTED_RELEASE_REF_OID" \ - "release_ref_type=$EXPECTED_RELEASE_REF_TYPE" > "$expected" - node "$RUNNER_TEMP/release-control/release-provenance.mjs" > "$actual" - diff --unified "$expected" "$actual" - if [[ "$PUBLISH_LATEST" == "true" ]]; then - current_latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" - [[ "$current_latest_release_tag" == "$RELEASE_TAG" ]] - fi + - uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + provenance: mode=max + sbom: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + SHELLPORT_VERSION=${{ steps.meta.outputs.version }} + SHELLPORT_SOURCE_URL=https://github.com/Snuffy2/shellport/archive/${{ github.sha }}.tar.gz diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml new file mode 100644 index 0000000..69e4957 --- /dev/null +++ b/.github/workflows/semantic-pull-request.yml @@ -0,0 +1,39 @@ +name: Lint PR title + +on: + pull_request_target: + types: + - opened + - reopened + - edited + - synchronize + +concurrency: + group: semantic-pull-request-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - name: Validate PR title + uses: amannn/action-semantic-pull-request@v6 + with: + types: | + build + chore + ci + deps + docs + feat + fix + perf + refactor + revert + style + test + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..954b159 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.2.6" +} diff --git a/AGENTS.md b/AGENTS.md index e490ba6..d714841 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,14 +142,23 @@ project command protocol. application in Debian-based stages, then copies the final binary into an Alpine runtime image. -GitHub release publishing is configured in `.github/workflows/release.yml` for -GHCR image `ghcr.io/snuffy2/shellport`. +Release Please is configured in `.github/workflows/release-please.yml` and requires +the `RELEASE_PLEASE_TOKEN` secret. It manages release PRs, version +bump updates, and GitHub releases. Docker publishing is configured in +`.github/workflows/release.yml` for GHCR image `ghcr.io/snuffy2/shellport`: main +pushes publish `edge`, and published releases publish version tags and `latest` +for stable releases. Do not push branches, publish images, or open pull requests unless the user explicitly asks. ## Git And File Safety +- All PRs created must have Conventional Commit titles: `type: description` or + `type(scope): description`, with `!` before `:` for breaking changes. Use one + of `build`, `chore`, `ci`, `deps`, `docs`, `feat`, `fix`, `perf`, `refactor`, + `revert`, `style`, or `test`, as enforced by the PR title lint workflow. + - Do not revert user changes unless explicitly instructed. - Before editing a file that already has uncommitted changes, inspect it and work with the current contents. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..a93abba --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "include-v-in-tag": true, + "changelog-sections": [ + { + "type": "feat", + "section": "Features", + "hidden": false + }, + { + "type": "fix", + "section": "Bug Fixes", + "hidden": false + }, + { + "type": "perf", + "section": "Performance Improvements", + "hidden": false + }, + { + "type": "deps", + "section": "Dependencies", + "hidden": false + }, + { + "type": "revert", + "section": "Reverts", + "hidden": false + }, + { + "type": "docs", + "section": "Documentation", + "hidden": false + }, + { + "type": "style", + "section": "Styles", + "hidden": false + }, + { + "type": "chore", + "section": "Miscellaneous Chores", + "hidden": false + }, + { + "type": "refactor", + "section": "Code Refactoring", + "hidden": false + }, + { + "type": "test", + "section": "Tests", + "hidden": false + }, + { + "type": "build", + "section": "Build System", + "hidden": false + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": false + } + ], + "packages": { + ".": { + "package-name": "shellport", + "skip-changelog": true + } + } +} diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js deleted file mode 100644 index 17423d7..0000000 --- a/ui/release_provenance_test.js +++ /dev/null @@ -1,354 +0,0 @@ -// Copyright (C) 2026 Snuffy2 -// SPDX-License-Identifier: AGPL-3.0-only - -import { readFileSync } from "node:fs"; - -import { describe, expect, test } from "vitest"; - -import { - resolveReleaseSource, - resolveWorkflowSource, -} from "../.github/scripts/release-provenance.mjs"; -import { - assertPlatformIndex, - resolveImmutableTag, - tagsToCopy, -} from "../.github/scripts/release-registry-guard.mjs"; - -const eventSHA = "a".repeat(40); -const indexDigest = `sha256:${"b".repeat(64)}`; -const otherDigest = `sha256:${"c".repeat(64)}`; -const releaseWorkflow = readFileSync( - new URL("../.github/workflows/release.yml", import.meta.url), - "utf8", -); - -function releaseEvent(overrides = {}) { - return { - defaultBranch: "main", - eventSHA, - releaseTag: "v1.2.3", - releaseTarget: "main", - ...overrides, - }; -} - -function repository(overrides = {}) { - return { - branchCommit: () => eventSHA, - commit: (value) => value, - tagCommit: () => eventSHA, - tagIdentity: () => ({ oid: eventSHA, type: "commit" }), - isAncestor: () => true, - ...overrides, - }; -} - -function workflowEvent(overrides = {}) { - return { - defaultBranch: "main", - eventName: "workflow_dispatch", - eventRef: "refs/heads/main", - eventSHA, - inputTag: "nightly", - ...overrides, - }; -} - -describe("release provenance", function () { - test("uses the event commit only when the published tag still names it", function () { - expect(resolveReleaseSource(releaseEvent(), repository())).toEqual({ - sourceSHA: eventSHA, - imageTag: "1.2.3", - immutableVersion: true, - releaseRefOID: eventSHA, - releaseRefType: "commit", - }); - }); - test.each([ - [ - "wrong target", - releaseEvent({ releaseTarget: "release" }), - repository(), - "not main", - ], - [ - "missing tag", - releaseEvent({ releaseTag: "candidate" }), - repository(), - "not a supported", - ], - [ - "non-ancestor", - releaseEvent(), - repository({ isAncestor: () => false }), - "not an ancestor", - ], - [ - "moved tag", - releaseEvent(), - repository({ tagCommit: () => "b".repeat(40) }), - "not event commit", - ], - ])("rejects %s", (_name, event, git, message) => { - expect(() => resolveReleaseSource(event, git)).toThrow(message); - }); - test("records the direct tag object as well as its peeled commit", function () { - expect( - resolveReleaseSource( - releaseEvent(), - repository({ - tagIdentity: () => ({ oid: "b".repeat(40), type: "tag" }), - }), - ), - ).toMatchObject({ - releaseRefOID: "b".repeat(40), - releaseRefType: "tag", - }); - }); - test.each([ - "v01.2.3", - "v1.02.3", - "v1.2.03", - "v1.2.3-", - "v1.2.3-alpha..1", - "v1.2.3-beta.01", - ])("rejects invalid semantic release tag %s", (releaseTag) => { - expect(() => - resolveReleaseSource(releaseEvent({ releaseTag }), repository()), - ).toThrow("not a supported semantic version"); - }); - test("keeps the implicit edge tag for main pushes", function () { - expect( - resolveWorkflowSource( - workflowEvent({ eventName: "push", inputTag: "" }), - repository(), - ), - ).toEqual({ - imageTag: "edge", - immutableVersion: false, - releaseRefOID: "", - releaseRefType: "", - sourceSHA: eventSHA, - }); - }); - test.each([ - ["edge", "may not publish edge"], - ["latest", "may not publish latest"], - ["1.2.3", "may not publish version tags"], - ["v1.2.3", "may not publish version tags"], - ["1.2.3-beta.01", "may not publish version tags"], - ["", "invalid Docker image tag"], - [" nightly ", "invalid Docker image tag"], - ["bad/tag", "invalid Docker image tag"], - ])("rejects reserved or invalid manual tag %j", (inputTag, message) => { - expect(() => - resolveWorkflowSource(workflowEvent({ inputTag }), repository()), - ).toThrow(message); - }); - test.each([ - [ - "non-default ref", - workflowEvent({ eventRef: "refs/heads/release" }), - repository(), - "is not refs/heads/main", - ], - [ - "stale default-branch commit", - workflowEvent(), - repository({ branchCommit: () => "b".repeat(40) }), - "is not the tip of main", - ], - [ - "unsupported event", - workflowEvent({ eventName: "pull_request" }), - repository(), - "unsupported workflow event", - ], - ])("rejects manual publication from %s", (_name, event, git, message) => { - expect(() => resolveWorkflowSource(event, git)).toThrow(message); - }); - test("accepts a custom tag only from the current default-branch tip", function () { - expect(resolveWorkflowSource(workflowEvent(), repository())).toMatchObject({ - immutableVersion: false, - imageTag: "nightly", - }); - }); -}); - -describe("registry immutability guard", function () { - test("retries the current release's failed latest write from its verified archive", function () { - const immutableTag = "ghcr.io/snuffy2/shellport:1.2.3"; - expect( - resolveImmutableTag({ - expectedDigest: indexDigest, - publishedDigest: indexDigest, - }), - ).toBe("matching"); - expect( - tagsToCopy({ - tags: [immutableTag, "ghcr.io/snuffy2/shellport:latest"], - immutableTag, - immutableState: "matching", - }), - ).toEqual(["ghcr.io/snuffy2/shellport:latest"]); - }); - test("publishes a previously absent immutable version", function () { - expect( - resolveImmutableTag({ expectedDigest: indexDigest, publishedDigest: "" }), - ).toBe("absent"); - }); - test("makes an older matching immutable release a no-op without latest", function () { - const immutableTag = "ghcr.io/snuffy2/shellport:1.2.3"; - expect( - tagsToCopy({ - tags: [immutableTag], - immutableTag, - immutableState: "matching", - }), - ).toEqual([]); - }); - test("rejects a full workflow rerun that rebuilds a different archive", function () { - expect(() => - resolveImmutableTag({ - expectedDigest: indexDigest, - publishedDigest: otherDigest, - }), - ).toThrow("not verified archive"); - }); -}); - -describe("OCI archive policy", function () { - const descriptor = (os, architecture, digestCharacter) => ({ - digest: `sha256:${digestCharacter.repeat(64)}`, - platform: { architecture, os }, - }); - const attestation = (digestCharacter, subjectCharacter) => ({ - annotations: { - "vnd.docker.reference.digest": `sha256:${subjectCharacter.repeat(64)}`, - "vnd.docker.reference.type": "attestation-manifest", - }, - digest: `sha256:${digestCharacter.repeat(64)}`, - platform: { architecture: "unknown", os: "unknown" }, - }); - - test("requires one amd64 and one arm64 Linux image", function () { - expect(() => - assertPlatformIndex({ - manifests: [ - descriptor("linux", "amd64", "a"), - descriptor("linux", "arm64", "b"), - attestation("c", "a"), - attestation("d", "b"), - ], - }), - ).not.toThrow(); - }); - test.each([ - [descriptor("linux", "amd64", "a"), descriptor("linux", "amd64", "b")], - [descriptor("linux", "amd64", "a")], - [descriptor("linux", "amd64", "a"), descriptor("linux", "s390x", "b")], - ])("rejects the invalid platform set %#", (...manifests) => { - expect(() => assertPlatformIndex({ manifests })).toThrow( - "not linux/amd64 and linux/arm64", - ); - }); - test.each([ - [ - descriptor("linux", "amd64", "a"), - descriptor("linux", "arm64", "b"), - { digest: `sha256:${"c".repeat(64)}` }, - ], - [ - descriptor("linux", "amd64", "a"), - descriptor("linux", "arm64", "b"), - attestation("c", "e"), - ], - ])("rejects an unrelated extra descriptor %#", (...manifests) => { - expect(() => assertPlatformIndex({ manifests })).toThrow( - /invalid manifest descriptor|attestations do not match/u, - ); - }); - test("requires a manifest index", function () { - expect(() => assertPlatformIndex({})).toThrow("missing a manifest index"); - }); - test.each([ - { - annotations: { "vnd.docker.reference.type": "attestation-manifest" }, - digest: `sha256:${"c".repeat(64)}`, - platform: { architecture: "unknown", os: "unknown" }, - }, - { - annotations: { - "vnd.docker.reference.digest": `sha256:${"a".repeat(64)}`, - "vnd.docker.reference.type": "sbom", - }, - digest: `sha256:${"c".repeat(64)}`, - platform: { architecture: "unknown", os: "unknown" }, - }, - ])("rejects an invalid attestation descriptor %#", (extra) => { - expect(() => - assertPlatformIndex({ - manifests: [ - descriptor("linux", "amd64", "a"), - descriptor("linux", "arm64", "b"), - extra, - ], - }), - ).toThrow("invalid attestation descriptor"); - }); -}); - -describe("release publisher serialization", function () { - test("shares one non-cancelling group for release and manual publishers", function () { - const group = releaseWorkflow.match(/^ {2}group: (?.+)$/mu)?.groups - ?.value; - const cancellation = releaseWorkflow.match( - /^ {2}cancel-in-progress: (?.+)$/mu, - )?.groups?.value; - - expect(group).toContain("release-publishers"); - expect(group).not.toContain("github.run_id"); - expect(group).toContain("main-edge"); - expect(cancellation).toContain("github.event_name == 'push'"); - expect(releaseWorkflow).toContain("Revalidate publication source"); - expect(releaseWorkflow).toContain( - "Confirm publication source remains current", - ); - expect(releaseWorkflow).toContain( - "ref: ${{ github.event.repository.default_branch }}", - ); - expect(releaseWorkflow.match(/fetch-depth: 0/gu)).toHaveLength(2); - expect(releaseWorkflow).toContain("include-hidden-files: true"); - expect(releaseWorkflow).toContain("https://$REGISTRY/token"); - expect(releaseWorkflow).toContain("Authorization: Bearer $bearer"); - expect(releaseWorkflow).not.toContain( - '--user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --header \'Accept:', - ); - expect(releaseWorkflow).toContain("steps.plan.outputs.tags_to_copy"); - expect(releaseWorkflow).toContain( - "steps.latest.outputs.publish_latest == 'true'", - ); - expect(releaseWorkflow).toContain("flavor: |\n latest=false"); - expect(releaseWorkflow).toContain( - 'gh api "repos/$GITHUB_REPOSITORY/releases/latest"', - ); - expect(releaseWorkflow).toContain( - '"$current_latest_release_tag" != "$RELEASE_TAG"', - ); - expect(releaseWorkflow).toContain("revalidate_source"); - expect(releaseWorkflow).toContain( - '"$current_latest_release_tag" == "$RELEASE_TAG"', - ); - expect(releaseWorkflow).toContain("Verify every published tag"); - expect(releaseWorkflow).toContain("tar --extract --to-stdout"); - expect(releaseWorkflow).not.toContain( - 'tar --extract --file "$OCI_ARCHIVE" --directory', - ); - expect(releaseWorkflow).toContain( - '"$published_digest" == "$EXPECTED_DIGEST"', - ); - expect(releaseWorkflow).toContain("copy --all"); - expect(releaseWorkflow).toContain("--preserve-digests"); - }); -}); diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..53a75d6 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.2.6 From 3b2148be2aa0f402f96f0d0fbbdbbe16252fcfe5 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 2 Oct 2026 13:44:03 -0400 Subject: [PATCH 2/6] fix(ci): harden release image publishing --- .github/workflows/prek_autoupdate.yml | 1 + .github/workflows/release-please.yml | 4 +- .github/workflows/release.yml | 62 ++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/.github/workflows/prek_autoupdate.yml b/.github/workflows/prek_autoupdate.yml index 98e11b8..9874e9e 100644 --- a/.github/workflows/prek_autoupdate.yml +++ b/.github/workflows/prek_autoupdate.yml @@ -30,4 +30,5 @@ jobs: token: ${{ secrets.PREK_AUTOUPDATE_TOKEN }} auto-merge: true commit-message: "deps: update prek hooks" + pr-title: "deps: update prek hooks" update-day: "1" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4a8468a..a48a1f0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -5,9 +5,7 @@ on: branches: - main -permissions: - contents: write - pull-requests: write +permissions: {} concurrency: group: release-please diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b135238..b9437ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,13 +40,50 @@ jobs: tags: | type=edge,branch=main,enable=${{ github.event_name == 'push' }} type=semver,pattern={{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} - type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v7 + - name: Refuse an existing release image version + if: github.event_name == 'release' + env: + IMAGE_TAG: ${{ steps.meta.outputs.version }} + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [[ -z "$IMAGE_TAG" || "$IMAGE_TAG" == "latest" ]]; then + printf 'release did not produce a semantic image version\n' >&2 + exit 1 + fi + + response_dir="$RUNNER_TEMP/ghcr-version-check" + mkdir -p "$response_dir" + token_status="$(curl --silent --show-error --output "$response_dir/token" --write-out '%{http_code}' --user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --get --data-urlencode "service=$REGISTRY" --data-urlencode "scope=repository:$IMAGE_NAME:pull" "https://$REGISTRY/token")" + if [[ "$token_status" != "200" ]]; then + printf 'GHCR token request returned HTTP %s\n' "$token_status" >&2 + exit 1 + fi + + registry_bearer="$(jq -er '.token // .access_token' "$response_dir/token")" + test -n "$registry_bearer" + manifest_status="$(curl --silent --show-error --output "$response_dir/manifest" --write-out '%{http_code}' --header "Authorization: Bearer $registry_bearer" --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' "https://$REGISTRY/v2/$IMAGE_NAME/manifests/$IMAGE_TAG")" + case "$manifest_status" in + 404) + printf 'Release image version %s is absent; publication may proceed.\n' "$IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY" + ;; + 200) + printf 'Refusing to overwrite existing release image version %s.\n' "$IMAGE_TAG" >&2 + exit 1 + ;; + *) + printf 'GHCR manifest lookup returned HTTP %s for release image version %s\n' "$manifest_status" "$IMAGE_TAG" >&2 + exit 1 + ;; + esac + - id: build + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 @@ -58,3 +95,24 @@ jobs: build-args: | SHELLPORT_VERSION=${{ steps.meta.outputs.version }} SHELLPORT_SOURCE_URL=https://github.com/Snuffy2/shellport/archive/${{ github.sha }}.tar.gz + - name: Publish latest stable release alias + id: latest + if: github.event_name == 'release' && !github.event.release.prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" + if [[ "$latest_release_tag" == "$RELEASE_TAG" ]]; then + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + printf 'build did not produce a valid image digest\n' >&2 + exit 1 + fi + docker buildx imagetools create --tag "$REGISTRY/$IMAGE_NAME:latest" "$REGISTRY/$IMAGE_NAME@$IMAGE_DIGEST" + printf 'publish_latest=true\n' >> "$GITHUB_OUTPUT" + else + printf 'publish_latest=false\n' >> "$GITHUB_OUTPUT" + printf 'Skipping latest: current stable release is %s, not %s.\n' "$latest_release_tag" "$RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY" + fi From 056d207e4d9325827074433353b530d3280aec25 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 2 Oct 2026 16:01:03 -0400 Subject: [PATCH 3/6] ci: use prek-autoupdate default commit and PR titles --- .github/workflows/prek_autoupdate.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/prek_autoupdate.yml b/.github/workflows/prek_autoupdate.yml index 9874e9e..596d094 100644 --- a/.github/workflows/prek_autoupdate.yml +++ b/.github/workflows/prek_autoupdate.yml @@ -29,6 +29,4 @@ jobs: with: token: ${{ secrets.PREK_AUTOUPDATE_TOKEN }} auto-merge: true - commit-message: "deps: update prek hooks" - pr-title: "deps: update prek hooks" update-day: "1" From d95166c5f8b94078a74a79338935f1b1c396e01c Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 2 Oct 2026 16:15:25 -0400 Subject: [PATCH 4/6] fix(ci): resume release alias publication on reruns Verify and reuse the existing GHCR version manifest digest instead of rejecting a partially completed release. Skip rebuilding immutable version tags and let the latest alias retry use the verified digest while retaining the current stable-release check. --- .github/workflows/release.yml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9437ae..708a3be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,8 @@ jobs: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Refuse an existing release image version + - name: Resolve existing release image version + id: existing if: github.event_name == 'release' env: IMAGE_TAG: ${{ steps.meta.outputs.version }} @@ -68,14 +69,25 @@ jobs: registry_bearer="$(jq -er '.token // .access_token' "$response_dir/token")" test -n "$registry_bearer" - manifest_status="$(curl --silent --show-error --output "$response_dir/manifest" --write-out '%{http_code}' --header "Authorization: Bearer $registry_bearer" --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' "https://$REGISTRY/v2/$IMAGE_NAME/manifests/$IMAGE_TAG")" + manifest_status="$(curl --silent --show-error --dump-header "$response_dir/headers" --output "$response_dir/manifest" --write-out '%{http_code}' --header "Authorization: Bearer $registry_bearer" --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' "https://$REGISTRY/v2/$IMAGE_NAME/manifests/$IMAGE_TAG")" case "$manifest_status" in 404) + printf 'exists=false\n' >> "$GITHUB_OUTPUT" printf 'Release image version %s is absent; publication may proceed.\n' "$IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY" ;; 200) - printf 'Refusing to overwrite existing release image version %s.\n' "$IMAGE_TAG" >&2 - exit 1 + registry_digest="$(tr -d '\r' < "$response_dir/headers" | awk 'tolower($1) == "docker-content-digest:" { digest = $2 } END { print digest }')" + if [[ ! "$registry_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + printf 'GHCR returned an invalid content digest for release image version %s\n' "$IMAGE_TAG" >&2 + exit 1 + fi + manifest_digest="sha256:$(sha256sum "$response_dir/manifest" | awk '{ print $1 }')" + if [[ "$registry_digest" != "$manifest_digest" ]]; then + printf 'GHCR content digest does not match release image version %s manifest\n' "$IMAGE_TAG" >&2 + exit 1 + fi + printf 'exists=true\ndigest=%s\n' "$registry_digest" >> "$GITHUB_OUTPUT" + printf 'Release image version %s already exists with verified digest %s; reusing it.\n' "$IMAGE_TAG" "$registry_digest" >> "$GITHUB_STEP_SUMMARY" ;; *) printf 'GHCR manifest lookup returned HTTP %s for release image version %s\n' "$manifest_status" "$IMAGE_TAG" >&2 @@ -83,6 +95,7 @@ jobs: ;; esac - id: build + if: github.event_name == 'push' || steps.existing.outputs.exists == 'false' uses: docker/build-push-action@v7 with: context: . @@ -101,7 +114,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ github.event.release.tag_name }} - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + IMAGE_DIGEST: ${{ steps.existing.outputs.digest || steps.build.outputs.digest }} run: | set -euo pipefail latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" From f1544e583c936a3ae072cf3ebe5287f1d57f8f97 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 2 Oct 2026 16:20:40 -0400 Subject: [PATCH 5/6] docs: clarify current stable release alias publishing Align the release summary with the workflow guard: version tags are published for releases, while latest is updated only for GitHub's current latest stable release. --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d714841..4a38874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,8 +146,8 @@ Release Please is configured in `.github/workflows/release-please.yml` and requi the `RELEASE_PLEASE_TOKEN` secret. It manages release PRs, version bump updates, and GitHub releases. Docker publishing is configured in `.github/workflows/release.yml` for GHCR image `ghcr.io/snuffy2/shellport`: main -pushes publish `edge`, and published releases publish version tags and `latest` -for stable releases. +pushes publish `edge`, published releases publish version tags, and only GitHub’s +current latest stable release updates `latest`. Do not push branches, publish images, or open pull requests unless the user explicitly asks. From 57aceaaff1e5a413390d3b31bb90d4eb1c1222ec Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 2 Oct 2026 16:27:06 -0400 Subject: [PATCH 6/6] ci: remove unused release workflow outputs Remove unreferenced step IDs and unused latest publication outputs while preserving release selection, digest reuse, and summary logging. Verified with the full prek suite using Go 1.26.8. --- .github/workflows/release-please.yml | 1 - .github/workflows/release.yml | 3 --- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index a48a1f0..9fc347d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -17,7 +17,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Create or update the release pull request - id: release uses: googleapis/release-please-action@v5 with: token: ${{ secrets.RELEASE_PLEASE_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 708a3be..04fc519 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,6 @@ jobs: SHELLPORT_VERSION=${{ steps.meta.outputs.version }} SHELLPORT_SOURCE_URL=https://github.com/Snuffy2/shellport/archive/${{ github.sha }}.tar.gz - name: Publish latest stable release alias - id: latest if: github.event_name == 'release' && !github.event.release.prerelease env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -124,8 +123,6 @@ jobs: exit 1 fi docker buildx imagetools create --tag "$REGISTRY/$IMAGE_NAME:latest" "$REGISTRY/$IMAGE_NAME@$IMAGE_DIGEST" - printf 'publish_latest=true\n' >> "$GITHUB_OUTPUT" else - printf 'publish_latest=false\n' >> "$GITHUB_OUTPUT" printf 'Skipping latest: current stable release is %s, not %s.\n' "$latest_release_tag" "$RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY" fi