From 5b58489fa5e6acd0d79a74388493031fe02734be Mon Sep 17 00:00:00 2001 From: a Date: Sat, 18 Jul 2026 14:55:50 +0000 Subject: [PATCH 1/2] feat(release): publish verified SemVer images --- .github/workflows/release.yml | 483 ++++++++++++++++++ Cargo.toml | 2 +- .../extract-and-standardize-pggomtm/tasks.md | 2 +- 3 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c31cc4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,483 @@ +name: Release + +on: + push: + tags: + - "v[0-9]*.[0-9]*.[0-9]*" + +permissions: + contents: read + +concurrency: + group: mtmpg-release + cancel-in-progress: false + +jobs: + metadata: + name: Validate SemVer tag + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + prerelease: ${{ steps.version.outputs.prerelease }} + version: ${{ steps.version.outputs.version }} + + steps: + - name: Validate release identity + id: version + shell: bash + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = "codeh007/mtmpg" + test "$GITHUB_REF_TYPE" = "tag" + tag="$GITHUB_REF_NAME" + [[ "$tag" == v* ]] + version="$(cut -c2- <<<"$tag")" + identifier='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' + semver="^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-$identifier([.]$identifier)*)?$" + [[ "$version" =~ $semver ]] + test "$tag" = "v$version" + test "$(printf '%s' "$version" | wc --bytes)" -le 120 + + prerelease=false + if [[ "$version" == *-* ]]; then + prerelease=true + fi + { + echo "prerelease=$prerelease" + echo "version=$version" + } >>"$GITHUB_OUTPUT" + + ci: + name: Verify release source + needs: metadata + permissions: + contents: read + uses: ./.github/workflows/ci.yml + with: + upload_release_artifacts: true + release_version: ${{ needs.metadata.outputs.version }} + + publish: + name: Publish verified release + if: github.repository == 'codeh007/mtmpg' + needs: + - metadata + - ci + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: write + packages: write + id-token: write + attestations: write + artifact-metadata: write + env: + GH_TOKEN: ${{ github.token }} + IMAGE_REPOSITORY: ghcr.io/codeh007/mtmpg + PRERELEASE: ${{ needs.metadata.outputs.prerelease }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.metadata.outputs.version }} + + steps: + - name: Download verified release materials + uses: actions/download-artifact@v8 + with: + name: ${{ needs.ci.outputs.release_artifact }} + path: ${{ runner.temp }}/release-materials + + - name: Install release inspection tools + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends ca-certificates curl jq skopeo + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Validate immutable tag and verified materials + env: + CI_SOURCE: ${{ needs.ci.outputs.source }} + CI_VERSION: ${{ needs.ci.outputs.version }} + shell: bash + run: | + set -euo pipefail + material_root="$RUNNER_TEMP/release-materials" + asset_root="$RUNNER_TEMP/release-assets" + archive="$material_root/mtmpg.oci.tar" + for required in Cargo.lock resolved-inputs.json verified-image.json mtmpg.oci.tar; do + test -f "$material_root/$required" + test ! -L "$material_root/$required" + done + + test "$TAG" = "$GITHUB_REF_NAME" + test "$CI_SOURCE" = "$GITHUB_SHA" + test "$CI_VERSION" = "$VERSION" + test "$(jq --raw-output '.schema' "$material_root/resolved-inputs.json")" = "mtmpg-resolved-inputs/v1" + test "$(jq --raw-output '.schema' "$material_root/verified-image.json")" = "mtmpg-verified-image/v1" + test "$(jq --raw-output '.source' "$material_root/resolved-inputs.json")" = "$GITHUB_SHA" + test "$(jq --raw-output '.source' "$material_root/verified-image.json")" = "$GITHUB_SHA" + test "$(jq --raw-output '.version' "$material_root/resolved-inputs.json")" = "$VERSION" + test "$(jq --raw-output '.version' "$material_root/verified-image.json")" = "$VERSION" + test "$(jq --raw-output '.workflow.run_id' "$material_root/resolved-inputs.json")" = "$GITHUB_RUN_ID" + test "$(jq --raw-output '.workflow.run_attempt' "$material_root/resolved-inputs.json")" = "$GITHUB_RUN_ATTEMPT" + + lock_sha256="$(sha256sum "$material_root/Cargo.lock" | cut -d' ' -f1)" + resolved_sha256="$(sha256sum "$material_root/resolved-inputs.json" | cut -d' ' -f1)" + archive_sha256="$(sha256sum "$archive" | cut -d' ' -f1)" + archive_digest="$(skopeo inspect --format '{{.Digest}}' "oci-archive:$archive")" + test "$lock_sha256" = "$(jq --raw-output '.cargo_lock_sha256' "$material_root/resolved-inputs.json")" + test "$lock_sha256" = "$(jq --raw-output '.cargo_lock_sha256' "$material_root/verified-image.json")" + test "$resolved_sha256" = "$(jq --raw-output '.resolved_inputs_sha256' "$material_root/verified-image.json")" + test "$archive_sha256" = "$(jq --raw-output '.oci_archive_sha256' "$material_root/verified-image.json")" + test "$archive_digest" = "$(jq --raw-output '.oci_manifest_digest' "$material_root/verified-image.json")" + + tag_source="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" + test "$tag_source" = "$GITHUB_SHA" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "GitHub Release already exists for $TAG" >&2 + exit 1 + fi + + auth_file="$HOME/.docker/config.json" + test -f "$auth_file" + target_reference="$IMAGE_REPOSITORY:$VERSION" + if skopeo inspect --authfile "$auth_file" "docker://$target_reference" >/dev/null 2>&1; then + echo "OCI version already exists: $target_reference" >&2 + exit 1 + fi + + latest_before=absent + if skopeo inspect --authfile "$auth_file" "docker://$IMAGE_REPOSITORY:latest" >/dev/null 2>&1; then + latest_before="$(skopeo inspect --authfile "$auth_file" --format '{{.Digest}}' "docker://$IMAGE_REPOSITORY:latest")" + fi + + install -d -m 0755 "$asset_root" + { + echo "ARCHIVE=$archive" + echo "ASSET_ROOT=$asset_root" + echo "LATEST_BEFORE=$latest_before" + echo "MATERIAL_ROOT=$material_root" + echo "TARGET_REFERENCE=$target_reference" + } >>"$GITHUB_ENV" + + - name: Push the verified OCI archive once + shell: bash + run: | + set -euo pipefail + digest_file="$RUNNER_TEMP/registry-digest.txt" + skopeo copy \ + --authfile "$HOME/.docker/config.json" \ + --preserve-digests \ + --digestfile "$digest_file" \ + "oci-archive:$ARCHIVE" \ + "docker://$TARGET_REFERENCE" + registry_digest="$(tr -d '\n' <"$digest_file")" + [[ "$registry_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + test "$registry_digest" = "$(jq --raw-output '.oci_manifest_digest' "$MATERIAL_ROOT/verified-image.json")" + test "$registry_digest" = "$(skopeo inspect --authfile "$HOME/.docker/config.json" --format '{{.Digest}}' "docker://$TARGET_REFERENCE")" + echo "REGISTRY_DIGEST=$registry_digest" >>"$GITHUB_ENV" + + - name: Download current Syft + id: syft + uses: anchore/sbom-action/download-syft@v0 + + - name: Generate release manifest and SPDX SBOM + env: + SYFT_BIN: ${{ steps.syft.outputs.cmd }} + shell: bash + run: | + set -euo pipefail + install -m 0644 \ + "$MATERIAL_ROOT/Cargo.lock" \ + "$MATERIAL_ROOT/resolved-inputs.json" \ + "$MATERIAL_ROOT/verified-image.json" \ + "$ASSET_ROOT" + + "$SYFT_BIN" \ + "oci-archive:$ARCHIVE" \ + --output "spdx-json=$ASSET_ROOT/sbom.spdx.json" + jq --exit-status \ + '(.spdxVersion | startswith("SPDX-")) and ((.packages // []) | length > 0)' \ + "$ASSET_ROOT/sbom.spdx.json" >/dev/null + + jq --null-input \ + --slurpfile resolved "$MATERIAL_ROOT/resolved-inputs.json" \ + --slurpfile verified "$MATERIAL_ROOT/verified-image.json" \ + --arg image_repository "$IMAGE_REPOSITORY" \ + --arg registry_digest "$REGISTRY_DIGEST" \ + --arg sbom_sha256 "$(sha256sum "$ASSET_ROOT/sbom.spdx.json" | cut -d' ' -f1)" \ + --arg skopeo "$(skopeo --version)" \ + --arg syft "$("$SYFT_BIN" version -o json | jq --raw-output '.version')" \ + --arg tag "$TAG" \ + --argjson prerelease "$PRERELEASE" \ + '{ + schema: "mtmpg-release-manifest/v1", + version: $verified[0].version, + tag: $tag, + prerelease: $prerelease, + source: $verified[0].source, + workflow: $resolved[0].workflow, + cargo_lock_sha256: $verified[0].cargo_lock_sha256, + resolved_inputs_sha256: $verified[0].resolved_inputs_sha256, + rust: $resolved[0].rust, + pgrx: $resolved[0].pgrx, + postgresql: $resolved[0].postgresql, + module: { + path: "/usr/lib/postgresql/18/lib/pggomtm.so", + sha256: $verified[0].module_sha256 + }, + oci: { + archive_sha256: $verified[0].oci_archive_sha256, + manifest_digest: $verified[0].oci_manifest_digest, + repository: $image_repository, + reference: ($image_repository + ":" + $verified[0].version), + registry_digest: $registry_digest + }, + sbom: { + format: "spdx-json", + sha256: $sbom_sha256 + }, + tooling: { + skopeo: $skopeo, + syft: $syft + } + }' >"$ASSET_ROOT/release-manifest.json" + + - name: Attest release provenance + id: provenance + uses: actions/attest@v4 + with: + subject-name: ${{ env.IMAGE_REPOSITORY }} + subject-digest: ${{ env.REGISTRY_DIGEST }} + subject-version: ${{ env.VERSION }} + push-to-registry: true + + - name: Attest release SBOM + id: sbom-attestation + uses: actions/attest@v4 + with: + subject-name: ${{ env.IMAGE_REPOSITORY }} + subject-digest: ${{ env.REGISTRY_DIGEST }} + subject-version: ${{ env.VERSION }} + sbom-path: ${{ env.ASSET_ROOT }}/sbom.spdx.json + push-to-registry: true + + - name: Finalize standard release materials + shell: bash + run: | + set -euo pipefail + provenance_bundle="${{ steps.provenance.outputs.bundle-path }}" + provenance_id="${{ steps.provenance.outputs.attestation-id }}" + provenance_url="${{ steps.provenance.outputs.attestation-url }}" + sbom_bundle="${{ steps.sbom-attestation.outputs.bundle-path }}" + sbom_id="${{ steps.sbom-attestation.outputs.attestation-id }}" + sbom_url="${{ steps.sbom-attestation.outputs.attestation-url }}" + test -s "$provenance_bundle" + test -s "$sbom_bundle" + test -n "$provenance_id" + test -n "$provenance_url" + test -n "$sbom_id" + test -n "$sbom_url" + + install -m 0644 "$provenance_bundle" "$ASSET_ROOT/provenance.intoto.jsonl" + install -m 0644 "$sbom_bundle" "$ASSET_ROOT/sbom-attestation.intoto.jsonl" + jq --slurp --exit-status \ + 'length > 0 and all(.[]; has("dsseEnvelope") and has("verificationMaterial"))' \ + "$ASSET_ROOT/provenance.intoto.jsonl" >/dev/null + jq --slurp --exit-status \ + 'length > 0 and all(.[]; has("dsseEnvelope") and has("verificationMaterial"))' \ + "$ASSET_ROOT/sbom-attestation.intoto.jsonl" >/dev/null + + jq --null-input \ + --arg subject "$IMAGE_REPOSITORY@$REGISTRY_DIGEST" \ + --arg provenance_id "$provenance_id" \ + --arg provenance_url "$provenance_url" \ + --arg sbom_id "$sbom_id" \ + --arg sbom_url "$sbom_url" \ + '{ + schema: "mtmpg-attestation/v1", + subject: $subject, + provenance: {id: $provenance_id, url: $provenance_url}, + sbom: {id: $sbom_id, url: $sbom_url} + }' >"$ASSET_ROOT/attestation.json" + + manifest_next="$RUNNER_TEMP/release-manifest.next.json" + jq \ + --arg provenance_sha256 "$(sha256sum "$ASSET_ROOT/provenance.intoto.jsonl" | cut -d' ' -f1)" \ + --arg sbom_attestation_sha256 "$(sha256sum "$ASSET_ROOT/sbom-attestation.intoto.jsonl" | cut -d' ' -f1)" \ + --arg provenance_id "$provenance_id" \ + --arg provenance_url "$provenance_url" \ + --arg sbom_id "$sbom_id" \ + --arg sbom_url "$sbom_url" \ + '. + { + provenance: { + format: "in-toto-jsonl", + sha256: $provenance_sha256 + }, + attestation: { + provenance: {id: $provenance_id, url: $provenance_url}, + sbom: {id: $sbom_id, url: $sbom_url, sha256: $sbom_attestation_sha256} + } + }' \ + "$ASSET_ROOT/release-manifest.json" >"$manifest_next" + mv "$manifest_next" "$ASSET_ROOT/release-manifest.json" + + ( + cd "$ASSET_ROOT" + sha256sum \ + Cargo.lock \ + resolved-inputs.json \ + verified-image.json \ + release-manifest.json \ + sbom.spdx.json \ + provenance.intoto.jsonl \ + sbom-attestation.intoto.jsonl \ + attestation.json \ + >checksums.txt + sha256sum --check checksums.txt + ) + + - name: Anonymously verify the versioned image + shell: bash + run: | + set -euo pipefail + anonymous_auth="$RUNNER_TEMP/anonymous-auth.json" + anonymous_inspect="$RUNNER_TEMP/anonymous-inspect.json" + printf '{"auths":{}}\n' >"$anonymous_auth" + + ready=0 + for _ in $(seq 1 120); do + if skopeo inspect \ + --authfile "$anonymous_auth" \ + "docker://$TARGET_REFERENCE" \ + >"$anonymous_inspect" 2>/dev/null; then + ready=1 + break + fi + sleep 5 + done + test "$ready" -eq 1 + jq --exit-status \ + --arg digest "$REGISTRY_DIGEST" \ + --arg source "$GITHUB_SHA" \ + --arg version "$VERSION" \ + '.Digest == $digest and + .Labels["org.opencontainers.image.revision"] == $source and + .Labels["org.opencontainers.image.version"] == $version' \ + "$anonymous_inspect" >/dev/null + + skopeo copy \ + --authfile "$anonymous_auth" \ + "docker://$TARGET_REFERENCE" \ + "docker-archive:$RUNNER_TEMP/anonymous-image.tar:mtmpg-verify:$VERSION" + docker load --input "$RUNNER_TEMP/anonymous-image.tar" >/dev/null + container_id="$(docker create "mtmpg-verify:$VERSION")" + docker cp \ + "$container_id:/usr/lib/postgresql/18/lib/pggomtm.so" \ + "$RUNNER_TEMP/anonymous-pggomtm.so" + docker rm "$container_id" >/dev/null + test "$(sha256sum "$RUNNER_TEMP/anonymous-pggomtm.so" | cut -d' ' -f1)" = \ + "$(jq --raw-output '.module_sha256' "$MATERIAL_ROOT/verified-image.json")" + + attestation_count="$(gh api "repos/$GITHUB_REPOSITORY/attestations/$REGISTRY_DIGEST?per_page=10" --jq '.attestations | length')" + test "$attestation_count" -ge 2 + + - name: Create immutable GitHub Release + shell: bash + run: | + set -euo pipefail + release_notes="PostgreSQL 18 OAuth validator image: $IMAGE_REPOSITORY:$VERSION + + Source: $GITHUB_SHA + OCI digest: $REGISTRY_DIGEST" + files=( + "$ASSET_ROOT/Cargo.lock" + "$ASSET_ROOT/resolved-inputs.json" + "$ASSET_ROOT/verified-image.json" + "$ASSET_ROOT/release-manifest.json" + "$ASSET_ROOT/sbom.spdx.json" + "$ASSET_ROOT/provenance.intoto.jsonl" + "$ASSET_ROOT/sbom-attestation.intoto.jsonl" + "$ASSET_ROOT/attestation.json" + "$ASSET_ROOT/checksums.txt" + ) + if test "$PRERELEASE" = true; then + gh release create "$TAG" "${files[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --prerelease \ + --title "mtmpg $VERSION" \ + --notes "$release_notes" + else + gh release create "$TAG" "${files[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --latest \ + --title "mtmpg $VERSION" \ + --notes "$release_notes" + fi + + - name: Verify public Release assets and latest policy + shell: bash + run: | + set -euo pipefail + release_json="$RUNNER_TEMP/public-release.json" + curl --fail --location --retry 5 \ + --header 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/$GITHUB_REPOSITORY/releases/tags/$TAG" \ + >"$release_json" + jq --exit-status \ + --arg tag "$TAG" \ + --argjson prerelease "$PRERELEASE" \ + '.tag_name == $tag and .draft == false and .prerelease == $prerelease' \ + "$release_json" >/dev/null + + verify_root="$RUNNER_TEMP/public-release-assets" + install -d -m 0755 "$verify_root" + for name in \ + Cargo.lock \ + resolved-inputs.json \ + verified-image.json \ + release-manifest.json \ + sbom.spdx.json \ + provenance.intoto.jsonl \ + sbom-attestation.intoto.jsonl \ + attestation.json \ + checksums.txt; do + asset_url="$(jq --raw-output --arg name "$name" '.assets[] | select(.name == $name) | .browser_download_url' "$release_json")" + test -n "$asset_url" + curl --fail --location --retry 5 "$asset_url" --output "$verify_root/$name" + done + ( + cd "$verify_root" + sha256sum --check checksums.txt + ) + jq --exit-status \ + --arg source "$GITHUB_SHA" \ + --arg version "$VERSION" \ + --arg digest "$REGISTRY_DIGEST" \ + '.source == $source and .version == $version and + .oci.manifest_digest == $digest and + .oci.registry_digest == $digest' \ + "$verify_root/release-manifest.json" >/dev/null + + auth_file="$HOME/.docker/config.json" + if test "$PRERELEASE" = true; then + if test "$LATEST_BEFORE" = absent; then + ! skopeo inspect --authfile "$auth_file" "docker://$IMAGE_REPOSITORY:latest" >/dev/null 2>&1 + else + test "$(skopeo inspect --authfile "$auth_file" --format '{{.Digest}}' "docker://$IMAGE_REPOSITORY:latest")" = "$LATEST_BEFORE" + fi + else + skopeo copy \ + --authfile "$auth_file" \ + --preserve-digests \ + "oci-archive:$ARCHIVE" \ + "docker://$IMAGE_REPOSITORY:latest" + test "$(skopeo inspect --authfile "$auth_file" --format '{{.Digest}}' "docker://$IMAGE_REPOSITORY:latest")" = "$REGISTRY_DIGEST" + fi diff --git a/Cargo.toml b/Cargo.toml index b0c73d8..9a09d1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pggomtm" -version = "0.1.0" +version = "0.1.0-rc.1" edition = "2024" license = "MIT" publish = false diff --git a/openspec/changes/extract-and-standardize-pggomtm/tasks.md b/openspec/changes/extract-and-standardize-pggomtm/tasks.md index bbc3e1e..b1faefc 100644 --- a/openspec/changes/extract-and-standardize-pggomtm/tasks.md +++ b/openspec/changes/extract-and-standardize-pggomtm/tasks.md @@ -54,7 +54,7 @@ - [x] 6.3 用支持`pull_request`、`main` push与`workflow_call`的可复用只读`ci.yml`替代复杂`native-ci.yml`,移除candidate、ORAS evidence和跨仓consumer逻辑 - [x] 6.4 让可复用CI在release调用时上传同一run已验证OCI archive、Cargo.lock、resolved inputs与manifest输入,并证明后续publish无需重新运行Cargo或Docker build - [x] 6.5 配置required CI与GitHub原生auto-merge,只允许owner、Agent或批准的Dependabot PR自动合并,外部PR保持人工批准且维护者/Agent可直接非force推进`main` -- [ ] 6.6 取得PR与`main`的精确远端成功run,证明两者没有packages、Release、attestation或跨仓写权限,并更新README/维护文档的CI入口 +- [x] 6.6 取得PR与`main`的精确远端成功run,证明两者没有packages、Release、attestation或跨仓写权限,并更新README/维护文档的CI入口 ## 7. 首个标准SemVer prerelease From 6ddb149f1ea724b74c641b4f69a9b5876248002a Mon Sep 17 00:00:00 2001 From: a Date: Sat, 18 Jul 2026 14:57:56 +0000 Subject: [PATCH 2/2] fix(release): align prerelease validation --- tests/image-readiness.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/image-readiness.sh b/tests/image-readiness.sh index 37fbfde..9aba35b 100755 --- a/tests/image-readiness.sh +++ b/tests/image-readiness.sh @@ -23,8 +23,9 @@ ARTIFACT_ROOT="$(realpath -- "$4")" || fail "artifact directory cannot be resolv readonly ARTIFACT_ROOT [[ "${SOURCE_REVISION}" =~ ^[0-9a-f]{40}$ ]] || fail "source must be a full Git commit" -[[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+(\.[0-9]+)*)?$ ]] || \ - fail "version is not an mtmpg SemVer candidate" +readonly SEMVER_IDENTIFIER='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' +readonly SEMVER_PATTERN="^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-${SEMVER_IDENTIFIER}([.]${SEMVER_IDENTIFIER})*)?$" +[[ "${VERSION}" =~ ${SEMVER_PATTERN} ]] || fail "version is not an mtmpg SemVer" test -x "${ARTIFACT_ROOT}/pggomtm_oauth_smoke_client" || fail "OAuth client is unavailable" test -x "${ARTIFACT_ROOT}/pggomtm_oauth_smoke_fixture" || fail "OAuth fixture is unavailable" command -v "${DOCKER_BIN}" >/dev/null || fail "Docker is unavailable"