forked from CardanoSolutions/kupo
-
Notifications
You must be signed in to change notification settings - Fork 0
ci: publish multi-arch (amd64/arm64) OCI images to GHCR #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
e964e18
ci: add container image release
snarlysodboxer 28fafc0
flake: add arm64 native and musl builds for oci generation
johnalotoski 54e9ff2
ci: update gha and dockerfile for dual arch oci prep
johnalotoski 336644b
ci: add aarch64-linux tgz to the release bins since we now make the m…
johnalotoski 523885e
docs: update the readme with oci registry info
johnalotoski e4fa1cf
ci: normalize git refs into valid image and release tags
johnalotoski eb78ceb
ai: add gha security skill and claude ai symlink
johnalotoski fdb1a5f
docs: add SECURITY.md
johnalotoski 97e85f3
bump: for release 2.11.0.2 versioning
snarlysodboxer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| --- | ||
| name: hardening-github-actions | ||
| description: Writes and reviews GitHub Actions workflows with security hardening. Use when creating, modifying, or reviewing .github/workflows/*.yml or .github/actions/*/action.yml files. Covers shell injection prevention, trust gating for fork PRs, action pinning, and secrets hygiene. | ||
| --- | ||
|
|
||
| # Hardening GitHub Actions | ||
|
|
||
| This guidance assumes a **public repository** threat model where untrusted users can open fork PRs. The fork trust-gating sections are primarily relevant in that context. Shell injection prevention, action pinning, credential hygiene, and checkout persistence apply to all repos. | ||
|
|
||
| ## Shell injection prevention | ||
|
|
||
| **Never interpolate `${{ }}` expressions directly into `run:` blocks.** GitHub expression substitution happens *before* the shell parses the command. Attacker-controlled values (branch names, PR titles, input fields) can inject arbitrary shell commands. | ||
|
|
||
| Unsafe values include anything derived from: | ||
| - `github.event.pull_request.head.ref` (fork branch names allow shell metacharacters) | ||
| - `github.event.pull_request.title` / `.body` | ||
| - `github.event.inputs.*` (free-form text) | ||
| - `github.event.comment.body` | ||
| - `github.event.pull_request.head.repo.full_name` (fork repo names) | ||
|
|
||
| ### Fix: use intermediate environment variables | ||
|
|
||
| Pass untrusted expressions via `env:` on the step, then reference them as quoted shell variables. Environment variables are set at runtime and are not subject to shell expansion. | ||
|
|
||
| ```yaml | ||
| # BAD - shell injection via branch name | ||
| - run: git fetch origin ${{ github.event.pull_request.head.ref }} | ||
|
|
||
| # GOOD - safe via env | ||
| - env: | ||
| HEAD_REF: ${{ github.event.pull_request.head.ref }} | ||
| run: git fetch origin "$HEAD_REF" | ||
| ``` | ||
|
|
||
| This applies to **all** `${{ }}` references in `run:` blocks that touch event data or action inputs. Expressions used only in `with:`, `if:`, or `env:` values (not shell) are fine. | ||
|
|
||
| Ref: https://docs.github.com/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable | ||
|
|
||
| ### Safe contexts (no fix needed) | ||
|
|
||
| - `github.actor` (alphanumeric + hyphens only) | ||
| - `github.repository` (org/repo, restricted charset) | ||
| - `github.sha`, `github.run_id`, `github.run_number` (hex/numeric) | ||
| - Values used only in `if:`, `with:`, `run-name:`, `concurrency.group:`, or other non-shell YAML fields | ||
|
|
||
| ## Trust gating for fork PRs | ||
|
|
||
| Fork PRs on `pull_request` triggers get a read-only `GITHUB_TOKEN` and empty `secrets.*` (platform-enforced). But composite actions, checkout steps, and shell commands still execute. Structure workflows to avoid running untrusted code with privileges. | ||
|
|
||
| ### Preferred: two-job structure | ||
|
|
||
| ```yaml | ||
| jobs: | ||
| detect: | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| is_trusted: ${{ steps.check.outputs.is_trusted }} | ||
| steps: | ||
| - uses: actions/checkout@<pinned-sha> | ||
| with: | ||
| ref: ${{ github.event.pull_request.base.sha || github.sha }} | ||
| - id: check | ||
| run: # ... determine trust level | ||
|
|
||
| build: | ||
| needs: detect | ||
| if: needs.detect.outputs.is_trusted == 'true' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| # ... privileged steps with secrets | ||
| ``` | ||
|
|
||
| Key properties: | ||
| - **Job 1 checks out trusted code only** (`base.sha` or `github.sha`, never the fork's HEAD) | ||
| - **Job 2 never spawns for untrusted forks** -- composite actions, checkout-merge, and all secret-bearing steps are unreachable | ||
| - Step-level `if:` guards are fragile (earlier steps still execute); job-level `if:` is definitive | ||
|
|
||
| ### Avoid: step-level early exit | ||
|
|
||
| A step like `if: is_trusted == 'false'` then `exit` leaves all preceding steps exposed to injection. Use this only as a temporary measure. | ||
|
|
||
| ### Why `pull_request` not `pull_request_target` | ||
|
|
||
| Use `pull_request` triggers, not `pull_request_target`. With `pull_request`, GitHub runs the workflow from the **base branch** -- fork PRs cannot modify the workflow or composite actions that execute. With `pull_request_target`, the workflow runs with write token and secrets but can be tricked into checking out and executing fork code. If `pull_request_target` is ever needed, it must never check out PR code. | ||
|
|
||
| ### Detect-job must check out base code only | ||
|
|
||
| The detect/gate job must use `ref: ${{ github.event.pull_request.base.sha || github.sha }}` to ensure it only runs trusted composite actions from the base branch. If this were changed to check out the PR head, a fork could replace the detect action itself. | ||
|
|
||
| ### `workflow_dispatch` trust assumptions | ||
|
|
||
| Manual dispatch with a PR number is treated as trusted because only maintainers/admins can trigger `workflow_dispatch`. Ensure repository settings continue to restrict dispatch permissions -- if collaborators with lower access levels gain `actions: write`, they could dispatch against a fork PR and run its code with secrets. | ||
|
|
||
| ## Action pinning | ||
|
|
||
| Pin third-party actions to full commit SHAs, not mutable tags. Tags like `v4` can be force-pushed. | ||
|
|
||
| ```yaml | ||
| # BAD | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| # GOOD | ||
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | ||
| ``` | ||
|
|
||
| Look up SHAs with: `git ls-remote --tags https://github.com/<owner>/<repo>.git <tag>` | ||
|
|
||
| Keep the tag as a trailing comment for readability. | ||
|
|
||
| ## Input validation | ||
|
|
||
| Validate free-form `workflow_dispatch` inputs before use: | ||
|
|
||
| ```yaml | ||
| - env: | ||
| PR_NUMBER: ${{ github.event.inputs.pr_number }} | ||
| run: | | ||
| if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then | ||
| echo "::error::pr_number must be numeric" | ||
| exit 1 | ||
| fi | ||
| ``` | ||
|
|
||
| ## Checkout credential persistence | ||
|
|
||
| Always set `persist-credentials: false` on `actions/checkout`. The default (`true`) writes the `GITHUB_TOKEN` into `.git/config`, making it readable by every subsequent step -- including third-party actions and build tools. With `contents: write` permission, a compromised step could push to the repository. | ||
|
|
||
| ```yaml | ||
| - uses: actions/checkout@<pinned-sha> | ||
| with: | ||
| persist-credentials: false | ||
| ``` | ||
|
|
||
| If git push is needed later, configure credentials explicitly for just that step. | ||
|
|
||
| ## Secrets hygiene in `run:` blocks | ||
|
|
||
| Pass secrets through `env:` rather than inline `${{ secrets.* }}` in shell: | ||
|
|
||
| ```yaml | ||
| # BAD - secret in shell substitution, visible in logs on syntax error | ||
| - run: echo "${{ secrets.KEY }}" > keyfile | ||
|
|
||
| # GOOD | ||
| - env: | ||
| KEY: ${{ secrets.KEY }} | ||
| run: echo "$KEY" > keyfile | ||
| ``` | ||
|
|
||
| ## `GITHUB_OUTPUT` injection | ||
|
|
||
| When writing to `$GITHUB_OUTPUT` with `echo "key=$value"`, an attacker who controls `value` can inject newlines to set arbitrary output keys. Use the multiline delimiter format when the value could contain newlines (e.g., PR titles, commit messages, comment bodies): | ||
|
|
||
| ```bash | ||
| { | ||
| echo "title<<EOF" | ||
| echo "$PR_TITLE" | ||
| echo "EOF" | ||
| } >> "$GITHUB_OUTPUT" | ||
| ``` | ||
|
|
||
| Values with restricted charsets (git branch names, numeric IDs, booleans computed from restricted inputs) are safe with the simple `echo "key=$value"` format. | ||
|
|
||
| ## `workflow_run` artifact poisoning | ||
|
|
||
| If adding `workflow_run` triggers: a fork PR can upload artifacts via `pull_request`, and a `workflow_run` job (which has secrets) may then download and process them. Never execute or trust artifact contents from untrusted runs without validation. | ||
|
|
||
| ## Review checklist | ||
|
|
||
| When reviewing or writing a workflow: | ||
|
|
||
| 1. Grep for `${{ ` inside `run:` blocks -- each one is a potential injection point | ||
| 2. For each match, determine if the value is attacker-controlled; if so, move to `env:` | ||
| 3. Verify fork PRs cannot reach secret-bearing steps (prefer two-job gate) | ||
| 4. Confirm third-party actions are SHA-pinned | ||
| 5. Validate any free-form `workflow_dispatch` inputs | ||
| 6. Check that `pull_request_target` is not used (or if it is, that it never checks out PR code) | ||
| 7. Verify no `accept-flake-config = true` or `--accept-flake-config` in Nix steps (prevents flake nixConfig escape) | ||
| 8. Verify `persist-credentials: false` on all `actions/checkout` steps | ||
| 9. Verify `workflow_dispatch` permissions remain restricted to maintainers/admins |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| --- | ||
| name: Container Image | ||
| # Builds the kupo container image from the static musl binary built by | ||
| # Hydra, and pushes it to GHCR. | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| push: | ||
| tags: | ||
| - '**' | ||
|
|
||
| permissions: | ||
| contents: read | ||
| checks: read | ||
| packages: write | ||
|
|
||
| jobs: | ||
| build-and-push: | ||
| name: "Build and push container image" | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | ||
| with: | ||
|
snarlysodboxer marked this conversation as resolved.
|
||
| fetch-depth: 0 | ||
| persist-credentials: false | ||
|
|
||
| # Images are versioned after git tags, and only published for tags | ||
| # pointing at commits that are part of master. | ||
| - name: Check that the workflow runs on a tag | ||
| if: github.ref_type != 'tag' | ||
| run: | | ||
| echo "::error::This workflow must run on a git tag (got $GITHUB_REF); refusing to publish images." | ||
| exit 1 | ||
|
|
||
| # For annotated tags, GITHUB_SHA is the SHA of the tag object itself, | ||
| # not of the commit it points at; HEAD after checkout is the commit. | ||
| - name: Resolve tag to commit SHA | ||
| run: echo "TARGET_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" | ||
|
|
||
| - name: Check that the tagged commit is on master | ||
| run: | | ||
| if ! git merge-base --is-ancestor "$TARGET_SHA" origin/master; then | ||
| echo "::error::Tag $GITHUB_REF_NAME points at $TARGET_SHA, which is not on master; refusing to publish images." | ||
| exit 1 | ||
| fi | ||
|
|
||
| # The deadline is generous because CI includes a chain synchronization | ||
| # that can still be running when the tag is pushed. | ||
| - name: Wait for Continuous Integration to pass | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| check_name="integration-tests" | ||
| deadline=$((SECONDS + 3600)) | ||
| while true; do | ||
| conclusions=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TARGET_SHA/check-runs?check_name=$check_name" \ | ||
| --jq '[.check_runs[].conclusion // "pending"] | unique | join(",")') | ||
| case "$conclusions" in | ||
| success) echo "Continuous Integration passed"; break ;; | ||
| ""|*pending*) echo "Continuous Integration pending, waiting 30s..." ;; | ||
| *) echo "::error::Continuous Integration concluded with: $conclusions"; exit 1 ;; | ||
| esac | ||
| if [ "$SECONDS" -ge "$deadline" ]; then | ||
| echo "::error::Timed out after 60 minutes waiting for Continuous Integration" | ||
| exit 1 | ||
| fi | ||
| sleep 30 | ||
| done | ||
|
|
||
| - name: Install Nix | ||
| uses: cachix/install-nix-action@b97f05dcb019ddea06450a50ef6203d2fdc19fee # v31 | ||
| with: | ||
| extra_nix_config: | | ||
| trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ= | ||
| substituters = https://cache.iog.io/ https://cache.nixos.org/ | ||
|
|
||
| # Temporary: poll the Nix cache directly until Hydra check-run reporting | ||
| # is fixed. Both architectures are built by Hydra; wait for each. | ||
| - name: Wait for kupo-musl in Hydra cache | ||
| run: | | ||
| set -o pipefail | ||
| echo "Target SHA: $TARGET_SHA" | ||
| flake_ref="git+https://github.com/$GITHUB_REPOSITORY?rev=$TARGET_SHA&submodules=1" | ||
| deadline=$((SECONDS + 1800)) | ||
| for sys in x86_64-linux aarch64-linux; do | ||
| if ! out=$(nix eval --raw "$flake_ref#packages.$sys.kupo-musl"); then | ||
| echo "nix eval failed for $sys" | ||
| exit 1 | ||
| fi | ||
| if [ -z "$out" ]; then | ||
| echo "nix eval returned an empty path for $sys" | ||
| exit 1 | ||
| fi | ||
| echo "[$sys] resolved store path: $out" | ||
| echo "[$sys] waiting for $out in cache..." | ||
| while true; do | ||
| if nix path-info --refresh --store https://cache.iog.io "$out" >/dev/null 2>&1; then | ||
| echo "[$sys] found in cache" | ||
| break | ||
| fi | ||
| if [ "$SECONDS" -ge "$deadline" ]; then | ||
| echo "Timed out after 30 minutes waiting for Hydra cache ($sys path: $out)" | ||
| exit 1 | ||
| fi | ||
| echo "[$sys] not in cache yet, waiting 30s..." | ||
| sleep 30 | ||
| done | ||
| done | ||
|
|
||
| # Place each architecture's static binary at the path the Dockerfile's | ||
| # `COPY ./bin/kupo-${TARGETARCH}` expects (amd64 / arm64). | ||
| - name: Download kupo binaries from Hydra cache | ||
| run: | | ||
| flake_ref="git+https://github.com/$GITHUB_REPOSITORY?rev=$TARGET_SHA&submodules=1" | ||
| mkdir -p bin | ||
| for pair in "x86_64-linux:amd64" "aarch64-linux:arm64"; do | ||
| sys="${pair%%:*}" | ||
| arch="${pair##*:}" | ||
| nix build --builders "" --max-jobs 0 "$flake_ref#packages.$sys.kupo-musl" -o "result-$arch" | ||
| cp "result-$arch/bin/kupo" "bin/kupo-$arch" | ||
| done | ||
|
|
||
| - name: Set up QEMU | ||
| uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 | ||
|
|
||
| - name: Set up Buildx | ||
| uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 | ||
|
|
||
| - name: Login to GHCR | ||
| uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 | ||
| with: | ||
| registry: ghcr.io | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| # Each architecture is built and pushed on its own as '<base>-amd64' / | ||
| # '<base>-arm64' (so the single-arch images are tagged from the start, never | ||
| # left as orphaned 'sha256:...' versions), then the multi-arch manifest lists | ||
| # are composed from those per-arch digests in a later step. | ||
| # | ||
| # kupo-base the canonical tag the per-arch builds attach '-<arch>' to | ||
| # kupo a space-separated list of every final multi-arch tag | ||
| - name: Compute image tags | ||
| id: tags | ||
| run: | | ||
| image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/kupo" | ||
| # Replace any run of characters invalid in a Docker tag with a single | ||
| # '-' (deleting them could collapse distinct refs to the same tag), | ||
| # then trim leading/trailing separators. | ||
| version=$(printf '%s' "$GITHUB_REF_NAME" | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^[-.]+//; s/[-.]+$//') | ||
| if ! [[ "$version" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$ ]]; then | ||
| echo "::error::Cannot derive a valid image tag from git tag '$GITHUB_REF_NAME' (got '$version')." | ||
| exit 1 | ||
| fi | ||
| { | ||
| echo "kupo-base=$image:$version" | ||
| echo "kupo=$image:$version $image:latest" | ||
| } >> "$GITHUB_OUTPUT" | ||
|
|
||
| # --- Per-architecture builds: each pushes a single '<base>-<arch>' tag. --- | ||
|
|
||
| - name: Build and push kupo (amd64) | ||
| uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 | ||
| with: | ||
| context: . | ||
| target: kupo | ||
| platforms: linux/amd64 | ||
| provenance: false | ||
| push: true | ||
| tags: ${{ steps.tags.outputs.kupo-base }}-amd64 | ||
|
|
||
| - name: Build and push kupo (arm64) | ||
| uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 | ||
| with: | ||
| context: . | ||
| target: kupo | ||
| platforms: linux/arm64 | ||
| provenance: false | ||
| push: true | ||
| tags: ${{ steps.tags.outputs.kupo-base }}-arm64 | ||
|
|
||
| # --- Compose multi-arch manifest lists from the per-arch images. --- | ||
| # Every final tag becomes a manifest list pointing at the two already-tagged | ||
| # '<base>-amd64' / '<base>-arm64' images by digest. No new single-arch | ||
| # manifests are created, so nothing is left untagged. | ||
| - name: Compose multi-arch manifests | ||
| run: | | ||
| set -euo pipefail | ||
| base='${{ steps.tags.outputs.kupo-base }}' | ||
| repo="${base%:*}" | ||
| amd64=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$base-amd64" | jq -er .digest) | ||
| arm64=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$base-arm64" | jq -er .digest) | ||
| for tag in ${{ steps.tags.outputs.kupo }}; do | ||
| docker buildx imagetools create -t "$tag" "$repo@$amd64" "$repo@$arm64" | ||
| done | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.