diff --git a/.buildkite/pinned-runtime-proof.py b/.buildkite/pinned-runtime-proof.py index 188b94c..c4ceebf 100644 --- a/.buildkite/pinned-runtime-proof.py +++ b/.buildkite/pinned-runtime-proof.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Opt-in hosted comparison. All agent and HTTP calls use real services.""" +import base64 import hashlib import json import os @@ -127,5 +128,113 @@ def execute(producer): print("PROOF PASS: same saved plan, real artifacts, real Job API, identity, permissions, status, cold/warm and bad digest", flush=True) +def platforms(): + """Test the plugin before a released importer emits the expanded contract.""" + assert os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" + version = os.environ.get("PINNED_PROOF_VERSION", "0.133.0") + assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) + plugin_ref = os.environ.get("PINNED_PROOF_PLUGIN_REF", os.environ["BUILDKITE_COMMIT"]) + assert re.fullmatch(r"[0-9a-f]{40}|v[0-9]+\.[0-9]+\.[0-9]+", plugin_ref) + source = os.environ.get("PINNED_PROOF_PLAN_SOURCE", "producer") + assert source in ("producer", "inline", "env") + namespace = os.environ.get("PINNED_PROOF_NAMESPACE") == "1" + repository = os.environ.get("PINNED_PROOF_REPOSITORY", "buildkite-plugins/github-actions-buildkite-plugin") + assert re.fullmatch(r"[\w.-]+/[\w.-]+", repository) + owner, name = repository.split("/") + producer = os.environ["BUILDKITE_JOB_ID"] + endpoint = os.environ["BUILDKITE_AGENT_ENDPOINT"].rstrip("/") + assert endpoint.startswith("https://") + request = urllib.request.Request(f"{endpoint}/jobs/{producer}/github-actions/releases/tags/v{version}", + headers={"Authorization": "Token " + os.environ["BUILDKITE_AGENT_ACCESS_TOKEN"]}) + with urllib.request.urlopen(request) as response: + release = json.load(response) + assert release["tag_name"] == "v" + version + names = {"linux/amd64": "linux_x86_64", "linux/arm64": "linux_arm64", + "darwin/arm64": "darwin_arm64", "windows/amd64": "windows_x86_64.exe"} + assets = {} + for platform, suffix in names.items(): + matches = [asset for asset in release["assets"] if asset["name"] == "buildkite-gha_" + suffix] + assert len(matches) == 1 + asset = matches[0] + assert asset["browser_download_url"].startswith("https://") + assert re.fullmatch(r"sha256:[0-9a-f]{64}", asset["digest"]) + assets[platform] = asset + with tempfile.TemporaryDirectory(prefix="platform-runtime-import-") as temporary: + root = Path(temporary) + binary = root / "buildkite-gha" + with urllib.request.urlopen(assets["linux/amd64"]["browser_download_url"]) as response: + binary.write_bytes(response.read()) + assert "sha256:" + hashlib.sha256(binary.read_bytes()).hexdigest() == assets["linux/amd64"]["digest"] + binary.chmod(0o700) + event = root / "event.json" + event.write_text(json.dumps({"provider": "github", "event": "workflow_dispatch", + "repository": {"owner": owner, "name": name, + "clone_url": "https://github.com/" + repository + ".git", "default_branch": "main"}, + "ref": "refs/heads/" + os.environ["BUILDKITE_BRANCH"], "sha": os.environ["BUILDKITE_COMMIT"], + "actor": "buildkite-gha-proof", "payload": {"inputs": {"marker": "platform-proof", "namespace": namespace}}})) + os.environ["PROOF_REAL_AGENT"] = shutil.which("buildkite-agent") + os.environ["PROOF_ROOT"] = str(root) + wrapper = root / "buildkite-agent" + wrapper.write_text(f'#!/bin/sh\nexec python3 "{SCRIPT}" capture "$@"\n') + wrapper.chmod(0o700) + env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', BUILDKITE_GHA_PLUGIN_RELEASE=json.dumps(release)) + arguments = [str(binary), "upload", "--event-path", str(event)] + for label, key in [("ubuntu-latest", "LINUX"), ("ubuntu-24.04-arm", "ARM"), ("macos-14", "MAC"), ("windows-2022", "WINDOWS")]: + arguments += ["--runner-queue", label + "=" + os.environ["PINNED_PROOF_" + key + "_QUEUE"]] + for platform, asset in assets.items(): + if platform == "linux/amd64": + continue + counterpart = root / asset["name"] + with urllib.request.urlopen(asset["browser_download_url"]) as response: + counterpart.write_bytes(response.read()) + assert "sha256:" + hashlib.sha256(counterpart.read_bytes()).hexdigest() == asset["digest"] + counterpart.chmod(0o700) + arguments += ["--runtime-distribution", platform + "=" + str(counterpart)] + subprocess.run([*arguments, ".github/workflows/pinned-runtime-platforms.yml"], env=env, check=True) + pipeline = json.loads(agent("pipeline", "upload", "--dry-run", "--no-interpolation", "--format", "json", str(root / "pipeline.yml"))) + steps = [job for group in pipeline["steps"] for job in group.get("steps", [group])] + records = [] + for step in steps: + digest = step.get("env", {}).get("BUILDKITE_GHA_PLAN_DIGEST") + assert digest, "expected an admitted executable plan" + path = ".buildkite-gha/plans/" + digest.removeprefix("sha256:") + ".json" + agent("artifact", "download", path, str(root), "--step", producer) + plan_bytes = (root / path).read_bytes() + assert "sha256:" + hashlib.sha256(plan_bytes).hexdigest() == digest + plan = json.loads(plan_bytes) + platform = next(platform for platform, asset in assets.items() if asset["digest"] == plan["runtime"]["distribution_digest"]) + asset = assets[platform] + run = {"plan": {"digest": digest, "producer": producer}, + "runtime": {"platform": platform, "url": asset["browser_download_url"], "sha256": asset["digest"][7:]}, + "hosted-tool-cache": "--hosted-tool-cache" in step["command"]} + if namespace and platform == "linux/amd64": + step.pop("image", None) + step["agents"]["nsc-gha-image"] = "ubuntu-24.04" + run.update({"namespace": True, "hosted-tool-cache": False}) + if source != "producer": + run["plan"] = {"digest": digest, "inline": base64.b64encode(plan_bytes).decode()} + event_path = ".buildkite-gha/events/" + plan["event"]["payload_digest"][7:] + ".json" + agent("artifact", "download", event_path, str(root), "--step", producer) + payload = base64.b64encode((root / event_path).read_bytes()).decode() + if source == "env": + run["event"] = {"env": "BUILDKITE_GHA_EVENT_PAYLOAD"} + step.setdefault("env", {})["BUILDKITE_GHA_EVENT_PAYLOAD"] = payload + else: + run["event"] = {"inline": payload} + # The plugin-stage proof deliberately changes only delivery. Later + # importer proofs must submit their emitted steps without rewriting. + step.pop("command", None) + step["plugins"] = [{"github-actions#" + plugin_ref: {"run": run}}] + step.setdefault("env", {})["BUILDKITE_PLUGIN_VALIDATION"] = "true" + records.append({"key": step["key"], "platform": platform, "plan": digest, "source": source, + "runtime": asset["digest"], "event": plan["event"]["payload_digest"]}) + assert len(records) == 8 and {r["platform"] for r in records} == set(names) + receipt = root / "platform-runtime-plans.json" + receipt.write_text(json.dumps(records, indent=2)) + agent("artifact", "upload", receipt.name, cwd=root) + agent("pipeline", "upload", "--no-interpolation", input=json.dumps(pipeline).encode()) + print(f"Scheduled {len(records)} native jobs with plugin {plugin_ref}, runtime v{version}, source={source}") + + if __name__ == "__main__": - {"import": prepare, "capture": capture, "probe": probe, "run": lambda: execute(sys.argv[2])}[sys.argv[1]]() + {"import": prepare, "capture": capture, "probe": probe, "platforms": platforms, "run": lambda: execute(sys.argv[2])}[sys.argv[1]]() diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 7e12e2b..146d35e 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -18,11 +18,11 @@ steps: plugins: - shellcheck#v1.4.0: files: - - hooks/* + - hooks/command - lib/* - label: ":pipeline: Load live plugin smoke" key: "live-plugin-smoke-loader" - if: build.env("PLUGIN_PINNED_RUNTIME_PROOF") != "1" + if: build.env("PLUGIN_PINNED_RUNTIME_PROOF") != "1" && build.env("PLUGIN_PINNED_RUNTIME_PROOF") != "platforms" agents: queue: "hosted" timeout_in_minutes: 15 @@ -147,3 +147,12 @@ steps: retry: automatic: false command: python3 .buildkite/pinned-runtime-proof.py import + - label: ":test_tube: Schedule native pinned runtime proof" + key: "platform-runtime-proof-importer" + if: build.env("PLUGIN_PINNED_RUNTIME_PROOF") == "platforms" + agents: + queue: "hosted" + timeout_in_minutes: 20 + retry: + automatic: false + command: python3 .buildkite/pinned-runtime-proof.py platforms diff --git a/.github/workflows/pinned-runtime-platforms.yml b/.github/workflows/pinned-runtime-platforms.yml new file mode 100644 index 0000000..a20b0fe --- /dev/null +++ b/.github/workflows/pinned-runtime-platforms.yml @@ -0,0 +1,96 @@ +name: Pinned runtime platform proof +on: workflow_dispatch +permissions: + contents: read +jobs: + linux: + runs-on: ubuntu-latest + outputs: + value: ${{ steps.probe.outputs.value }} + steps: + - id: probe + env: + TOKEN: ${{ github.token }} + NAMESPACE: ${{ github.event.inputs.namespace }} + run: | + test -n "$TOKEN" + test -z "${BUILDKITE_AGENT_ACCESS_TOKEN:-}" + test ! -e .git + test "$(id -un)" = runner + test "$HOME" = /home/runner + sudo -n true + test "$RUNNER_ARCH" = X64 + if test "$NAMESPACE" = true; then + rustc --version + printf 'FROM scratch\nLABEL proof=namespace\n' | docker build --load --platform linux/amd64 -t pinned-runtime-proof - + docker image inspect pinned-runtime-proof >/dev/null + docker image rm pinned-runtime-proof + fi + echo 'value=41' >> "$GITHUB_OUTPUT" + echo 'PLATFORM_PROOF linux/amd64 value=41 token=present' + arm: + needs: linux + runs-on: ubuntu-24.04-arm + outputs: + value: ${{ steps.probe.outputs.value }} + steps: + - id: probe + env: + VALUE: ${{ needs.linux.outputs.value }} + TOKEN: ${{ github.token }} + run: | + test "$VALUE" = 41 + test -n "$TOKEN" + test -z "${BUILDKITE_AGENT_ACCESS_TOKEN:-}" + test ! -e .git + test "$(id -un)" = runner + test "$HOME" = /home/runner + sudo -n true + test "$RUNNER_ARCH" = ARM64 + echo 'value=42' >> "$GITHUB_OUTPUT" + echo 'PLATFORM_PROOF linux/arm64 value=42 token=present' + mac: + needs: arm + runs-on: macos-14 + outputs: + value: ${{ steps.probe.outputs.value }} + steps: + - id: probe + env: + VALUE: ${{ needs.arm.outputs.value }} + TOKEN: ${{ github.token }} + run: | + test "$VALUE" = 42 + test -n "$TOKEN" + test -z "${BUILDKITE_AGENT_ACCESS_TOKEN:-}" + test ! -e .git + test "$RUNNER_OS" = macOS + test "$RUNNER_ARCH" = ARM64 + test -w /Users/runner/hostedtoolcache + echo 'value=43' >> "$GITHUB_OUTPUT" + echo 'PLATFORM_PROOF darwin/arm64 value=43 token=present' + windows: + needs: mac + runs-on: windows-2022 + steps: + - env: + VALUE: ${{ needs.mac.outputs.value }} + TOKEN: ${{ github.token }} + run: | + if ($env:VALUE -ne '43') { throw 'output transport failed' } + if (-not $env:TOKEN) { throw 'missing provider token' } + if ($env:BUILDKITE_AGENT_ACCESS_TOKEN) { throw 'agent token leaked' } + if (Test-Path .git) { throw 'checkout was not skipped' } + if ($env:RUNNER_OS -ne 'Windows' -or $env:RUNNER_ARCH -ne 'X64') { throw 'wrong platform' } + Write-Output 'PLATFORM_PROOF windows/amd64 value=43 token=present' + failure: + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + continue-on-error: true + steps: + - shell: bash + run: | + echo "PLATFORM_PROOF $RUNNER_OS/$RUNNER_ARCH expected failure=37" + exit 37 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 207e563..c6acade 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -38,7 +38,7 @@ Each map URL also accepts a checksum fragment. The plugin downloads only its imp ## Binary acquisition -For custom importers, use a Linux amd64/arm64 or native macOS arm64 agent with the dependencies in [`plugin.yml`](plugin.yml) and outbound HTTPS access to Buildkite and GitHub. Windows importers are not supported. The importer's `agents` configuration selects its queue; `runners` mappings select generated jobs, not the importer. +For custom importers, use a Linux amd64/arm64 or native macOS arm64 agent with Bash, curl, jq, mktemp, and outbound HTTPS access to Buildkite and GitHub. Windows importers are not supported. The importer's `agents` configuration selects its queue; `runners` mappings select generated jobs, not the importer. Linux also requires `sha256sum`; macOS requires `shasum`. Historical release archives require `tar`. These dependencies are used by the hook only on their applicable paths, not declared as unconditional agent plugin requirements. @@ -52,6 +52,8 @@ The plugin validates the release tag, selected asset URL, and SHA256 digest on c The plugin passes the resolved release to the CLI in `BUILDKITE_GHA_PLUGIN_RELEASE`. CLIs with metadata-handoff support use it for counterpart selection without another API request. Historical CLIs retain their own counterpart acquisition. Custom binaries clear this internal environment variable. +The importer hook also sets `BUILDKITE_GHA_PLUGIN_MULTIPLATFORM_RUN=1` to advertise this plugin's expanded execution support. Release metadata alone does not establish that capability: older plugin pins must retain their existing delivery path when paired with a newer CLI. + Official binaries and checksum-pinned custom binaries use a local cache keyed by platform and digest. Each invocation copies cached bytes into a private temporary directory and verifies them before execution. Missing or corrupt entries are downloaded and verified before replacing the cache entry. Plain custom URLs are downloaded each time. Custom URLs never use the release metadata cache. A warm release import makes no network requests for metadata or binaries until its metadata expires. Refreshing metadata does not download the binary again when its digest is unchanged. Historical releases cache the verified archive by platform and archive digest, then extract the executable into the private temporary directory on each invocation. Warm imports need no archive download. Historical release assets and exact version pins remain unchanged. @@ -75,7 +77,7 @@ In importer mode, the plugin validates acquisition properties only. All other op ## Execute a pinned plan -The `run` option launches an existing static Linux job plan instead of importing a workflow. It cannot be combined with importer options. Neither the Go nor server-side importer selects this mode automatically. +The `run` option launches an existing job plan instead of importing a workflow. It cannot be combined with importer options. Importers select this mode only for their supported delivery contracts. Pin the plugin revision as well as the runtime bytes. Replace the plugin reference, plan digest, and producer UUID below with the values recorded for the build: @@ -93,15 +95,23 @@ plugins: hosted-tool-cache: true ``` -`plan.producer` is the exact job UUID holding `.buildkite-gha/plans/.json` and any event artifact, not a step key that could select a different retry. Alternatively, use `plan.path` for a local plan file with the same required digest. Local plans must not require event artifacts from another job. The plugin copies the plan; it never rewrites its bytes or source, event, target, or runtime bindings. +`plan.producer` is the exact job UUID holding `.buildkite-gha/plans/.json` and any event artifact, not a step key that could select a different retry. Alternatively, use `plan.path` for a local plan file or `plan.inline` for base64-encoded plan JSON, with the same required digest. Select exactly one source. The plugin copies or decodes the plan; it never rewrites its bytes or source, event, target, or runtime bindings. + +Local and inline plans can supply `event: {inline: }` or `event: {env: BUILDKITE_GHA_EVENT_PAYLOAD}`. The latter reads base64 from that separate environment variable and unsets it before execution, keeping large events out of the plugin configuration's per-string environment limit. Select exactly one event source. The plugin verifies decoded bytes against the verified plan's `event.payload_digest`, requires `event.payload_artifact: true`, and uploads `.buildkite-gha/events/.json` from the real execution job. It then passes that job's UUID to `run-job --artifact-producer`. The decoded event limit is 64 KiB. An event source cannot override `plan.producer`; without one, local plans must not require an event artifact. Importers retain their own workflow size limits. Payload bytes are not diagnostic output. -The runtime URL must point to a raw executable over HTTPS, without credentials or a fragment. SHA-256 is mandatory. The platform must match the host (`linux/amd64` or `linux/arm64`). Acquisition reuses the importer's platform-and-digest cache and verifies the private copy before execution. A missing or corrupt entry downloads only that pinned URL. Execution never reads release metadata or resolves `latest`, including on retries or clean checkouts. +The runtime URL must point to a raw executable over HTTPS, without credentials or a fragment. SHA-256 is mandatory. The platform must match the host: `linux/amd64`, `linux/arm64`, `darwin/arm64`, or `windows/amd64`. Acquisition reuses the platform-and-digest cache and verifies the private copy before execution. A missing or corrupt entry downloads only that pinned URL. Execution never reads release metadata or resolves `latest`, including on retries or clean checkouts. Start the command hook as root or as `runner` with passwordless sudo, on a disposable Linux worker. In addition to the acquisition dependencies, setup requires `getent`, `useradd`, `usermod`, `install`, `sudo`, `chown`, `chmod`, and `find`; Docker socket access may require `groupadd`. Artifact references require `buildkite-agent`. Setup provisions the same non-root `runner` identity, `/home/runner`, passwordless sudo and Docker socket access as the existing generated Linux bootstrap. It grants access to the agent's Job API socket, keeps the plan and executable root-owned and non-writable by `runner`, and preserves PATH, the incoming umask, and runtime job credentials. This is compatibility setup, not a security boundary against a workflow with sudo. -`hosted-tool-cache: true` grants `runner` access to an existing `/opt/hostedtoolcache` and passes `--hosted-tool-cache` to the runtime. It defaults to false for workers without that directory. An existing `BUILDKITE_GHA_MISE_DATA_DIR` and its sibling `runtime` directory are made writable by `runner`; the runtime still owns tool installation. Runtime execution does not set `MISE_DATA_DIR` to the launcher's binary cache. Custom cache-volume ownership and Namespace-specific host options are not part of this entry point; keep those jobs on their existing launcher. +`hosted-tool-cache: true` grants `runner` access to an existing `/opt/hostedtoolcache` on Linux and passes `--hosted-tool-cache` to the runtime. It defaults to false. An existing `BUILDKITE_GHA_MISE_DATA_DIR` and its sibling `runtime` directory are made writable by `runner`; the runtime still owns tool installation. Runtime execution does not set `MISE_DATA_DIR` to the launcher's binary cache. Custom cache-volume ownership remains on the existing launcher. + +macOS keeps the agent's current identity, HOME and PATH. When hosted tool caching is enabled, setup creates `/Users/runner/hostedtoolcache` and grants the current user ownership, matching the generated macOS launcher. It requires passwordless sudo for that setup, not Linux user provisioning. -The released `run-job` command still validates the plan format, required capabilities, runtime digest, host and target, and hydrates digest-bound event artifacts. Its exit status passes through unchanged (for example, a failed workflow shell command normally yields runtime status 1, not the shell command's status). Existing emitted commands and importer configuration are unchanged. Continuation uploads, macOS and Windows execution remain on their existing paths. +Windows uses the native `command.ps1` hook with PowerShell 7, `curl.exe`, and `buildkite-agent`. It keeps the agent's identity and environment, verifies bytes with SHA-256, and preserves the runtime exit status. Its cache uses `BUILDKITE_GHA_CACHE_DIR`, `MISE_DATA_DIR`, or `BUILDKITE_AGENT_DATA_PATH` in the same order as Unix, then `%LOCALAPPDATA%/buildkite-gha`. Windows supports execution only, not imports. + +`namespace: true` selects native Namespace image setup on Linux or macOS: single-node Docker builders load their output into the local daemon; multi-node builders still require `--load`. Linux also restores image tool paths from `/etc/environment` and enables runtime hostname repair. It does not select agents or enable hosted tool caching; those remain the producer's decisions. Do not set it for other worker images. + +The released `run-job` command still validates the plan format, required capabilities, runtime digest, host and target, and hydrates digest-bound event artifacts. Its exit status passes through unchanged (for example, a failed workflow shell command normally yields runtime status 1, not the shell command's status). Existing emitted commands and importer configuration are unchanged. Continuation uploads retain their existing path. ## Run local checks @@ -130,6 +140,8 @@ These checks exercise real released CLI integration on Linux x86-64 and native m For an explicitly authorized disposable Linux launcher proof, set `PLUGIN_PINNED_RUNTIME_PROOF=1`. This replaces the normal smoke graph with one released-v0.129.0 importer and one execution job. The job runs the emitted legacy command and the candidate hook from the exact checkout against the same saved plan. It compares identity, permissions, output and exit status, reads a harmless variable through the real Job API, and checks cold/warm downloads and bad-digest rejection. Artifact uploads and downloads use the real agent; the proof only intercepts pipeline emission to install the comparison command. It does not exercise plugin checkout installation, cross-worker cache persistence or other platforms. +For the four-platform scheduled plugin gate, set `PLUGIN_PINNED_RUNTIME_PROOF=platforms`, `GITHUB_EVENT_NAME=workflow_dispatch`, and `PINNED_PROOF_LINUX_QUEUE`, `PINNED_PROOF_ARM_QUEUE`, `PINNED_PROOF_MAC_QUEUE`, and `PINNED_PROOF_WINDOWS_QUEUE` to existing matching queues. `PINNED_PROOF_VERSION` defaults to `0.133.0`; `PINNED_PROOF_PLUGIN_REF` defaults to the build commit. Run with `PINNED_PROOF_PLAN_SOURCE=producer` and again with `inline` or `env`. Set `PINNED_PROOF_NAMESPACE=1` with a compatible Linux queue to exercise native image tools and Docker loading. The proof uses released plans and binaries, normal plugin installation, cross-platform output transport, provider tokens, and allowed-failure commands. It replaces legacy commands with the plugin contract before scheduling, so it proves the consumer, not unchanged importer emission. Inspect job logs, result manifests, and the `platform-runtime-plans.json` receipt; a green soft-failed job alone does not prove its workflow ran. Later importer gates must schedule their emitted jobs unchanged. None of these checks proves cross-worker cache reuse or scheduler retries. + ## Release the plugin Plugin releases are immutable Git tags with asset-free GitHub Releases. The release tasks require `gh` authenticated with write access to `buildkite-plugins/github-actions-buildkite-plugin`: diff --git a/README.md b/README.md index e60e9eb..bc51dc2 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ You can pin the plugin with an exact tag and the `buildkite-gha` runtime with `v steps: - label: ":github: GitHub Actions" plugin: - github-actions#v0.15.1: + github-actions#v0.16.0: version: v0.124.0 ``` diff --git a/hooks/command b/hooks/command index 2fd91bd..72334ec 100755 --- a/hooks/command +++ b/hooks/command @@ -2,6 +2,7 @@ set -euo pipefail original_umask="$(umask)" umask 077 +unset BUILDKITE_GHA_PLUGIN_MULTIPLATFORM_RUN error() { echo "github-actions plugin: $*" >&2; exit 1; } download() { @@ -114,18 +115,23 @@ if jq -e 'has("run")' <<< "$configuration" >/dev/null; then jq -e ' def text: type == "string" and length > 0 and (test("[\u0000-\u001f\u007f]") | not); def digest: type == "string" and test("^sha256:[0-9a-f]{64}$") and length == 71; + def base64: text and test("^[A-Za-z0-9+/]+={0,2}$") and length % 4 == 0; keys == ["run"] and (.run | type == "object" and - (keys - ["plan", "runtime", "hosted-tool-cache"] | length == 0) and + (keys - ["plan", "event", "runtime", "hosted-tool-cache", "namespace"] | length == 0) and (.plan | type == "object" and - (keys == ["digest", "path"] or keys == ["digest", "producer"]) and + (keys == ["digest", "path"] or keys == ["digest", "producer"] or keys == ["digest", "inline"]) and (.digest | digest) and - (if has("path") then .path | text else .producer | type == "string" and test("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") and length == 36 end)) and + (if has("path") then .path | text elif has("inline") then .inline | base64 else .producer | type == "string" and test("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") and length == 36 end)) and + (if has("event") then (.plan | has("producer") | not) and (.event | type == "object" and + ((keys == ["inline"] and (.inline | base64 and length <= 87384)) or + (keys == ["env"] and .env == "BUILDKITE_GHA_EVENT_PAYLOAD" and (env.BUILDKITE_GHA_EVENT_PAYLOAD | base64 and length <= 87384)))) else true end) and (.runtime | type == "object" and keys == ["platform", "sha256", "url"] and - (.platform == "linux/amd64" or .platform == "linux/arm64") and + (.platform == "linux/amd64" or .platform == "linux/arm64" or .platform == "darwin/arm64") and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$") and length == 64) and (.url | type == "string" and test("^https://[^/@\\s?#]+([/?][^\\s#]*)?$") and (test("[\u0000-\u0020\u007f]") | not))) and - (if has("hosted-tool-cache") then .["hosted-tool-cache"] | type == "boolean" else true end)) - ' <<< "$configuration" >/dev/null || error "run requires a plan digest and path or producer, and a pinned Linux runtime platform, HTTPS URL and SHA256; importer options cannot be combined with run" + (if has("hosted-tool-cache") then .["hosted-tool-cache"] | type == "boolean" else true end) and + (if has("namespace") then .namespace | type == "boolean" else true end)) + ' <<< "$configuration" >/dev/null || error "run requires a plan digest and path, inline or producer, and a pinned runtime platform, HTTPS URL and SHA256; importer options cannot be combined with run" [[ "$(jq -r '.run.runtime.platform' <<< "$configuration")" == "$platform" ]] || error "runtime platform does not match '$platform'" binary_url="$(jq -r '.run.runtime | .url + "#sha256=" + .sha256' <<< "$configuration")" elif jq -e 'has("binary")' <<< "$configuration" >/dev/null; then @@ -218,24 +224,76 @@ chmod 0700 "$runtime_dir/buildkite-gha" if "$execution"; then plan_digest="$(jq -r '.run.plan.digest' <<< "$configuration")" producer="$(jq -r '.run.plan.producer // empty' <<< "$configuration")" + decode=(-d) + [[ "$platform" != darwin/arm64 ]] || decode=(-D) if [[ -n "$producer" ]]; then plan_path=".buildkite-gha/plans/${plan_digest#sha256:}.json" buildkite-agent artifact download "$plan_path" "$runtime_dir" --step "$producer" mv -- "$runtime_dir/$plan_path" "$runtime_dir/plan.json" + elif jq -e '.run.plan | has("inline")' <<< "$configuration" >/dev/null; then + jq -r '.run.plan.inline' <<< "$configuration" | base64 "${decode[@]}" > "$runtime_dir/plan.json" else cp -- "$(jq -r '.run.plan.path' <<< "$configuration")" "$runtime_dir/plan.json" fi [[ "$(sha256 "$runtime_dir/plan.json")" == "${plan_digest#sha256:}" ]] || error "plan checksum verification failed" + if jq -e '.run | has("event")' <<< "$configuration" >/dev/null; then + producer="${BUILDKITE_JOB_ID:-}" + [[ "$producer" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || error "inline event requires the execution job UUID" + event_digest="$(jq -er '.event | select(.payload_artifact == true) | .payload_digest | select(type == "string" and test("^sha256:[0-9a-f]{64}$") and length == 71)' "$runtime_dir/plan.json" 2>/dev/null)" || error "plan requires a digest-bound event artifact" + event_path=".buildkite-gha/events/${event_digest#sha256:}.json" + mkdir -p "$runtime_dir/.buildkite-gha/events" + if jq -e '.run.event | has("env")' <<< "$configuration" >/dev/null; then + event_base64="$BUILDKITE_GHA_EVENT_PAYLOAD" + unset BUILDKITE_GHA_EVENT_PAYLOAD + else + event_base64="$(jq -r '.run.event.inline' <<< "$configuration")" + fi + printf '%s' "$event_base64" | base64 "${decode[@]}" > "$runtime_dir/$event_path" + unset event_base64 + [[ "$(wc -c < "$runtime_dir/$event_path")" -le 65536 ]] || error "inline event exceeds 64 KiB" + [[ "$(sha256 "$runtime_dir/$event_path")" == "${event_digest#sha256:}" ]] || error "event checksum verification failed" + (cd "$runtime_dir" && buildkite-agent artifact upload "$event_path") + fi + hosted_tool_cache="$(jq -r '.run["hosted-tool-cache"] // false' <<< "$configuration")" + arguments=(run-job --plan "$runtime_dir/plan.json") + [[ -z "$producer" ]] || arguments+=(--artifact-producer "$producer") + [[ "$hosted_tool_cache" == false ]] || arguments+=(--hosted-tool-cache) + if [[ "$(jq -r '.run.namespace // false' <<< "$configuration")" == true ]]; then + arguments+=(--docker-build-load) + if [[ "$platform" == linux/* ]]; then + # Restore image tool paths that the agent service does not read via PAM. + etc_path="$(sed -n 's/^PATH=//p' /etc/environment 2>/dev/null | tail -n 1)" + etc_path="${etc_path#\"}"; etc_path="${etc_path%\"}" + missing_path='' + IFS=: read -r -a etc_entries <<< "$etc_path" + for entry in "${etc_entries[@]}"; do + case ":$PATH:$missing_path:" in + *":$entry:"*) ;; + *) [[ -z "$entry" ]] || missing_path="$missing_path${missing_path:+:}$entry" ;; + esac + done + [[ -z "$missing_path" ]] || export PATH="$missing_path:$PATH" + arguments+=(--repair-hostname) + fi + fi + if [[ "$platform" == darwin/arm64 ]]; then + # Host directories need the agent's normal permissions, not the private + # download directory's umask (which can hide /Users/runner from the agent). + umask "$original_umask" + if [[ "$hosted_tool_cache" == true ]]; then + sudo -n mkdir -p /Users/runner/hostedtoolcache + sudo -n chown -R "$(id -u):$(id -g)" /Users/runner/hostedtoolcache + sudo -n chmod -R u+rwX /Users/runner/hostedtoolcache + fi + BUILDKITE_GHA_PLAN_DIGEST="$plan_digest" "$runtime_dir/buildkite-gha" "${arguments[@]}" + exit $? + fi setup=() if [[ "$(id -u)" != 0 ]]; then [[ "$(id -un)" == runner ]] || error "runtime bootstrap must start as root or runner" setup=(sudo -n --preserve-env --) fi - hosted_tool_cache="$(jq -r '.run["hosted-tool-cache"] // false' <<< "$configuration")" "${setup[@]}" bash "$(dirname "${BASH_SOURCE[0]}")/../lib/runner-setup" "$runtime_dir" "$hosted_tool_cache" - arguments=(run-job --plan "$runtime_dir/plan.json") - [[ -z "$producer" ]] || arguments+=(--artifact-producer "$producer") - [[ "$hosted_tool_cache" == false ]] || arguments+=(--hosted-tool-cache) # Keep the job credentials and native tool PATH across sudo, just as the # existing generated bootstrap does. The runtime still validates all bindings. umask "$original_umask" @@ -246,4 +304,5 @@ fi if [[ -z "${MISE_DATA_DIR:-}" && -n "$cache" ]]; then export MISE_DATA_DIR="$cache" fi +export BUILDKITE_GHA_PLUGIN_MULTIPLATFORM_RUN=1 "$runtime_dir/buildkite-gha" plugin diff --git a/hooks/command.ps1 b/hooks/command.ps1 new file mode 100644 index 0000000..c37432c --- /dev/null +++ b/hooks/command.ps1 @@ -0,0 +1,153 @@ +# Windows is an execution target, not an importer host. +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + & pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $PSCommandPath + exit $LASTEXITCODE +} +$runtimeStatus = 1 +$runtimeDir = $null + +function Assert-Keys($value, [string[]]$required, [string[]]$optional = @()) { + if ($value -isnot [System.Collections.IDictionary]) { throw 'expected an object' } + foreach ($key in $required) { + if (-not $value.Contains($key)) { throw "missing $key" } + } + foreach ($key in $value.Keys) { + if ($key -cnotin ($required + $optional)) { throw "unknown option $key" } + } +} + +function Assert-Text($value, [string]$pattern) { + if ($value -isnot [string] -or $value -cmatch '[\x00-\x1f\x7f]' -or $value -cnotmatch $pattern) { + throw 'invalid pinned runtime or plan reference' + } +} + +function Test-Digest([string]$path, [string]$expected) { + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $expected +} + +try { + try { $configuration = ConvertFrom-Json -AsHashtable $env:BUILDKITE_PLUGIN_CONFIGURATION } + catch { throw 'configuration must be a JSON object' } + Assert-Keys $configuration @('run') + $run = $configuration.run + Assert-Keys $run @('plan', 'runtime') @('event', 'hosted-tool-cache', 'namespace') + Assert-Keys $run.plan @('digest') @('path', 'producer', 'inline') + if (@('path', 'producer', 'inline').Where({ $run.plan.Contains($_) }).Count -ne 1) { throw 'plan requires exactly one of path, inline or producer' } + Assert-Text $run.plan.digest '^sha256:[0-9a-f]{64}$' + if ($run.plan.Contains('path')) { + Assert-Text $run.plan.path '^.+$' + } elseif ($run.plan.Contains('inline')) { + Assert-Text $run.plan.inline '^[A-Za-z0-9+/]+={0,2}$' + } else { + Assert-Text $run.plan.producer '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + } + if ($run.Contains('event')) { + if ($run.plan.Contains('producer')) { throw 'inline event cannot override a plan producer' } + Assert-Keys $run.event @() @('inline', 'env') + if ($run.event.Contains('inline') -eq $run.event.Contains('env')) { throw 'event requires exactly one of inline or env' } + if ($run.event.Contains('env')) { + if ($run.event.env -cne 'BUILDKITE_GHA_EVENT_PAYLOAD') { throw 'unsupported event environment variable' } + $eventBase64 = $env:BUILDKITE_GHA_EVENT_PAYLOAD + Remove-Item Env:BUILDKITE_GHA_EVENT_PAYLOAD -ErrorAction SilentlyContinue + } else { $eventBase64 = $run.event.inline } + Assert-Text $eventBase64 '^[A-Za-z0-9+/]+={0,2}$' + if ($eventBase64.Length -gt 87384) { throw 'inline event exceeds 64 KiB' } + } + Assert-Keys $run.runtime @('platform', 'url', 'sha256') + if ($run.runtime.platform -cne 'windows/amd64' -or -not $IsWindows -or + [Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'X64') { + throw 'runtime platform must match windows/amd64' + } + Assert-Text $run.runtime.url '^https://[^/@\s?#]+([/?][^\s#]*)?$' + Assert-Text $run.runtime.sha256 '^[0-9a-fA-F]{64}$' + foreach ($key in @('hosted-tool-cache', 'namespace')) { + if ($run.Contains($key) -and $run[$key] -isnot [bool]) { throw "$key must be boolean" } + } + if ($run.namespace) { throw 'Namespace setup is not supported on Windows' } + Remove-Item Env:BUILDKITE_GHA_PLUGIN_RELEASE -ErrorAction SilentlyContinue + $runtimeDir = Join-Path ([IO.Path]::GetTempPath()) ('buildkite-gha-' + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $runtimeDir | Out-Null + $binary = Join-Path $runtimeDir 'buildkite-gha.exe' + $digest = $run.runtime.sha256.ToLowerInvariant() + $cacheRoot = $null + if ($env:BUILDKITE_GHA_CACHE_DIR) { $cacheRoot = $env:BUILDKITE_GHA_CACHE_DIR } + elseif ($env:MISE_DATA_DIR) { $cacheRoot = Join-Path $env:MISE_DATA_DIR 'buildkite-gha' } + elseif ($env:BUILDKITE_AGENT_DATA_PATH) { $cacheRoot = Join-Path $env:BUILDKITE_AGENT_DATA_PATH 'cache/buildkite-gha' } + elseif ($env:LOCALAPPDATA) { $cacheRoot = Join-Path $env:LOCALAPPDATA 'buildkite-gha' } + $cached = $null + if ($cacheRoot -and [IO.Path]::IsPathFullyQualified($cacheRoot)) { + $cached = Join-Path $cacheRoot "binaries/windows-amd64/$digest" + } + $cacheHit = $false + if ($cached -and (Test-Path -LiteralPath $cached -PathType Leaf)) { + Copy-Item -LiteralPath $cached -Destination $binary + $cacheHit = Test-Digest $binary $digest + } + if (-not $cacheHit) { + Write-Host '--- :github: Prepare pinned runtime' + & curl.exe --disable --fail --silent --show-error --location --globoff --proto '=https' --proto-redir '=https' --tlsv1.2 $run.runtime.url --output $binary + if ($LASTEXITCODE -ne 0) { throw "runtime download failed with exit code $LASTEXITCODE" } + if (-not (Test-Digest $binary $digest)) { throw 'binary checksum verification failed' } + if ($cached) { + $staged = "$cached.$([Guid]::NewGuid().ToString('N'))" + try { + New-Item -ItemType Directory -Force -Path (Split-Path $cached) | Out-Null + Copy-Item -LiteralPath $binary -Destination $staged + Move-Item -LiteralPath $staged -Destination $cached -Force + } catch { + Write-Warning 'github-actions plugin: could not write cache' + } finally { + Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue + } + } + } + $plan = Join-Path $runtimeDir 'plan.json' + if ($run.plan.producer) { + $artifact = '.buildkite-gha/plans/' + $run.plan.digest.Substring(7) + '.json' + & buildkite-agent artifact download $artifact $runtimeDir --step $run.plan.producer + if ($LASTEXITCODE -ne 0) { throw "plan download failed with exit code $LASTEXITCODE" } + Move-Item -LiteralPath (Join-Path $runtimeDir $artifact) -Destination $plan + } elseif ($run.plan.inline) { + try { $bytes = [Convert]::FromBase64String($run.plan.inline) } + catch { throw 'invalid inline plan encoding' } + [IO.File]::WriteAllBytes($plan, $bytes) + } else { + Copy-Item -LiteralPath $run.plan.path -Destination $plan + } + if (-not (Test-Digest $plan $run.plan.digest.Substring(7))) { throw 'plan checksum verification failed' } + $producer = $run.plan.producer + if ($run.Contains('event')) { + $producer = $env:BUILDKITE_JOB_ID + Assert-Text $producer '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + try { $eventBinding = (Get-Content -Raw -LiteralPath $plan | ConvertFrom-Json -AsHashtable).event } + catch { throw 'invalid plan JSON' } + if ($eventBinding.payload_artifact -cne $true) { throw 'plan requires a digest-bound event artifact' } + Assert-Text $eventBinding.payload_digest '^sha256:[0-9a-f]{64}$' + try { $bytes = [Convert]::FromBase64String($eventBase64) } + catch { throw 'invalid inline event encoding' } + if ($bytes.Length -gt 65536) { throw 'inline event exceeds 64 KiB' } + $eventPath = '.buildkite-gha/events/' + $eventBinding.payload_digest.Substring(7) + '.json' + New-Item -ItemType Directory -Force -Path (Join-Path $runtimeDir '.buildkite-gha/events') | Out-Null + $eventFile = Join-Path $runtimeDir $eventPath + [IO.File]::WriteAllBytes($eventFile, $bytes) + if (-not (Test-Digest $eventFile $eventBinding.payload_digest.Substring(7))) { throw 'event checksum verification failed' } + Push-Location $runtimeDir + try { + & buildkite-agent artifact upload $eventPath + if ($LASTEXITCODE -ne 0) { throw "event upload failed with exit code $LASTEXITCODE" } + } finally { Pop-Location } + } + $arguments = @('run-job', '--plan', $plan) + if ($producer) { $arguments += @('--artifact-producer', $producer) } + if ($run['hosted-tool-cache']) { $arguments += '--hosted-tool-cache' } + $env:BUILDKITE_GHA_PLAN_DIGEST = $run.plan.digest + & $binary @arguments + $runtimeStatus = $LASTEXITCODE +} catch { + Write-Error "github-actions plugin: $_" -ErrorAction Continue +} finally { + if ($runtimeDir) { Remove-Item -LiteralPath $runtimeDir -Recurse -Force -ErrorAction SilentlyContinue } +} +exit $runtimeStatus diff --git a/mise.toml b/mise.toml index 82dd259..06a4ee3 100644 --- a/mise.toml +++ b/mise.toml @@ -12,7 +12,7 @@ run = "bats tests" [tasks.lint] description = "Run ShellCheck" -run = "shellcheck hooks/* lib/*" +run = "shellcheck hooks/command lib/*" [tasks.check] description = "Run all local checks" diff --git a/plugin.yml b/plugin.yml index a51a11c..c1a2333 100644 --- a/plugin.yml +++ b/plugin.yml @@ -2,10 +2,7 @@ name: GitHub Actions description: Run GitHub Actions workflows as native Buildkite steps author: https://github.com/buildkite-plugins requirements: - - bash - curl - - jq - - mktemp configuration: type: object oneOf: @@ -32,6 +29,7 @@ configuration: oneOf: - required: [path] - required: [producer] + - required: [inline] properties: digest: type: string @@ -40,17 +38,38 @@ configuration: type: string minLength: 1 description: Local plan file, copied and verified before use. + inline: + type: string + minLength: 1 + pattern: '^[A-Za-z0-9+/]+={0,2}$' + description: Base64-encoded plan JSON, verified without rewriting its bytes. producer: type: string pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' description: Exact Buildkite job UUID holding the content-addressed plan and event artifacts. + event: + type: object + oneOf: + - required: [inline] + - required: [env] + additionalProperties: false + properties: + env: + enum: [BUILDKITE_GHA_EVENT_PAYLOAD] + description: Read base64 event JSON from this environment variable, then unset it before execution. + inline: + type: string + minLength: 1 + maxLength: 87384 + pattern: '^[A-Za-z0-9+/]+={0,2}$' + description: Base64-encoded event JSON, at most 64 KiB decoded, bound to the plan and uploaded by the execution job. runtime: type: object required: [platform, url, sha256] additionalProperties: false properties: platform: - enum: [linux/amd64, linux/arm64] + enum: [linux/amd64, linux/arm64, darwin/arm64, windows/amd64] url: type: string pattern: '^https://[^/@\s?#]+([/?][^\s#]*)?$' @@ -60,7 +79,11 @@ configuration: hosted-tool-cache: type: boolean default: false - description: Grant runner access to the existing /opt/hostedtoolcache and enable it in run-job. + description: Prepare the platform's hosted tool cache and enable it in run-job. + namespace: + type: boolean + default: false + description: Preserve native Namespace image PATH, hostname and Docker build behavior on Linux and macOS. version: type: string default: latest diff --git a/tests/command.bats b/tests/command.bats index 7c6737a..e78654f 100755 --- a/tests/command.bats +++ b/tests/command.bats @@ -21,6 +21,7 @@ set -eu printf '%s\n' "$*" > "$FIXTURE/arguments" printf '%s' "$BUILDKITE_PLUGIN_CONFIGURATION" > "$FIXTURE/configuration" printf '%s' "${BUILDKITE_GHA_PLUGIN_RELEASE:-}" > "$FIXTURE/metadata" +printf '%s' "${BUILDKITE_GHA_EVENT_PAYLOAD:-}" > "$FIXTURE/payload-env" printf '%s' "${MISE_DATA_DIR:-}" > "$FIXTURE/cli-cache-root" stat -c %a "$(dirname "$0")" > "$FIXTURE/mode" 2>/dev/null || stat -f %Lp "$(dirname "$0")" > "$FIXTURE/mode" ls -A "$(dirname "$0")" > "$FIXTURE/files" @@ -627,9 +628,10 @@ execution_configuration() { '.run.runtime.url = "http://example.com/runtime"' '.run.runtime.url += "#sha256=bad"' \ '.run.runtime.url = "https://user:password@example.com/runtime"' \ '.run.plan.digest = "bad"' '.run.plan.path = ""' '.run.plan.path += "\u0000"' \ + '.run.plan.inline = "e30="' 'del(.run.plan.path) | .run.plan.inline = "!bad"' \ '.run.plan.producer = "11111111-1111-4111-8111-111111111111"' \ 'del(.run.plan.path) | .run.plan.producer = "step-key-not-job-id"' \ - '.run["hosted-tool-cache"] = "true"'; do + '.run["hosted-tool-cache"] = "true"' '.run.namespace = "true"'; do export BUILDKITE_PLUGIN_CONFIGURATION="$(jq -c "$mutation" <<< "$configuration")" run "$REPO/hooks/command" [ "$status" -ne 0 ] || { echo "$mutation: $output"; false; } @@ -706,3 +708,72 @@ EOF [ ! -s "$FIXTURE/metadata" ] assert_clean } + +@test "macOS uses current identity and preserves runtime status without Linux setup" { + export BUILDKITE_PLUGIN_CONFIGURATION="$(execution_configuration | jq -c '.run.runtime.platform = "darwin/arm64" | .run.namespace = true | .run["hosted-tool-cache"] = true')" + export MOCK_OS=Darwin MOCK_ARCH=arm64 MOCK_IMPORTER_EXIT=37 + cat > "$FIXTURE/bin/sudo" <<'EOF' +#!/bin/bash +case "$2" in + mkdir) mkdir -p "$FIXTURE/mac/runner/hostedtoolcache" ;; + chown|chmod) ;; # Leave the parent permissions as mkdir created them. + *) exit 1 ;; +esac +EOF + chmod +x "$FIXTURE/bin/sudo" + umask 022 + run "$REPO/hooks/command" + [ "$status" -eq 37 ] || { echo "$output"; false; } + [ "$(stat -c %a "$FIXTURE/mac/runner" 2>/dev/null || stat -f %Lp "$FIXTURE/mac/runner")" = 755 ] + [[ "$(cat "$FIXTURE/arguments")" == "run-job --plan $TMPDIR/"*"/plan.json --hosted-tool-cache --docker-build-load" ]] + [ "$(cat "$MOCK_LOG")" = https://example.com/runtime ] + assert_clean +} + +@test "inline events are digest-bound and published by the execution job" { + # No privileged setup: this probes materialization and the launch boundary. + export MOCK_OS=Darwin MOCK_ARCH=arm64 + configuration="$(execution_configuration | jq -c '.run.runtime.platform = "darwin/arm64"')" + printf '{"marker":"event-bytes-not-for-logs"}' > "$FIXTURE/event.json" + event_hash="$(shasum -a 256 "$FIXTURE/event.json" | cut -d' ' -f1)" + jq -nc --arg digest "sha256:$event_hash" '{event:{payload_digest:$digest,payload_artifact:true}}' > "$FIXTURE/plan.json" + plan_hash="$(shasum -a 256 "$FIXTURE/plan.json" | cut -d' ' -f1)" + configuration="$(jq -c --arg plan "$(base64 < "$FIXTURE/plan.json" | tr -d '\n')" --arg event "$(base64 < "$FIXTURE/event.json" | tr -d '\n')" --arg digest "sha256:$plan_hash" \ + '.run.plan = {digest:$digest,inline:$plan} | .run.event = {inline:$event}' <<< "$configuration")" + export REAL_BASE64="$(command -v base64)" + cat > "$FIXTURE/bin/base64" <<'EOF' +#!/bin/bash +exec "$REAL_BASE64" -d +EOF + cat > "$FIXTURE/bin/buildkite-agent" <<'EOF' +#!/bin/bash +set -eu +[[ "$1 $2" == 'artifact upload' ]] +cmp "$3" "$FIXTURE/event.json" +printf '%s\n' "$BUILDKITE_JOB_ID $3" > "$FIXTURE/upload" +EOF + chmod +x "$FIXTURE/bin/"* + for mutation in '.run.plan.digest = ("sha256:" + ("0" * 64))' '.run.event.inline = "e30="' '.run.event.inline = ("x" * 65537 | @base64)' '.'; do + export BUILDKITE_PLUGIN_CONFIGURATION="$(jq -c "$mutation" <<< "$configuration")" + run "$REPO/hooks/command" + [[ "$output" != *event-bytes-not-for-logs* ]] + if [[ "$mutation" == . ]]; then + [ "$status" -eq 0 ] || { echo "$output"; false; } + [ "$(cat "$FIXTURE/upload")" = "$BUILDKITE_JOB_ID .buildkite-gha/events/$event_hash.json" ] + [[ "$(cat "$FIXTURE/arguments")" == "run-job --plan $TMPDIR/"*"/plan.json --artifact-producer $BUILDKITE_JOB_ID" ]] + else + [ "$status" -ne 0 ] + [[ "$output" == *"checksum verification failed"* || "$output" == *"inline event exceeds 64 KiB"* ]] + [ ! -e "$FIXTURE/upload" ] + [ ! -e "$FIXTURE/arguments" ] + fi + assert_clean + done + export BUILDKITE_GHA_EVENT_PAYLOAD="$(jq -r '.run.event.inline' <<< "$configuration")" + export BUILDKITE_PLUGIN_CONFIGURATION="$(jq -c '.run.event = {env:"BUILDKITE_GHA_EVENT_PAYLOAD"}' <<< "$configuration")" + run "$REPO/hooks/command" + [ "$status" -eq 0 ] || { echo "$output"; false; } + [ ! -s "$FIXTURE/payload-env" ] + [ "$(cat "$FIXTURE/upload")" = "$BUILDKITE_JOB_ID .buildkite-gha/events/$event_hash.json" ] + assert_clean +}