Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Stage and validate ghd release assets generated by GoReleaser."""
"""Stage and validate release assets generated by GoReleaser."""

from __future__ import annotations

Expand All @@ -9,7 +9,6 @@
import os
import shutil
import sys
import tomllib
from pathlib import Path
from typing import Any

Expand All @@ -31,7 +30,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tag", required=True, help="Release tag, for example v1.2.3")
parser.add_argument("--artifacts", default=Path("dist/artifacts.json"), type=Path)
parser.add_argument("--config", default=Path("ghd.toml"), type=Path)
parser.add_argument("--output", default=Path("dist/release-assets"), type=Path)
parser.add_argument("--binary-name", default="mock-oidc")
return parser.parse_args(argv)
Expand All @@ -43,7 +41,6 @@ def main(argv: list[str] | None = None) -> int:
stage_release_assets(
tag=args.tag,
artifacts_path=args.artifacts,
config_path=args.config,
output_dir=args.output,
binary_name=args.binary_name,
)
Expand All @@ -57,17 +54,10 @@ def stage_release_assets(
*,
tag: str,
artifacts_path: Path,
config_path: Path,
output_dir: Path,
binary_name: str,
) -> None:
version = release_version(tag)
repository = os.environ.get("GITHUB_REPOSITORY")
if not repository:
raise StageError("GITHUB_REPOSITORY must be set")

config = load_toml(config_path)
validate_ghd_config(config, binary_name=binary_name, repository=repository)

artifacts = load_artifacts(artifacts_path)
stage_artifacts(artifacts, output_dir=output_dir, binary_name=binary_name)
Expand All @@ -89,20 +79,6 @@ def release_version(tag: str) -> str:
return tag[1:]


def load_toml(path: Path) -> dict[str, Any]:
try:
with path.open("rb") as config_file:
data = tomllib.load(config_file)
except FileNotFoundError as exc:
raise StageError(f"missing ghd config {path}") from exc
except tomllib.TOMLDecodeError as exc:
raise StageError(f"invalid TOML in {path}: {exc}") from exc

if not isinstance(data, dict):
raise StageError(f"ghd config {path} must be a TOML table")
return data


def load_artifacts(path: Path) -> list[dict[str, Any]]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
Expand All @@ -122,65 +98,6 @@ def load_artifacts(path: Path) -> list[dict[str, Any]]:
return artifacts


def validate_ghd_config(config: dict[str, Any], *, binary_name: str, repository: str) -> None:
provenance = config.get("provenance")
if not isinstance(provenance, dict):
raise StageError("ghd.toml must define a [provenance] table")

expected_signer = f"{repository}/.github/workflows/attest.yml"
actual_signer = provenance.get("signer_workflow")
if actual_signer != expected_signer:
raise StageError(
f"ghd.toml signer_workflow must be {expected_signer}, got {actual_signer}"
)

package = find_package(config, binary_name)
if package.get("tag_pattern") != "v${version}":
raise StageError(
f"ghd.toml package {binary_name!r} must use tag_pattern = \"v${{version}}\""
)

binaries = package.get("binaries")
if not isinstance(binaries, list) or not any(
isinstance(binary, dict) and binary.get("path") == binary_name for binary in binaries
):
raise StageError(
f"ghd.toml package {binary_name!r} must include binary path {binary_name!r}"
)

assets = package.get("assets")
if not isinstance(assets, list):
raise StageError(f"ghd.toml package {binary_name!r} must include assets")

actual_patterns = {
asset.get("pattern")
for asset in assets
if isinstance(asset, dict) and isinstance(asset.get("pattern"), str)
}
expected_patterns = {
f"{binary_name}_${{version}}_{goos}_{goarch}" for goos, goarch in EXPECTED_PLATFORMS
}
missing_patterns = sorted(expected_patterns - actual_patterns)
if missing_patterns:
raise StageError(f"ghd.toml is missing expected asset pattern(s): {', '.join(missing_patterns)}")


def find_package(config: dict[str, Any], binary_name: str) -> dict[str, Any]:
packages = config.get("packages")
if not isinstance(packages, list):
raise StageError("ghd.toml must define at least one [[packages]] entry")

matches = [
package
for package in packages
if isinstance(package, dict) and package.get("name") == binary_name
]
if len(matches) != 1:
raise StageError(f"ghd.toml must define exactly one package named {binary_name!r}")

return matches[0]


def stage_artifacts(
artifacts: list[dict[str, Any]],
*,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
from pathlib import Path


SCRIPT_PATH = Path(__file__).with_name("stage_ghd_release_assets.py")
SPEC = importlib.util.spec_from_file_location("stage_ghd_release_assets", SCRIPT_PATH)
SCRIPT_PATH = Path(__file__).with_name("stage_release_assets.py")
SPEC = importlib.util.spec_from_file_location("stage_release_assets", SCRIPT_PATH)
assert SPEC is not None
assert SPEC.loader is not None
stage_ghd_release_assets = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(stage_ghd_release_assets)
stage_release_assets = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(stage_release_assets)


PLATFORMS = (
Expand All @@ -37,20 +37,7 @@ def working_directory(path: Path):
os.chdir(original)


@contextlib.contextmanager
def github_repository(value: str):
original = os.environ.get("GITHUB_REPOSITORY")
os.environ["GITHUB_REPOSITORY"] = value
try:
yield
finally:
if original is None:
os.environ.pop("GITHUB_REPOSITORY", None)
else:
os.environ["GITHUB_REPOSITORY"] = original


class StageGhdReleaseAssetsTest(unittest.TestCase):
class StageReleaseAssetsTest(unittest.TestCase):
def test_stages_expected_assets(self) -> None:
with fixture() as root:
result, stdout, stderr = run_script(root)
Expand Down Expand Up @@ -92,13 +79,6 @@ def test_fails_on_checksum_mismatch(self) -> None:
self.assertEqual(result, 1)
self.assertIn("checksum mismatch for mock-oidc_1.2.3_linux_amd64", stderr)

def test_fails_on_wrong_signer_workflow(self) -> None:
with fixture(signer="other/repo/.github/workflows/release.yml") as root:
result, _, stderr = run_script(root)

self.assertEqual(result, 1)
self.assertIn("signer_workflow", stderr)

def test_fails_on_missing_os_arch_asset(self) -> None:
with fixture(omit_artifact=("linux", "arm64", "Binary")) as root:
result, _, stderr = run_script(root)
Expand All @@ -117,16 +97,15 @@ def test_fails_on_unexpected_asset_count(self) -> None:
def run_script(root: Path) -> tuple[int, str, str]:
stdout = io.StringIO()
stderr = io.StringIO()
with working_directory(root), github_repository("meigma/mock-oidc"):
with working_directory(root):
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
result = stage_ghd_release_assets.main(["--tag", "v1.2.3"])
result = stage_release_assets.main(["--tag", "v1.2.3"])
return result, stdout.getvalue(), stderr.getvalue()


@contextlib.contextmanager
def fixture(
*,
signer: str = "meigma/mock-oidc/.github/workflows/attest.yml",
missing_checksum: str | None = None,
checksum_override: tuple[str, str] | None = None,
omit_artifact: tuple[str, str, str] | None = None,
Expand All @@ -135,7 +114,6 @@ def fixture(
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "dist").mkdir()
write_ghd_toml(root / "ghd.toml", signer)

artifacts: list[dict[str, str]] = []
checksum_entries: dict[str, str] = {}
Expand Down Expand Up @@ -189,45 +167,6 @@ def fixture(
yield root


def write_ghd_toml(path: Path, signer: str) -> None:
path.write_text(
f'''version = 1

[provenance]
signer_workflow = "{signer}"

[[packages]]
name = "mock-oidc"
description = "Meigma Go repository template starter CLI."
tag_pattern = "v${{version}}"

[[packages.assets]]
os = "darwin"
arch = "amd64"
pattern = "mock-oidc_${{version}}_darwin_amd64"

[[packages.assets]]
os = "darwin"
arch = "arm64"
pattern = "mock-oidc_${{version}}_darwin_arm64"

[[packages.assets]]
os = "linux"
arch = "amd64"
pattern = "mock-oidc_${{version}}_linux_amd64"

[[packages.assets]]
os = "linux"
arch = "arm64"
pattern = "mock-oidc_${{version}}_linux_arm64"

[[packages.binaries]]
path = "mock-oidc"
''',
encoding="utf-8",
)


def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()

Expand Down
20 changes: 5 additions & 15 deletions .github/workflows/release-dry-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,13 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
GORELEASER_CURRENT_TAG: v0.0.0-dryrun.${{ github.run_id }}.${{ github.run_attempt }}

- name: Validate ghd-compatible dry-run artifacts
- name: Validate dry-run release artifacts
run: |
set -euo pipefail

version="0.0.0-dryrun.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}"
binary_name="mock-oidc"
asset_prefix="${binary_name}_"
expected_signer="${GITHUB_REPOSITORY}/.github/workflows/attest.yml"
actual_signer="$(awk -F'"' '/signer_workflow/ { print $2; exit }' ghd.toml)"

if [ "$actual_signer" != "$expected_signer" ]; then
echo "ghd.toml signer_workflow must be $expected_signer, got $actual_signer" >&2
exit 1
fi

grep -Fq "name = \"$binary_name\"" ghd.toml
grep -Fq "tag_pattern = \"v\${version}\"" ghd.toml
grep -Fq "path = \"$binary_name\"" ghd.toml

jq -e --arg asset_prefix "$asset_prefix" '
[
Expand All @@ -94,11 +83,9 @@ jobs:
for arch in amd64 arm64; do
expected="${binary_name}_${version}_${os}_${arch}"
expected_sbom="${expected}.sbom.json"
expected_pattern="pattern = \"${binary_name}_\${version}_${os}_${arch}\""

jq -e --arg name "$expected" '.[] | select(.type == "Binary" and .name == $name)' dist/artifacts.json >/dev/null
jq -e --arg name "$expected_sbom" '.[] | select(.type == "SBOM" and .name == $name)' dist/artifacts.json >/dev/null
grep -Fq "$expected_pattern" ghd.toml
awk -v name="$expected" '$2 == name && length($1) == 64 && $1 ~ /^[[:xdigit:]]+$/ { found = 1 } END { exit(found ? 0 : 1) }' dist/checksums.txt
done
done
Expand Down Expand Up @@ -251,4 +238,7 @@ jobs:
docker tag mock-oidc:dry-run-amd64 mock-oidc:dry-run

docker run --rm mock-oidc:dry-run --version
docker run --rm mock-oidc:dry-run openapi | grep -Fq "openapi: 3.0.3"
# Capture before grepping: grep -q exits at the first match and the
# resulting broken pipe fails `docker run` under pipefail.
docker run --rm mock-oidc:dry-run openapi > openapi-smoke.yaml
grep -Fq "openapi: 3.0.3" openapi-smoke.yaml
10 changes: 6 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}

- name: Stage and validate ghd release assets
- name: Stage and validate release assets
env:
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
run: python3 .github/scripts/stage_ghd_release_assets.py --tag "$RELEASE_TAG"
run: python3 .github/scripts/stage_release_assets.py --tag "$RELEASE_TAG"

- name: Smoke test release binary
env:
Expand Down Expand Up @@ -363,7 +363,10 @@ jobs:
set -euo pipefail

docker run --rm "$IMAGE_REF" --version
docker run --rm "$IMAGE_REF" openapi | grep -Fq "openapi: 3.0.3"
# Capture before grepping: grep -q exits at the first match and the
# resulting broken pipe fails `docker run` under pipefail.
docker run --rm "$IMAGE_REF" openapi > openapi-smoke.yaml
grep -Fq "openapi: 3.0.3" openapi-smoke.yaml

- name: Sign image (keyless, Sigstore/Fulcio via OIDC)
env:
Expand Down Expand Up @@ -427,7 +430,6 @@ jobs:
echo "gh release view $RELEASE_TAG --repo $GITHUB_REPOSITORY --json isDraft,assets"
echo "asset=\"mock-oidc_${RELEASE_VERSION}_\$(go env GOOS)_\$(go env GOARCH)\""
echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners"
echo "ghd download \"$GITHUB_REPOSITORY/mock-oidc@${RELEASE_VERSION}\" --output \"\$(mktemp -d)\""
echo '```'
echo
echo "Container verification commands:"
Expand Down