2026-10-16 01:18:37 -04:00
PROJECT_ID = " $1 "
PROJECT_NAMESPACE = " $2 "
PROJECT_NAME = " $3 "
RELEASE_COMMIT = " $4 "
RELEASE_TAG = " $5 "
RELEASE_TIME = " $6 "
2026-10-15 20:33:59 -04:00
: " ${ PIGEON_GITLAB_TOKEN :? } "
: " ${ PIGEON_PROJECTS :? } "
2026-10-16 01:18:37 -04:00
: " ${ PIGEON_STATE :? } "
2026-10-15 23:45:11 -04:00
2026-10-16 01:18:37 -04:00
RATE_LIMIT = 3
PIGEON_DATABASE = " $( realpath -m " $PIGEON_STATE /db" ) "
2026-10-23 11:25:43 -04:00
PIGEON_PROJECTS = " $( realpath -m " $PIGEON_PROJECTS " ) "
2026-10-16 01:18:37 -04:00
RELEASE_TIME = " $( date -d " $RELEASE_TIME " '+%Y-%m-%d %H:%M:%S' 2>/dev/null || true ) "
2026-10-15 23:45:11 -04:00
report() {
curl -s --show-error --out-null --request PUT \
--header "PRIVATE-TOKEN: $PIGEON_GITLAB_TOKEN " \
--header 'Content-Type: application/json' \
2026-10-16 01:18:37 -04:00
--data " $( jq -n --arg d " $1 " '{"description": $d}' ) " \
--url "https://gitlab.com/api/v4/projects/ $PROJECT_ID /releases/ $RELEASE_TAG "
}
2026-10-23 11:25:43 -04:00
# shellcheck disable=SC2120,SC2119
2026-10-16 01:18:37 -04:00
db() {
sqlite3 -bail -cmd '.timeout 10000' " $PIGEON_DATABASE " " $@ "
}
q() {
printf "'%s'" " $( printf %s " $1 " | sed "s/'/''/g" ) "
2026-10-15 23:45:11 -04:00
}
db << 'EOF'
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY,
student TEXT NOT NULL,
project TEXT NOT NULL,
2026-10-16 01:18:37 -04:00
hash TEXT NOT NULL,
2026-10-15 23:45:11 -04:00
time TEXT NOT NULL DEFAULT (datetime('now')),
2026-10-23 11:25:43 -04:00
score INTEGER CHECK (score >= 0),
token INTEGER NOT NULL
2026-10-15 23:45:11 -04:00
);
EOF
2026-10-15 20:33:59 -04:00
DIRECTORY = " $( mktemp -d /tmp/pigeon.XXXXXXXXXX) "
trap 'rm -rf "$DIRECTORY"' EXIT
cd " $DIRECTORY "
2026-10-16 01:18:37 -04:00
git -c advice.detachedHead= false clone -q --rev " $RELEASE_COMMIT " --depth 1 \
"https://oauth2: $PIGEON_GITLAB_TOKEN @gitlab.com/ $PROJECT_NAMESPACE / $PROJECT_NAME .git" student/
2026-10-15 20:33:59 -04:00
rm -rf student/.git
2026-10-23 11:25:43 -04:00
TESTS = " $PIGEON_PROJECTS / $PROJECT_NAME /tests"
2026-10-15 20:33:59 -04:00
mkdir -p project/tests project/build
2026-10-23 11:25:43 -04:00
cp -a " $TESTS " /*.sh project/tests/
2026-10-16 01:18:37 -04:00
cp -a " $PIGEON_PROJECTS / $PROJECT_NAME " /build/. project/build/
2026-10-15 20:33:59 -04:00
2026-10-23 11:25:43 -04:00
# A project may ship an answer-key solution. It is built and run only here,
# on the host, and never copied onto any drive the guest can reach -- tests
# compare the student's reported output against it after the VM has exited.
CANONICAL = ""
if [ -d " $PIGEON_PROJECTS / $PROJECT_NAME /canonical" ] ; then
mkdir -p canonical
cp -a " $PIGEON_PROJECTS / $PROJECT_NAME " /build/. canonical/
cp -a --remove-destination " $PIGEON_PROJECTS / $PROJECT_NAME " /canonical/. canonical/
( cd canonical && timeout 30 make >/dev/null 2>& 1 ) || true
[ -x canonical/program ] && CANONICAL = " $( realpath canonical/program) "
fi
2026-10-15 20:33:59 -04:00
mkfs.ext4 -q -F -d student student.img 16M
mkfs.ext4 -q -F -d project project.img 16M
mkfs.ext4 -q -F results.img 16M
cat >vm.json <<'EOF'
{
"boot-source": {
"kernel_image_path": "@kernel@",
"boot_args": "reboot=k panic=1 init=/init quiet nomodule i8042.noaux i8042.nomux i8042.dumbkbd swiotlb=noforce console=ttyS0"
},
"drives": [
{ "drive_id": "rootfs", "path_on_host": "@rootfs@", "is_root_device": true, "is_read_only": true },
{ "drive_id": "student", "path_on_host": "student.img", "is_root_device": false, "is_read_only": true },
{ "drive_id": "project", "path_on_host": "project.img", "is_root_device": false, "is_read_only": true },
{ "drive_id": "results", "path_on_host": "results.img", "is_root_device": false, "is_read_only": false }
],
"machine-config": { "vcpu_count": 1, "mem_size_mib": 512 }
}
EOF
firecracker --no-api --level Error --config-file vm.json
2026-10-23 11:25:43 -04:00
PUBLIC_SCORE = 0; PUBLIC_TOTAL = 0
RELEASE_SCORE = 0; RELEASE_TOTAL = 0
SECRET_SCORE = 0; SECRET_TOTAL = 0
NAMES =()
STATUSES =()
ANSWERS =()
QUESTIONS =()
TIERS =()
2026-10-15 20:33:59 -04:00
for TEST in " $TESTS " /*.sh ; do
2026-10-23 11:25:43 -04:00
NAME = " $( basename " $TEST " .sh) "
TIER = " ${ NAME %%-* } "
QUESTION = " $( cat " $TESTS / $NAME .question" ) "
debugfs -R "cat / $NAME " results.img >scratch 2>/dev/null
EXIT = " $( debugfs -R "cat / $NAME .status" results.img 2>/dev/null) "
case " $EXIT " in '' | *[ !0-9] *) EXIT = 0 ;; esac
# A test may expect the student's program to exit with a specific status
# (e.g. 134 for a SIGABRT from a correctly-triggered assert()) instead of
# the default 0.
EXPECTED_EXIT = 0
[ -f " $TESTS / $NAME .exit" ] && EXPECTED_EXIT = " $( cat " $TESTS / $NAME .exit" ) "
if [ -f " $TESTS / $NAME .expected" ] ; then
if cmp -s scratch " $TESTS / $NAME .expected" && [ " $EXIT " -eq " $EXPECTED_EXIT " ] ; then
MATCH = 1
else
MATCH = 0
fi
elif [ -n " $CANONICAL " ] ; then
# No static .expected: the test reported its own "ARG<TAB>OUTPUT" pairs,
# so check each one against the canonical solution run here on the host.
MATCH = 1
while IFS = " $( printf '\t' ) " read -r ARG OUT ; do
[ -n " $ARG " ] || continue
EXPECTED = " $( " $CANONICAL " " $ARG " 2>/dev/null) "
[ " $OUT " = " $EXPECTED " ] || MATCH = 0
done <scratch
2026-10-15 20:33:59 -04:00
else
2026-10-23 11:25:43 -04:00
MATCH = 0
2026-10-15 20:33:59 -04:00
fi
2026-10-23 11:25:43 -04:00
if [ " $MATCH " = 1 ] ; then
STATUS = PASS
ANSWER = YES
else
STATUS = FAIL
# timeout -s 9 (used in init.sh) exits 137 (128+SIGKILL) on a timeout,
# not the 124 it uses for its default signal (SIGTERM). Timing out is
# never the expected outcome, so it's checked before EXPECTED_EXIT.
if [ " $EXIT " -eq 124 ] || [ " $EXIT " -eq 137 ] ; then
ANSWER = "NO (timed out)"
elif [ " $EXIT " -eq " $EXPECTED_EXIT " ] ; then
ANSWER = "NO (wrong output)"
elif [ " $EXIT " -gt 128 ] ; then
ANSWER = "NO (crashed, signal $(( EXIT - 128 )) )"
else
ANSWER = "NO (exited with status $EXIT , expected $EXPECTED_EXIT )"
fi
fi
NAMES +=( " $NAME " )
STATUSES +=( " $STATUS " )
ANSWERS +=( " $ANSWER " )
QUESTIONS +=( " $QUESTION " )
TIERS +=( " $TIER " )
case " $TIER " in
public)
PUBLIC_TOTAL = $(( PUBLIC_TOTAL + 1 ))
[ " $STATUS " = PASS ] && PUBLIC_SCORE = $(( PUBLIC_SCORE + 1 ))
;;
release)
RELEASE_TOTAL = $(( RELEASE_TOTAL + 1 ))
[ " $STATUS " = PASS ] && RELEASE_SCORE = $(( RELEASE_SCORE + 1 ))
;;
secret)
SECRET_TOTAL = $(( SECRET_TOTAL + 1 ))
[ " $STATUS " = PASS ] && SECRET_SCORE = $(( SECRET_SCORE + 1 ))
;;
esac
2026-10-15 20:33:59 -04:00
done
2026-10-23 11:25:43 -04:00
PUBLIC_PASS = 0
[ " $PUBLIC_SCORE " -eq " $PUBLIC_TOTAL " ] && PUBLIC_PASS = 1
SCORE = $(( PUBLIC_SCORE + RELEASE_SCORE + SECRET_SCORE))
2026-10-15 23:45:11 -04:00
2026-10-23 11:25:43 -04:00
TOKEN = " $( db <<EOF
INSERT INTO results (student, project, hash, time, score, token)
SELECT $(q "$PROJECT_NAMESPACE"), $(q "$PROJECT_NAME"), $(q "$RELEASE_COMMIT"),
COALESCE(NULLIF($(q "$RELEASE_TIME"), ''), datetime('now')),
$SCORE,
CASE WHEN $PUBLIC_PASS = 1 AND (
SELECT COUNT(*) FROM results
WHERE student = $(q "$PROJECT_NAMESPACE") AND project = $(q "$PROJECT_NAME")
AND token = 1 AND time > datetime('now', '-1 day')
) < $RATE_LIMIT THEN 1 ELSE 0 END
RETURNING token;
EOF
) "
SHOWN_QUESTIONS =()
SHOWN_ANSWERS =()
for i in " ${ !NAMES[@] } " ; do
case " ${ TIERS [ $i ] } " in
public) ;;
release) [ " $TOKEN " = 1 ] || continue ;;
*) continue ;;
esac
SHOWN_QUESTIONS +=( " ${ QUESTIONS [ $i ] } " )
SHOWN_ANSWERS +=( " ${ ANSWERS [ $i ] } " )
done
MESSAGE = ""
for i in " ${ !SHOWN_QUESTIONS[@] } " ; do
MESSAGE = " ${ MESSAGE }${ SHOWN_QUESTIONS [ $i ] } ${ SHOWN_ANSWERS [ $i ] } " $'\n'
done
if [ " $TOKEN " != 1 ] && [ " $PUBLIC_PASS " = 1 ] ; then
MESSAGE = " ${ MESSAGE } " $'\n' "All public tests passed, but you have used all $RATE_LIMIT submissions for today. Release test results are withheld until tomorrow."
fi
report " $MESSAGE "