Skip to content
Open
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
30 changes: 30 additions & 0 deletions .github/scripts/resolve-node-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash

set -euo pipefail

output_file="${1:-${GITHUB_ENV:-}}"
: "${output_file:?pass an output file or set GITHUB_ENV}"
: "${INPUT_TAG:?INPUT_TAG is required}"
: "${SOURCE_REF:?SOURCE_REF is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"

# Docker tags cannot contain '/', so sanitize manual refs such as feat/x.
tag="${INPUT_TAG//[^a-zA-Z0-9._-]/-}"
[[ -n "$tag" ]] || { echo "Docker tag is empty" >&2; exit 1; }

# Main is the only production publication path. A successful main build
# updates :main and :latest together; release and network tags cannot race it
# and move :latest backward.
if [[ "$tag" == main && "$SOURCE_REF" == refs/heads/main ]]; then
latest_tag=true
else
latest_tag=false
fi

image_repository=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')

{
echo "tag=$tag"
echo "latest_tag=$latest_tag"
echo "image=ghcr.io/$image_repository"
} >> "$output_file"
56 changes: 56 additions & 0 deletions .github/scripts/test-resolve-node-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash

set -euo pipefail

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
resolver="$script_dir/resolve-node-image.sh"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

run_case() {
local name="$1"
local input_tag="$2"
local source_ref="$3"
local expected_tag="$4"
local expected_latest="$5"
local output="$tmp/$name"

GITHUB_REPOSITORY=RaoFoundation/subtensor \
INPUT_TAG="$input_tag" \
SOURCE_REF="$source_ref" \
"$resolver" "$output"

grep -qxF "tag=$expected_tag" "$output"
grep -qxF "latest_tag=$expected_latest" "$output"
grep -qxF "image=ghcr.io/raofoundation/subtensor" "$output"
}

run_case main main refs/heads/main main true
run_case stale-main main refs/tags/v448 main false
run_case testnet testnet refs/heads/testnet testnet false
run_case release v448 refs/tags/v448 v448 false
run_case feature feature/example refs/heads/feature/example feature-example false

workflow="$script_dir/../workflows/docker.yml"
grep -qF 'branches: [main, devnet, testnet]' "$workflow"
grep -qF 'run: ./.github/scripts/resolve-node-image.sh' "$workflow"
grep -qF "env.latest_tag == 'true'" "$workflow"
grep -qF "cancel-in-progress: \${{ github.ref != 'refs/heads/main' }}" "$workflow"
grep -qF 'current_main=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main"' "$workflow"

publish_job=$(sed -n '/^ publish:/,$p' "$workflow")
checkout_line=$(grep -nF 'ref: ${{ needs.setup.outputs.sha }}' <<<"$publish_job" | head -n 1 | cut -d: -f1)
resolver_line=$(grep -nF 'run: ./.github/scripts/resolve-node-image.sh' <<<"$publish_job" | cut -d: -f1)
[[ "$checkout_line" -lt "$resolver_line" ]] || {
echo "publish job must check out the pinned source before running its resolver" >&2
exit 1
}

head_check_line=$(grep -nF 'name: Verify current main revision' <<<"$publish_job" | cut -d: -f1)
push_line=$(grep -nF 'name: Build and push' <<<"$publish_job" | cut -d: -f1)
[[ "$head_check_line" -lt "$push_line" ]] || {
echo "publish job must reject stale main revisions before pushing" >&2
exit 1
}

echo "node image tag policy checks passed"
20 changes: 20 additions & 0 deletions .github/workflows/check-node-image-publication.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Validate Node Image Publication

on:
pull_request:
paths:
- ".github/scripts/resolve-node-image.sh"
- ".github/scripts/test-resolve-node-image.sh"
- ".github/workflows/docker.yml"
- ".github/workflows/check-node-image-publication.yml"

permissions:
contents: read

jobs:
policy:
name: main updates latest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./.github/scripts/test-resolve-node-image.sh
50 changes: 24 additions & 26 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
name: Publish Docker Image

# Node images publish whenever main or a network mirror moves, on runtime
# releases, and on demand. Main advances the development-facing :main tag.
# Releases cut by watch-mainnet-release.yml use the default GITHUB_TOKEN, which
# never emits `release: published`; the watcher therefore dispatches this
# workflow directly with the release tag. Release-version tags (vN) move
# :latest; branch tags do not.
# releases, and on demand. Main advances both :main and :latest so every
# successful merge publishes the default production image without waiting for
# a runtime release. Release-version and other branch tags never move :latest.

on:
release:
Expand All @@ -21,7 +19,9 @@ on:

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
# Main publications run serially so an older push cannot finish after a
# newer one and roll :latest backward. Other refs keep the fast-cancel path.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
contents: read
Expand Down Expand Up @@ -122,30 +122,16 @@ jobs:
runs-on: [self-hosted, fireactions-turbo-8]
timeout-minutes: 30
steps:
- name: Determine tag and image name
env:
INPUT_TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: |
# Docker tags cannot contain '/', so derive the tag by replacing any
# disallowed characters — otherwise a ref like `feat/x` fails tag
# validation at push time.
tag="${INPUT_TAG//[^a-zA-Z0-9._-]/-}"
echo "tag=$tag" >> $GITHUB_ENV
# Move :latest for release events and for dispatched release-version
# tags (the watcher dispatches with tag=vN because its GITHUB_TOKEN
# release cannot fire the `release` trigger).
if [[ "${{ github.event_name }}" == "release" || "$tag" =~ ^v[0-9]+$ ]]; then
echo "latest_tag=true" >> $GITHUB_ENV
else
echo "latest_tag=false" >> $GITHUB_ENV
fi
# Docker requires lowercase image names; github.repository is RaoFoundation/subtensor
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV

- uses: actions/checkout@v4
with:
ref: ${{ needs.setup.outputs.sha }}

- name: Determine tag and image name
env:
INPUT_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.ref }}
run: ./.github/scripts/resolve-node-image.sh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Checkout the trusted revision before executing its script

This self-hosted job executes a workspace-relative script before actions/checkout establishes ${{ needs.setup.outputs.sha }}. A stale or attacker-controlled workspace can therefore supply this executable, retain control of the publication job, and reach the package credential used later. Move the immutable checkout ahead of this step, then execute the resolver from that checked-out tree.


- name: Download AMD64 production binary
uses: actions/download-artifact@v5
with:
Expand All @@ -171,6 +157,18 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Verify current main revision
if: env.latest_tag == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILT_SHA: ${{ needs.setup.outputs.sha }}
run: |
current_main=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')
[ "$BUILT_SHA" = "$current_main" ] || {
echo "Refusing to move :latest from stale main revision $BUILT_SHA; current main is $current_main" >&2
exit 1
}

- name: Build and push
uses: docker/build-push-action@v6
with:
Expand Down
Loading