huskies: merge 1189 story Gitea Actions workflow: build the sled artifact on master merges and publish to the dev channel
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
# Gitea Actions workflows
|
||||||
|
|
||||||
|
## `release-artifact.yml`
|
||||||
|
|
||||||
|
Triggers on every push to `master`. Builds the `linux-arm64` sled binary and
|
||||||
|
publishes it to the "dev" release channel via `script/ci-publish-artifact`.
|
||||||
|
|
||||||
|
### Runner registration
|
||||||
|
|
||||||
|
The job targets the `arm64-mac` runner label. Register an `act_runner` on an
|
||||||
|
Apple Silicon macOS host that has `cargo`/`rustc` and `curl` on `PATH`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
act_runner register \
|
||||||
|
--instance https://code.crashlabs.io \
|
||||||
|
--token <runner-registration-token> \
|
||||||
|
--labels arm64-mac
|
||||||
|
act_runner daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
The registration token comes from the repo's **Settings → Actions →
|
||||||
|
Runners → Create new Runner** page in Gitea. Without a runner carrying the
|
||||||
|
`arm64-mac` label, jobs from this workflow queue indefinitely.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
Configure these under the repo's **Settings → Actions → Secrets**. Never
|
||||||
|
commit credentials — the workflow only ever references them via
|
||||||
|
`${{ secrets.* }}`.
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `HUSKIES_CHANNEL_URL` | Base URL of the dev release channel host. |
|
||||||
|
| `HUSKIES_CHANNEL_TOKEN` | Bearer token authorised to publish artifacts to that channel. |
|
||||||
|
|
||||||
|
### Channel host contract
|
||||||
|
|
||||||
|
`script/ci-publish-artifact` expects the channel host at
|
||||||
|
`HUSKIES_CHANNEL_URL` to implement:
|
||||||
|
|
||||||
|
- `POST {HUSKIES_CHANNEL_URL}/<artifact-name>` — accepts the raw artifact
|
||||||
|
bytes as the request body. Requires `Authorization: Bearer <token>` and
|
||||||
|
`X-Git-Hash: <short-git-hash>` headers. Non-2xx responses in the 4xx range
|
||||||
|
(including 401/403) are treated as permanent failures; 5xx responses and
|
||||||
|
network errors are retried with backoff.
|
||||||
|
- `GET {HUSKIES_CHANNEL_URL}/manifest.json` — returns a JSON object with a
|
||||||
|
`git_hash` field reflecting the most recently published artifact.
|
||||||
|
Requires `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
This is a separate, unsigned channel distinct from the Ed25519-signed
|
||||||
|
release channels the `pull <channel>` gateway command consumes (see
|
||||||
|
`server/src/service/gateway/release_manifest.rs`) — CI has no safe place to
|
||||||
|
hold a channel signing key, so the dev channel trusts the bearer token alone.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Publish sled artifact
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-dev-artifact:
|
||||||
|
# Registered on an arm64 macOS act_runner host — see
|
||||||
|
# .gitea/workflows/README.md for registration instructions.
|
||||||
|
runs-on: [arm64-mac]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build linux-arm64 sled binary
|
||||||
|
env:
|
||||||
|
# Dedicated target dir so this CI build never clobbers a developer's
|
||||||
|
# incremental target/release build on the shared runner host, mirroring
|
||||||
|
# SLED_TARGET_DIR in server/src/chat/transport/matrix/release.rs.
|
||||||
|
CARGO_TARGET_DIR: target/ci-release
|
||||||
|
run: cargo build --release -p huskies
|
||||||
|
|
||||||
|
- name: Stage artifact
|
||||||
|
run: cp target/ci-release/release/huskies target/ci-release/release/huskies-linux-arm64
|
||||||
|
|
||||||
|
- name: Publish to dev channel
|
||||||
|
env:
|
||||||
|
HUSKIES_CHANNEL_URL: ${{ secrets.HUSKIES_CHANNEL_URL }}
|
||||||
|
HUSKIES_CHANNEL_TOKEN: ${{ secrets.HUSKIES_CHANNEL_TOKEN }}
|
||||||
|
run: script/ci-publish-artifact target/ci-release/release/huskies-linux-arm64 "$(git rev-parse --short HEAD)"
|
||||||
Generated
+20
@@ -1958,6 +1958,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"serde_yaml",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"source-map-gen",
|
"source-map-gen",
|
||||||
@@ -4368,6 +4369,19 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial2"
|
name = "serial2"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -5371,6 +5385,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -65,3 +65,4 @@ sqlx = { version = "0.9.0", default-features = false, features = [
|
|||||||
"macros",
|
"macros",
|
||||||
"migrate",
|
"migrate",
|
||||||
] }
|
] }
|
||||||
|
serde_yaml = "0.9.34"
|
||||||
|
|||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# script/ci-publish-artifact — upload a built sled artifact to a release
|
||||||
|
# channel and verify the channel's manifest reflects the published commit.
|
||||||
|
#
|
||||||
|
# Usage: script/ci-publish-artifact <artifact-path> [git-hash]
|
||||||
|
#
|
||||||
|
# git-hash defaults to `git rev-parse --short HEAD` when omitted.
|
||||||
|
#
|
||||||
|
# Required env:
|
||||||
|
# HUSKIES_CHANNEL_URL Base URL of the release channel (Gitea secret).
|
||||||
|
# HUSKIES_CHANNEL_TOKEN Bearer token authorised to publish (Gitea secret).
|
||||||
|
#
|
||||||
|
# Protocol against the channel host:
|
||||||
|
# POST {HUSKIES_CHANNEL_URL}/<artifact-name>
|
||||||
|
# Headers: Authorization: Bearer <token>, X-Git-Hash: <hash>
|
||||||
|
# Body: raw artifact bytes
|
||||||
|
# GET {HUSKIES_CHANNEL_URL}/manifest.json
|
||||||
|
# Headers: Authorization: Bearer <token>
|
||||||
|
# Body: JSON object with a "git_hash" field
|
||||||
|
#
|
||||||
|
# 5xx responses and network errors are retried with backoff; 4xx responses
|
||||||
|
# (including auth failures) fail immediately since retrying won't fix them.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ARTIFACT_PATH="${1:?Usage: script/ci-publish-artifact <artifact-path> [git-hash]}"
|
||||||
|
GIT_HASH="${2:-$(git rev-parse --short HEAD)}"
|
||||||
|
|
||||||
|
if [ -z "${HUSKIES_CHANNEL_URL:-}" ]; then
|
||||||
|
echo "Error: HUSKIES_CHANNEL_URL is not set." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${HUSKIES_CHANNEL_TOKEN:-}" ]; then
|
||||||
|
echo "Error: HUSKIES_CHANNEL_TOKEN is not set." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$ARTIFACT_PATH" ]; then
|
||||||
|
echo "Error: artifact not found at $ARTIFACT_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARTIFACT_NAME="$(basename "$ARTIFACT_PATH")"
|
||||||
|
CHANNEL_URL="${HUSKIES_CHANNEL_URL%/}"
|
||||||
|
UPLOAD_URL="${CHANNEL_URL}/${ARTIFACT_NAME}"
|
||||||
|
MANIFEST_URL="${CHANNEL_URL}/manifest.json"
|
||||||
|
|
||||||
|
UPLOAD_MAX_ATTEMPTS="${HUSKIES_CI_PUBLISH_MAX_ATTEMPTS:-3}"
|
||||||
|
UPLOAD_BACKOFF_SECS="${HUSKIES_CI_PUBLISH_BACKOFF_SECS:-1}"
|
||||||
|
MANIFEST_MAX_ATTEMPTS=3
|
||||||
|
MANIFEST_BACKOFF_SECS=1
|
||||||
|
|
||||||
|
RESPONSE_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "$RESPONSE_FILE"' EXIT
|
||||||
|
|
||||||
|
# ── Upload ────────────────────────────────────────────────────────────────
|
||||||
|
attempt=1
|
||||||
|
while :; do
|
||||||
|
echo "==> Uploading ${ARTIFACT_NAME} (${GIT_HASH}), attempt ${attempt}/${UPLOAD_MAX_ATTEMPTS}..."
|
||||||
|
HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 60 \
|
||||||
|
-o "$RESPONSE_FILE" -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: Bearer ${HUSKIES_CHANNEL_TOKEN}" \
|
||||||
|
-H "X-Git-Hash: ${GIT_HASH}" \
|
||||||
|
--data-binary "@${ARTIFACT_PATH}" \
|
||||||
|
"${UPLOAD_URL}") || HTTP_CODE="000"
|
||||||
|
RESPONSE_BODY="$(cat "$RESPONSE_FILE" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
case "$HTTP_CODE" in
|
||||||
|
2??)
|
||||||
|
echo "==> Upload succeeded (HTTP ${HTTP_CODE})."
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
401|403)
|
||||||
|
echo "Error: upload rejected — authentication failed (HTTP ${HTTP_CODE})." >&2
|
||||||
|
echo "Response: ${RESPONSE_BODY}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
4??)
|
||||||
|
echo "Error: upload rejected by the channel (HTTP ${HTTP_CODE}); not retrying a client error." >&2
|
||||||
|
echo "Response: ${RESPONSE_BODY}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$attempt" -ge "$UPLOAD_MAX_ATTEMPTS" ]; then
|
||||||
|
echo "Error: upload failed after ${UPLOAD_MAX_ATTEMPTS} attempts (last HTTP ${HTTP_CODE})." >&2
|
||||||
|
echo "Response: ${RESPONSE_BODY}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Transient failure (HTTP ${HTTP_CODE}); retrying in ${UPLOAD_BACKOFF_SECS}s..."
|
||||||
|
sleep "$UPLOAD_BACKOFF_SECS"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
UPLOAD_BACKOFF_SECS=$((UPLOAD_BACKOFF_SECS * 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Verify manifest ──────────────────────────────────────────────────────
|
||||||
|
attempt=1
|
||||||
|
while :; do
|
||||||
|
echo "==> Verifying channel manifest reflects ${GIT_HASH} (attempt ${attempt}/${MANIFEST_MAX_ATTEMPTS})..."
|
||||||
|
MANIFEST_JSON=$(curl -sS --connect-timeout 10 --max-time 30 \
|
||||||
|
-H "Authorization: Bearer ${HUSKIES_CHANNEL_TOKEN}" \
|
||||||
|
"${MANIFEST_URL}") || MANIFEST_JSON=""
|
||||||
|
|
||||||
|
MANIFEST_HASH=$(printf '%s' "$MANIFEST_JSON" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin).get('git_hash',''))" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ "$MANIFEST_HASH" = "$GIT_HASH" ]; then
|
||||||
|
echo "==> Published ${ARTIFACT_NAME} (${GIT_HASH}) to ${CHANNEL_URL}; manifest verified."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$attempt" -ge "$MANIFEST_MAX_ATTEMPTS" ]; then
|
||||||
|
echo "Error: manifest mismatch — channel reports git_hash '${MANIFEST_HASH}', expected '${GIT_HASH}'." >&2
|
||||||
|
echo "Manifest: ${MANIFEST_JSON}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "$MANIFEST_BACKOFF_SECS"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
MANIFEST_BACKOFF_SECS=$((MANIFEST_BACKOFF_SECS * 2))
|
||||||
|
done
|
||||||
@@ -66,3 +66,4 @@ check-cfg = ["cfg(feature, values(\"logging-base\"))"]
|
|||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
mockito = "1.7.2"
|
mockito = "1.7.2"
|
||||||
filetime = { workspace = true }
|
filetime = { workspace = true }
|
||||||
|
serde_yaml = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
//! Tests for `script/ci-publish-artifact` and `.gitea/workflows/release-artifact.yml`
|
||||||
|
//! (story 1189). Exercises the publish script end-to-end as a subprocess against a
|
||||||
|
//! minimal local HTTP stub rather than a live channel host — this crate has no
|
||||||
|
//! production code path that talks to the dev channel itself.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
/// One request as observed by the stub: method, path, and the two headers
|
||||||
|
/// the publish script is required to send.
|
||||||
|
struct ObservedRequest {
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
authorization: Option<String>,
|
||||||
|
git_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repo_root() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script_path() -> PathBuf {
|
||||||
|
repo_root().join("script/ci-publish-artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one HTTP/1.1 request off `stream`: request line, headers (lower-cased
|
||||||
|
/// keys), and body (read exactly `Content-Length` bytes, defaulting to none).
|
||||||
|
fn read_request(stream: &mut TcpStream) -> (String, String, HashMap<String, String>, Vec<u8>) {
|
||||||
|
let mut header_bytes = Vec::new();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
loop {
|
||||||
|
stream.read_exact(&mut byte).expect("read request byte");
|
||||||
|
header_bytes.push(byte[0]);
|
||||||
|
if header_bytes.ends_with(b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let header_text = String::from_utf8_lossy(&header_bytes);
|
||||||
|
let mut lines = header_text.lines();
|
||||||
|
let request_line = lines.next().unwrap_or("");
|
||||||
|
let mut parts = request_line.split_whitespace();
|
||||||
|
let method = parts.next().unwrap_or("").to_string();
|
||||||
|
let path = parts.next().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
let mut headers = HashMap::new();
|
||||||
|
for line in lines {
|
||||||
|
if let Some((k, v)) = line.split_once(':') {
|
||||||
|
headers.insert(k.trim().to_lowercase(), v.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_length: usize = headers
|
||||||
|
.get("content-length")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut body = vec![0u8; content_length];
|
||||||
|
if content_length > 0 {
|
||||||
|
stream.read_exact(&mut body).expect("read request body");
|
||||||
|
}
|
||||||
|
(method, path, headers, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_response(stream: &mut TcpStream, status: u16, body: &str) {
|
||||||
|
let reason = match status {
|
||||||
|
200 => "OK",
|
||||||
|
401 => "Unauthorized",
|
||||||
|
403 => "Forbidden",
|
||||||
|
404 => "Not Found",
|
||||||
|
500 => "Internal Server Error",
|
||||||
|
_ => "Unknown",
|
||||||
|
};
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(response.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a background thread serving `responses` in order, one per accepted
|
||||||
|
/// connection; once exhausted, further requests get a 500 fallback (a test
|
||||||
|
/// bug, not an expected script behaviour). Returns the port and a log of
|
||||||
|
/// every request observed, in arrival order.
|
||||||
|
fn spawn_stub(responses: Vec<(u16, &'static str)>) -> (u16, Arc<Mutex<Vec<ObservedRequest>>>) {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
|
||||||
|
let port = listener.local_addr().expect("stub local addr").port();
|
||||||
|
let queue = Arc::new(Mutex::new(responses.into_iter().collect::<VecDeque<_>>()));
|
||||||
|
let log: Arc<Mutex<Vec<ObservedRequest>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let log_writer = Arc::clone(&log);
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let Ok(mut stream) = stream else { break };
|
||||||
|
let (method, path, headers, _body) = read_request(&mut stream);
|
||||||
|
log_writer.lock().unwrap().push(ObservedRequest {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
authorization: headers.get("authorization").cloned(),
|
||||||
|
git_hash: headers.get("x-git-hash").cloned(),
|
||||||
|
});
|
||||||
|
let (status, body) = queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or((500, "no more stub responses queued"));
|
||||||
|
write_response(&mut stream, status, body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(port, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RunResult {
|
||||||
|
success: bool,
|
||||||
|
stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_publish_script(port: u16, git_hash: &str) -> RunResult {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let artifact_path = dir.path().join("huskies-linux-arm64");
|
||||||
|
std::fs::write(&artifact_path, b"fake sled binary").expect("write fake artifact");
|
||||||
|
|
||||||
|
let output = Command::new("bash")
|
||||||
|
.arg(script_path())
|
||||||
|
.arg(&artifact_path)
|
||||||
|
.arg(git_hash)
|
||||||
|
.env("HUSKIES_CHANNEL_URL", format!("http://127.0.0.1:{port}"))
|
||||||
|
.env("HUSKIES_CHANNEL_TOKEN", "test-token")
|
||||||
|
.env("HUSKIES_CI_PUBLISH_BACKOFF_SECS", "1")
|
||||||
|
.output()
|
||||||
|
.expect("run script/ci-publish-artifact");
|
||||||
|
|
||||||
|
RunResult {
|
||||||
|
success: output.status.success(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Script behaviour ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_succeeds_and_verifies_manifest() {
|
||||||
|
let (port, log) = spawn_stub(vec![
|
||||||
|
(200, r#"{"status":"ok"}"#),
|
||||||
|
(200, r#"{"git_hash":"abc1234"}"#),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = run_publish_script(port, "abc1234");
|
||||||
|
assert!(result.success, "expected success: {}", result.stderr);
|
||||||
|
|
||||||
|
let requests = log.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 2, "expected one upload + one manifest GET");
|
||||||
|
assert_eq!(requests[0].method, "POST");
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].authorization.as_deref(),
|
||||||
|
Some("Bearer test-token")
|
||||||
|
);
|
||||||
|
assert_eq!(requests[0].git_hash.as_deref(), Some("abc1234"));
|
||||||
|
assert_eq!(requests[1].method, "GET");
|
||||||
|
assert_eq!(requests[1].path, "/manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_fails_immediately_on_auth_failure_without_retry() {
|
||||||
|
let (port, log) = spawn_stub(vec![(401, r#"{"error":"bad token"}"#)]);
|
||||||
|
|
||||||
|
let result = run_publish_script(port, "abc1234");
|
||||||
|
assert!(!result.success, "auth failure must not succeed");
|
||||||
|
assert!(
|
||||||
|
result.stderr.contains("authentication failed"),
|
||||||
|
"stderr should explain the auth failure: {}",
|
||||||
|
result.stderr
|
||||||
|
);
|
||||||
|
|
||||||
|
let requests = log.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
requests.len(),
|
||||||
|
1,
|
||||||
|
"a 401 must not be retried, and must not reach the manifest check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_retries_transient_failure_then_succeeds() {
|
||||||
|
let (port, log) = spawn_stub(vec![
|
||||||
|
(500, "boom"),
|
||||||
|
(200, r#"{"status":"ok"}"#),
|
||||||
|
(200, r#"{"git_hash":"abc1234"}"#),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = run_publish_script(port, "abc1234");
|
||||||
|
assert!(
|
||||||
|
result.success,
|
||||||
|
"expected eventual success: {}",
|
||||||
|
result.stderr
|
||||||
|
);
|
||||||
|
|
||||||
|
let requests = log.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
requests.len(),
|
||||||
|
3,
|
||||||
|
"expected the failed upload, the retried upload, and the manifest GET"
|
||||||
|
);
|
||||||
|
assert_eq!(requests[0].method, "POST");
|
||||||
|
assert_eq!(requests[1].method, "POST");
|
||||||
|
assert_eq!(requests[2].method, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_fails_on_manifest_mismatch() {
|
||||||
|
let (port, log) = spawn_stub(vec![
|
||||||
|
(200, r#"{"status":"ok"}"#),
|
||||||
|
(200, r#"{"git_hash":"deadbee"}"#),
|
||||||
|
(200, r#"{"git_hash":"deadbee"}"#),
|
||||||
|
(200, r#"{"git_hash":"deadbee"}"#),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = run_publish_script(port, "abc1234");
|
||||||
|
assert!(!result.success, "manifest mismatch must fail");
|
||||||
|
assert!(
|
||||||
|
result.stderr.contains("manifest mismatch"),
|
||||||
|
"stderr should explain the mismatch: {}",
|
||||||
|
result.stderr
|
||||||
|
);
|
||||||
|
|
||||||
|
let requests = log.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
requests.len(),
|
||||||
|
4,
|
||||||
|
"expected the upload plus 3 manifest-check attempts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workflow yaml structure ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_yaml_triggers_on_master_push_and_targets_arm64_mac() {
|
||||||
|
let path = repo_root().join(".gitea/workflows/release-artifact.yml");
|
||||||
|
let content = std::fs::read_to_string(&path).expect("read release-artifact.yml");
|
||||||
|
let value: serde_yaml::Value = serde_yaml::from_str(&content).expect("valid yaml");
|
||||||
|
|
||||||
|
let branches = value["on"]["push"]["branches"]
|
||||||
|
.as_sequence()
|
||||||
|
.expect("on.push.branches sequence")
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_str().unwrap_or_default().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(branches, vec!["master".to_string()]);
|
||||||
|
|
||||||
|
let jobs = value["jobs"].as_mapping().expect("jobs mapping");
|
||||||
|
let (_, job) = jobs.iter().next().expect("at least one job defined");
|
||||||
|
|
||||||
|
let runs_on = job["runs-on"].as_sequence().expect("runs-on sequence");
|
||||||
|
let runs_on: Vec<&str> = runs_on.iter().filter_map(|v| v.as_str()).collect();
|
||||||
|
assert_eq!(runs_on, vec!["arm64-mac"]);
|
||||||
|
|
||||||
|
let steps = job["steps"].as_sequence().expect("steps sequence");
|
||||||
|
let calls_publish_script = steps.iter().any(|step| {
|
||||||
|
step.get("run")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.is_some_and(|s| s.contains("script/ci-publish-artifact"))
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
calls_publish_script,
|
||||||
|
"a step must invoke script/ci-publish-artifact"
|
||||||
|
);
|
||||||
|
|
||||||
|
let references_channel_secrets = steps.iter().any(|step| {
|
||||||
|
step.get("env")
|
||||||
|
.and_then(|e| e.as_mapping())
|
||||||
|
.is_some_and(|env| {
|
||||||
|
env.values().any(|v| {
|
||||||
|
v.as_str().is_some_and(|s| {
|
||||||
|
s.contains("secrets.HUSKIES_CHANNEL_URL")
|
||||||
|
|| s.contains("secrets.HUSKIES_CHANNEL_TOKEN")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
references_channel_secrets,
|
||||||
|
"workflow must reference the channel secrets via ${{{{ secrets.* }}}}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readme_documents_runner_label_and_secrets() {
|
||||||
|
let path = repo_root().join(".gitea/workflows/README.md");
|
||||||
|
let content = std::fs::read_to_string(&path).expect("read .gitea/workflows/README.md");
|
||||||
|
assert!(
|
||||||
|
content.contains("arm64-mac"),
|
||||||
|
"README must document the runner label"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("HUSKIES_CHANNEL_URL"),
|
||||||
|
"README must document HUSKIES_CHANNEL_URL"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("HUSKIES_CHANNEL_TOKEN"),
|
||||||
|
"README must document HUSKIES_CHANNEL_TOKEN"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ mod agent_log;
|
|||||||
mod agent_mode;
|
mod agent_mode;
|
||||||
mod agents;
|
mod agents;
|
||||||
mod chat;
|
mod chat;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ci_publish_artifact;
|
||||||
mod config;
|
mod config;
|
||||||
/// CRDT snapshot — serialisation and restore of the full pipeline CRDT state.
|
/// CRDT snapshot — serialisation and restore of the full pipeline CRDT state.
|
||||||
pub mod crdt_snapshot;
|
pub mod crdt_snapshot;
|
||||||
|
|||||||
Reference in New Issue
Block a user