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/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..9fc347d --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,24 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: {} + +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 + 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..04fc519 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,89 @@ 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' + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve existing release image version + id: existing + if: github.event_name == 'release' env: - IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} + IMAGE_TAG: ${{ steps.meta.outputs.version }} REGISTRY_USERNAME: ${{ github.actor }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - response_dir="$RUNNER_TEMP/immutable-version" - mkdir "$response_dir" + 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")" - [[ "$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 + 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 --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) - 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" ]] + 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" ;; - 404) ;; *) - printf 'immutable registry lookup returned HTTP %s\n' "$status" >&2 + printf 'GHCR manifest lookup returned HTTP %s for release image version %s\n' "$manifest_status" "$IMAGE_TAG" >&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" - - uses: docker/login-action@v4 + - id: build + if: github.event_name == 'push' || steps.existing.outputs.exists == 'false' + uses: docker/build-push-action@v7 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Upload verified OCI archive without rebuilding + 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 + - name: Publish latest stable release alias + if: github.event_name == 'release' && !github.event.release.prerelease 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 }} + IMAGE_DIGEST: ${{ steps.existing.outputs.digest || steps.build.outputs.digest }} 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 + 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 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" ]] + docker buildx imagetools create --tag "$REGISTRY/$IMAGE_NAME:latest" "$REGISTRY/$IMAGE_NAME@$IMAGE_DIGEST" + else + printf 'Skipping latest: current stable release is %s, not %s.\n' "$latest_release_tag" "$RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY" fi 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..4a38874 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`, 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. ## 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