From 8a5157086999e1b32a8337de6538c1602ab87ba8 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:42:11 +0100 Subject: [PATCH 001/215] feat(scripts): add devc-remote.sh skeleton with logging and stubs Refs: #152 Co-authored-by: Cursor --- scripts/devc-remote.sh | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100755 scripts/devc-remote.sh diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh new file mode 100755 index 00000000..318f0a56 --- /dev/null +++ b/scripts/devc-remote.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +############################################################################### +# devc-remote.sh - Remote devcontainer orchestrator +# +# Starts a devcontainer on a remote host via SSH and opens Cursor/VS Code. +# Handles SSH connectivity, pre-flight checks, container state detection, +# and compose lifecycle. URI construction delegated to Python helper. +# +# USAGE: +# ./scripts/devc-remote.sh [--path ] +# ./scripts/devc-remote.sh --help +# +# Part of #70. See issue #152 for design. +############################################################################### + +set -euo pipefail + +# ═══════════════════════════════════════════════════════════════════════════════ +# CONFIGURATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# shellcheck disable=SC2034 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ═══════════════════════════════════════════════════════════════════════════════ +# LOGGING (matches init.sh patterns) +# ═══════════════════════════════════════════════════════════════════════════════ + +log_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +log_error() { + echo -e "${RED}✗${NC} $1" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# ORCHESTRATION FUNCTIONS +# ═══════════════════════════════════════════════════════════════════════════════ + +parse_args() { + : +} + +detect_editor_cli() { + : +} + +check_ssh() { + : +} + +remote_preflight() { + : +} + +remote_compose_up() { + : +} + +open_editor() { + : +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════════════════════ + +main() { + parse_args "$@" + detect_editor_cli + check_ssh + remote_preflight + remote_compose_up + open_editor +} + +main "$@" From 1c5ac06c4998805dd3c08e589fe9866143068900 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:42:44 +0100 Subject: [PATCH 002/215] test(bats): add devc-remote script structure and parse_args tests Refs: #152 Co-authored-by: Cursor --- tests/bats/devc-remote.bats | 110 ++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/bats/devc-remote.bats diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats new file mode 100644 index 00000000..56ee6488 --- /dev/null +++ b/tests/bats/devc-remote.bats @@ -0,0 +1,110 @@ +#!/usr/bin/env bats +# shellcheck disable=SC2016 +# BATS tests for devc-remote.sh +# +# Tests the devc-remote.sh script which orchestrates starting a devcontainer +# on a remote host via SSH. These tests verify: +# - Script structure (set -euo pipefail, logging, functions) +# - Argument parsing (missing host, --help, --path, unknown flags) +# - detect_editor_cli, check_ssh, remote_preflight, remote_compose_up, open_editor +# +# Note: SC2016 disabled because we intentionally use single quotes to search +# for literal shell variable syntax in the target scripts. + +setup() { + load test_helper + DEVC_REMOTE="$PROJECT_ROOT/scripts/devc-remote.sh" +} + +# ── script structure ────────────────────────────────────────────────────────── + +@test "devc-remote.sh is executable" { + run test -x "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh has shebang" { + run head -1 "$DEVC_REMOTE" + assert_output "#!/usr/bin/env bash" +} + +@test "devc-remote.sh uses strict error handling (set -euo pipefail)" { + run grep 'set -euo pipefail' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines log_info function" { + run grep 'log_info()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines log_success function" { + run grep 'log_success()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines log_warning function" { + run grep 'log_warning()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines log_error function" { + run grep 'log_error()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines parse_args function" { + run grep 'parse_args()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines detect_editor_cli function" { + run grep 'detect_editor_cli()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines check_ssh function" { + run grep 'check_ssh()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines remote_preflight function" { + run grep 'remote_preflight()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines remote_compose_up function" { + run grep 'remote_compose_up()' "$DEVC_REMOTE" + assert_success +} + +@test "devc-remote.sh defines open_editor function" { + run grep 'open_editor()' "$DEVC_REMOTE" + assert_success +} + +# ── parse_args: missing host ────────────────────────────────────────────────── + +@test "devc-remote.sh with no args exits with error" { + run "$DEVC_REMOTE" + assert_failure +} + +# ── parse_args: --help ─────────────────────────────────────────────────────── + +@test "devc-remote.sh --help exits 0" { + run "$DEVC_REMOTE" --help + assert_success +} + +@test "devc-remote.sh --help shows usage" { + run "$DEVC_REMOTE" --help + assert_output --partial "usage" +} + +# ── parse_args: unknown flag ────────────────────────────────────────────────── + +@test "devc-remote.sh with unknown flag exits with error" { + run "$DEVC_REMOTE" --unknown-flag myserver + assert_failure +} From 35ed6206ee678dfa6335c83fa590620a16b70e80 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:42:51 +0100 Subject: [PATCH 003/215] test(devc_remote_uri): add hex_encode unit tests Refs: #153 Co-authored-by: Cursor --- scripts/devc_remote_uri.py | 9 +++++++++ tests/test_devc_remote_uri.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 scripts/devc_remote_uri.py create mode 100644 tests/test_devc_remote_uri.py diff --git a/scripts/devc_remote_uri.py b/scripts/devc_remote_uri.py new file mode 100644 index 00000000..ab534ad4 --- /dev/null +++ b/scripts/devc_remote_uri.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""Build Cursor/VS Code nested authority URI for remote devcontainers.""" + +from __future__ import annotations + + +def hex_encode(s: str) -> str: + """Hex-encode a string (UTF-8). Stub for TDD.""" + raise NotImplementedError diff --git a/tests/test_devc_remote_uri.py b/tests/test_devc_remote_uri.py new file mode 100644 index 00000000..de27cb9a --- /dev/null +++ b/tests/test_devc_remote_uri.py @@ -0,0 +1,29 @@ +"""Tests for scripts/devc_remote_uri.py — Cursor URI construction for remote devcontainers.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +scripts_dir = Path(__file__).parent.parent / "scripts" +devc_spec = importlib.util.spec_from_file_location( + "devc_remote_uri", scripts_dir / "devc_remote_uri.py" +) +devc_remote_uri = importlib.util.module_from_spec(devc_spec) +devc_spec.loader.exec_module(devc_remote_uri) + + +class TestHexEncode: + """Test hex_encode function.""" + + def test_hex_encode_simple_string(self): + """Known input produces exact hex output.""" + assert devc_remote_uri.hex_encode("a") == "61" + + def test_hex_encode_empty_string(self): + """Empty string produces empty hex.""" + assert devc_remote_uri.hex_encode("") == "" + + def test_hex_encode_unicode(self): + """Unicode string is UTF-8 encoded then hexed.""" + assert devc_remote_uri.hex_encode("é") == "c3a9" From 46c7ba2db3e0705f9cf5537f34d493f4e612b7f5 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:42:56 +0100 Subject: [PATCH 004/215] feat(devc_remote_uri): implement hex_encode Refs: #153 Co-authored-by: Cursor --- scripts/devc_remote_uri.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/devc_remote_uri.py b/scripts/devc_remote_uri.py index ab534ad4..3c9f6639 100644 --- a/scripts/devc_remote_uri.py +++ b/scripts/devc_remote_uri.py @@ -5,5 +5,5 @@ def hex_encode(s: str) -> str: - """Hex-encode a string (UTF-8). Stub for TDD.""" - raise NotImplementedError + """Hex-encode a string (UTF-8).""" + return s.encode().hex() From 73c4fad6d4da8e278714a508971fabca3928e552 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:43:05 +0100 Subject: [PATCH 005/215] feat(scripts): implement parse_args in devc-remote.sh Refs: #152 Co-authored-by: Cursor --- scripts/devc-remote.sh | 46 +++++++++++++++++++++++++++++++++---- tests/bats/devc-remote.bats | 2 +- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 318f0a56..1c0d5d28 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -49,12 +49,50 @@ log_error() { echo -e "${RED}✗${NC} $1" } -# ═══════════════════════════════════════════════════════════════════════════════ -# ORCHESTRATION FUNCTIONS -# ═══════════════════════════════════════════════════════════════════════════════ +show_help() { + sed -n '/^###############################################################################$/,/^###############################################################################$/p' "$0" | sed '1d;$d' + exit 0 +} parse_args() { - : + SSH_HOST="" + REMOTE_PATH="$HOME" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help + ;; + --path) + if [[ $# -lt 2 ]]; then + log_error "Missing value for --path" + exit 1 + fi + # shellcheck disable=SC2034 + REMOTE_PATH="$2" + shift 2 + ;; + -*) + log_error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + *) + if [[ -n "$SSH_HOST" ]]; then + log_error "Unexpected argument: $1" + exit 1 + fi + SSH_HOST="$1" + shift + ;; + esac + done + + if [[ -z "$SSH_HOST" ]]; then + log_error "Missing required argument: " + echo "Use --help for usage information" + exit 1 + fi } detect_editor_cli() { diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 56ee6488..fe6af2eb 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -99,7 +99,7 @@ setup() { @test "devc-remote.sh --help shows usage" { run "$DEVC_REMOTE" --help - assert_output --partial "usage" + assert_output --partial "USAGE" } # ── parse_args: unknown flag ────────────────────────────────────────────────── From d9d9f810f2a35dd63fe1d2c527c61f86573e27f7 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:43:13 +0100 Subject: [PATCH 006/215] test(devc_remote_uri): add build_uri unit tests Refs: #153 Co-authored-by: Cursor --- tests/test_devc_remote_uri.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_devc_remote_uri.py b/tests/test_devc_remote_uri.py index de27cb9a..3113c83d 100644 --- a/tests/test_devc_remote_uri.py +++ b/tests/test_devc_remote_uri.py @@ -27,3 +27,33 @@ def test_hex_encode_empty_string(self): def test_hex_encode_unicode(self): """Unicode string is UTF-8 encoded then hexed.""" assert devc_remote_uri.hex_encode("é") == "c3a9" + + +class TestBuildUri: + """Test build_uri function.""" + + def test_build_uri_matches_cursor_format(self): + """Known inputs produce exact URI matching Cursor docs.""" + uri = devc_remote_uri.build_uri( + workspace_path="/home/user/repo", + devcontainer_path="/home/user/repo/.devcontainer/devcontainer.json", + ssh_host="loginnode", + container_workspace="/workspace", + ) + expected = ( + "vscode-remote://dev-container+" + "7b2273657474696e6754797065223a22636f6e666967222c22776f726b737061636550617468223a222f686f6d652f757365722f7265706f222c22646576636f6e7461696e657250617468223a222f686f6d652f757365722f7265706f2f2e646576636f6e7461696e65722f646576636f6e7461696e65722e6a736f6e227d" + "@ssh-remote+loginnode/workspace" + ) + assert uri == expected + + def test_build_uri_container_workspace_without_leading_slash(self): + """container_workspace without leading slash is normalized.""" + uri = devc_remote_uri.build_uri( + workspace_path="/repo", + devcontainer_path="/repo/.devcontainer/devcontainer.json", + ssh_host="host", + container_workspace="workspace", + ) + assert uri.endswith("/workspace") + assert "@ssh-remote+host" in uri From 13ec6ebd0b4dd7ec65c0e20c8e2f7c0fd62fb239 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:43:22 +0100 Subject: [PATCH 007/215] feat(devc_remote_uri): implement build_uri Refs: #153 Co-authored-by: Cursor --- scripts/devc_remote_uri.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/devc_remote_uri.py b/scripts/devc_remote_uri.py index 3c9f6639..c4679f5c 100644 --- a/scripts/devc_remote_uri.py +++ b/scripts/devc_remote_uri.py @@ -3,7 +3,29 @@ from __future__ import annotations +import json + def hex_encode(s: str) -> str: """Hex-encode a string (UTF-8).""" return s.encode().hex() + + +def build_uri( + workspace_path: str, + devcontainer_path: str, + ssh_host: str, + container_workspace: str, +) -> str: + """Build vscode-remote URI for dev-container over SSH. + + Format: vscode-remote://dev-container+{DC_HEX}@ssh-remote+{SSH_SPEC}/{container_workspace} + """ + spec = { + "settingType": "config", + "workspacePath": workspace_path, + "devcontainerPath": devcontainer_path, + } + dc_hex = hex_encode(json.dumps(spec, separators=(",", ":"))) + path = "/" + container_workspace.lstrip("/") + return f"vscode-remote://dev-container+{dc_hex}@ssh-remote+{ssh_host}{path}" From 19b2e513d34f805d66795822574f6383f180a722 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:43:52 +0100 Subject: [PATCH 008/215] feat(scripts): implement detect_editor_cli in devc-remote.sh Refs: #152 Co-authored-by: Cursor --- scripts/devc-remote.sh | 11 ++++++++++- tests/bats/devc-remote.bats | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 1c0d5d28..80d2e831 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -96,7 +96,16 @@ parse_args() { } detect_editor_cli() { - : + if command -v cursor &>/dev/null; then + # shellcheck disable=SC2034 + EDITOR_CLI="cursor" + elif command -v code &>/dev/null; then + # shellcheck disable=SC2034 + EDITOR_CLI="code" + else + log_error "Neither cursor nor code CLI found. Install Cursor or VS Code and enable the shell command." + exit 1 + fi } check_ssh() { diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index fe6af2eb..5e61b775 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -108,3 +108,36 @@ setup() { run "$DEVC_REMOTE" --unknown-flag myserver assert_failure } + +# ── detect_editor_cli ───────────────────────────────────────────────────────── + +@test "detect_editor_cli prefers cursor when both cursor and code exist" { + local mock_bin + mock_bin="$(mktemp -d)" + echo '#!/bin/sh' > "$mock_bin/cursor" + echo '#!/bin/sh' > "$mock_bin/code" + chmod +x "$mock_bin/cursor" "$mock_bin/code" + # Script will fail at check_ssh, but we verify cursor was chosen by checking + # we get past detect_editor_cli (would fail with "Neither cursor nor code" otherwise) + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" nonexistent-host 2>&1 + # Should not contain "Neither cursor nor code" - fails at check_ssh instead + refute_output --partial "Neither cursor nor code" + rm -rf "$mock_bin" +} + +@test "detect_editor_cli uses code when cursor not found" { + local mock_bin + mock_bin="$(mktemp -d)" + echo '#!/bin/sh' > "$mock_bin/code" + chmod +x "$mock_bin/code" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" nonexistent-host 2>&1 + refute_output --partial "Neither cursor nor code" + rm -rf "$mock_bin" +} + +@test "detect_editor_cli fails when neither cursor nor code in PATH" { + # Use env -i for clean environment; minimal PATH has no cursor/code + run env -i PATH="/usr/bin:/bin" HOME="$HOME" "$DEVC_REMOTE" myserver 2>&1 + assert_failure + assert_output --partial "Neither cursor nor code" +} From d9496e830b256c8fccec983a2e2420c9ce7bd1be Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:43:55 +0100 Subject: [PATCH 009/215] test(devc_remote_uri): add CLI tests Refs: #153 Co-authored-by: Cursor --- tests/test_devc_remote_uri.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_devc_remote_uri.py b/tests/test_devc_remote_uri.py index 3113c83d..02b41c11 100644 --- a/tests/test_devc_remote_uri.py +++ b/tests/test_devc_remote_uri.py @@ -3,9 +3,23 @@ from __future__ import annotations import importlib.util +import subprocess +import sys from pathlib import Path scripts_dir = Path(__file__).parent.parent / "scripts" +script_path = scripts_dir / "devc_remote_uri.py" + + +def _run_cli(*args: str) -> subprocess.CompletedProcess[str]: + """Run devc_remote_uri.py with given args.""" + return subprocess.run( + [sys.executable, str(script_path), *args], + capture_output=True, + text=True, + ) + + devc_spec = importlib.util.spec_from_file_location( "devc_remote_uri", scripts_dir / "devc_remote_uri.py" ) @@ -57,3 +71,34 @@ def test_build_uri_container_workspace_without_leading_slash(self): ) assert uri.endswith("/workspace") assert "@ssh-remote+host" in uri + + +class TestCli: + """Test CLI interface.""" + + def test_cli_prints_uri_to_stdout(self): + """CLI with valid args prints URI to stdout.""" + result = _run_cli("/repo", "host", "/workspace") + assert result.returncode == 0 + assert result.stdout.startswith("vscode-remote://dev-container+") + assert "@ssh-remote+host" in result.stdout + assert result.stderr == "" + + def test_cli_with_devcontainer_path_arg(self): + """CLI accepts optional devcontainer path.""" + result = _run_cli( + "/repo", + "host", + "/workspace", + "--devcontainer-path", + "/custom/devcontainer.json", + ) + assert result.returncode == 0 + assert result.stdout.startswith("vscode-remote://dev-container+") + assert "@ssh-remote+host" in result.stdout + + def test_cli_missing_args_exits_nonzero(self): + """CLI with missing args exits with code 2.""" + result = _run_cli("/repo") + assert result.returncode == 2 + assert "usage" in result.stderr.lower() or "error" in result.stderr.lower() From d6678b0afd96ad0ee5fa152e7c501cadf0d9e2e3 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:44:09 +0100 Subject: [PATCH 010/215] feat(scripts): implement check_ssh in devc-remote.sh Refs: #152 Co-authored-by: Cursor --- scripts/devc-remote.sh | 5 ++++- tests/bats/devc-remote.bats | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 80d2e831..b98eeaf9 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -109,7 +109,10 @@ detect_editor_cli() { } check_ssh() { - : + if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" true 2>/dev/null; then + log_error "Cannot connect to $SSH_HOST. Check your SSH config and network." + exit 1 + fi } remote_preflight() { diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 5e61b775..98e52f3c 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -141,3 +141,32 @@ setup() { assert_failure assert_output --partial "Neither cursor nor code" } + +# ── check_ssh ──────────────────────────────────────────────────────────────── + +@test "check_ssh succeeds when ssh connects" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + # Need cursor for detect_editor_cli + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" anyhost 2>&1 + # Should get past check_ssh; will fail at remote_preflight (mock ssh just exits) + refute_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "check_ssh fails when ssh returns non-zero" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" badhost 2>&1 + assert_failure + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} From 45d8be389165cb40a8c80920604f00e38b95de7f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:44:26 +0100 Subject: [PATCH 011/215] feat(devc_remote_uri): implement CLI with argparse Refs: #153 Co-authored-by: Cursor --- scripts/devc_remote_uri.py | 39 +++++++++++++++++++++++++++++++++++ tests/test_devc_remote_uri.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/scripts/devc_remote_uri.py b/scripts/devc_remote_uri.py index c4679f5c..e32a5362 100644 --- a/scripts/devc_remote_uri.py +++ b/scripts/devc_remote_uri.py @@ -3,6 +3,7 @@ from __future__ import annotations +import argparse import json @@ -21,6 +22,14 @@ def build_uri( Format: vscode-remote://dev-container+{DC_HEX}@ssh-remote+{SSH_SPEC}/{container_workspace} """ + if not workspace_path: + raise ValueError("workspace_path cannot be empty") + if not devcontainer_path: + raise ValueError("devcontainer_path cannot be empty") + if not ssh_host: + raise ValueError("ssh_host cannot be empty") + if not container_workspace: + raise ValueError("container_workspace cannot be empty") spec = { "settingType": "config", "workspacePath": workspace_path, @@ -29,3 +38,33 @@ def build_uri( dc_hex = hex_encode(json.dumps(spec, separators=(",", ":"))) path = "/" + container_workspace.lstrip("/") return f"vscode-remote://dev-container+{dc_hex}@ssh-remote+{ssh_host}{path}" + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Build Cursor/VS Code URI for remote devcontainers" + ) + parser.add_argument("workspace_path", help="Workspace path on the remote host") + parser.add_argument("ssh_host", help="SSH host from ~/.ssh/config") + parser.add_argument("container_workspace", help="Container workspace path") + parser.add_argument( + "--devcontainer-path", + help="Path to devcontainer.json (default: {workspace_path}/.devcontainer/devcontainer.json)", + ) + args = parser.parse_args() + + devcontainer_path = args.devcontainer_path or ( + f"{args.workspace_path.rstrip('/')}/.devcontainer/devcontainer.json" + ) + uri = build_uri( + workspace_path=args.workspace_path, + devcontainer_path=devcontainer_path, + ssh_host=args.ssh_host, + container_workspace=args.container_workspace, + ) + print(uri) + + +if __name__ == "__main__": + main() diff --git a/tests/test_devc_remote_uri.py b/tests/test_devc_remote_uri.py index 02b41c11..75762e39 100644 --- a/tests/test_devc_remote_uri.py +++ b/tests/test_devc_remote_uri.py @@ -7,6 +7,8 @@ import sys from pathlib import Path +import pytest + scripts_dir = Path(__file__).parent.parent / "scripts" script_path = scripts_dir / "devc_remote_uri.py" @@ -102,3 +104,39 @@ def test_cli_missing_args_exits_nonzero(self): result = _run_cli("/repo") assert result.returncode == 2 assert "usage" in result.stderr.lower() or "error" in result.stderr.lower() + + +class TestEdgeCases: + """Edge cases: special chars, spaces, empty strings.""" + + def test_build_uri_special_chars_in_paths(self): + """Paths with special characters are hex-encoded correctly.""" + uri = devc_remote_uri.build_uri( + workspace_path="/home/user/repo (dev)", + devcontainer_path="/home/user/repo (dev)/.devcontainer/devcontainer.json", + ssh_host="host", + container_workspace="/workspace", + ) + assert uri.startswith("vscode-remote://dev-container+") + assert "@ssh-remote+host" in uri + assert uri.endswith("/workspace") + + def test_build_uri_spaces_in_ssh_host(self): + """SSH host with spaces is passed through (for full SSH spec).""" + uri = devc_remote_uri.build_uri( + workspace_path="/repo", + devcontainer_path="/repo/.devcontainer/devcontainer.json", + ssh_host="user@1.2.3.4 -p 22", + container_workspace="/workspace", + ) + assert "user@1.2.3.4 -p 22" in uri + + def test_build_uri_empty_string_raises(self): + """Empty workspace_path raises ValueError.""" + with pytest.raises(ValueError, match="workspace_path"): + devc_remote_uri.build_uri( + workspace_path="", + devcontainer_path="/repo/.devcontainer/devcontainer.json", + ssh_host="host", + container_workspace="/workspace", + ) From 63a20994e4a93730217683193ef2dd36bfaafd39 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:44:56 +0100 Subject: [PATCH 012/215] feat(scripts): implement remote_preflight in devc-remote.sh Refs: #152 Co-authored-by: Cursor --- scripts/devc-remote.sh | 72 ++++++++++++++++++++++++++++++++++++- tests/bats/devc-remote.bats | 45 +++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index b98eeaf9..2c14d67b 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -116,7 +116,77 @@ check_ssh() { } remote_preflight() { - : + local preflight_output + # shellcheck disable=SC2029 + preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' +REPO_PATH="${1:-$HOME}" +if command -v podman &>/dev/null; then + echo "RUNTIME=podman" +elif command -v docker &>/dev/null; then + echo "RUNTIME=docker" +else + echo "RUNTIME=" +fi +if (command -v podman &>/dev/null && podman compose version &>/dev/null) || \ + (command -v docker &>/dev/null && docker compose version &>/dev/null); then + echo "COMPOSE_AVAILABLE=1" +else + echo "COMPOSE_AVAILABLE=0" +fi +if [ -d "$REPO_PATH" ]; then + echo "REPO_PATH_EXISTS=1" +else + echo "REPO_PATH_EXISTS=0" +fi +if [ -d "$REPO_PATH/.devcontainer" ]; then + echo "DEVCONTAINER_EXISTS=1" +else + echo "DEVCONTAINER_EXISTS=0" +fi +AVAIL_GB=$(df -BG "$REPO_PATH" 2>/dev/null | awk 'NR==2 {gsub(/G/,""); print $4}') +echo "DISK_AVAILABLE_GB=${AVAIL_GB:-0}" +if [ "$(uname -s)" = "Darwin" ]; then + echo "OS_TYPE=macos" +else + echo "OS_TYPE=linux" +fi +REMOTEEOF + ) + + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + RUNTIME) RUNTIME="${BASH_REMATCH[2]}" ;; + COMPOSE_AVAILABLE) COMPOSE_AVAILABLE="${BASH_REMATCH[2]}" ;; + REPO_PATH_EXISTS) REPO_PATH_EXISTS="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; + DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; + OS_TYPE) OS_TYPE="${BASH_REMATCH[2]}" ;; + esac + done <<< "$preflight_output" + + if [[ -z "${RUNTIME:-}" ]]; then + log_error "No container runtime found on $SSH_HOST. Install podman or docker." + exit 1 + fi + if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then + log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." + exit 1 + fi + if [[ "${REPO_PATH_EXISTS:-0}" != "1" ]]; then + log_error "Repository not found at $REMOTE_PATH on $SSH_HOST." + exit 1 + fi + if [[ "${DEVCONTAINER_EXISTS:-0}" != "1" ]]; then + log_error "No .devcontainer/ found in $REMOTE_PATH. Is this a devcontainer-enabled project?" + exit 1 + fi + if [[ "${DISK_AVAILABLE_GB:-0}" -lt 2 ]] 2>/dev/null; then + log_warning "Low disk space on $SSH_HOST (${DISK_AVAILABLE_GB:-0}GB). At least 2GB recommended." + fi + if [[ "${OS_TYPE:-}" == "macos" ]]; then + log_warning "Remote host is macOS. Devcontainer support may be limited." + fi } remote_compose_up() { diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 98e52f3c..db518002 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -170,3 +170,48 @@ setup() { assert_output --partial "Cannot connect to" rm -rf "$mock_bin" } + +# ── remote_preflight ───────────────────────────────────────────────────────── + +@test "remote_preflight parses structured KEY=value output" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << 'SSHEOF' +#!/bin/sh +echo "RUNTIME=podman" +echo "COMPOSE_AVAILABLE=1" +echo "REPO_PATH_EXISTS=1" +echo "DEVCONTAINER_EXISTS=1" +echo "DISK_AVAILABLE_GB=5" +echo "OS_TYPE=linux" +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + # Will fail at remote_compose_up or open_editor; we verify we get past preflight + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + refute_output --partial "No container runtime" + refute_output --partial "Compose not available" + refute_output --partial "Repository not found" + refute_output --partial "No .devcontainer" + rm -rf "$mock_bin" +} + +@test "remote_preflight fails when runtime missing" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << 'SSHEOF' +#!/bin/sh +echo "RUNTIME=" +echo "COMPOSE_AVAILABLE=0" +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + assert_failure + assert_output --partial "No container runtime" + rm -rf "$mock_bin" +} From 07330ebf846291e0ae59eff25ee7ee19d89b380a Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:47:05 +0100 Subject: [PATCH 013/215] docs: add CHANGELOG entry for devc_remote_uri Refs: #153 Co-authored-by: Cursor --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2431306f..6376875e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **devc_remote_uri.py — Cursor URI construction for remote devcontainers** ([#153](https://github.com/vig-os/devcontainer/issues/153)) + - Standalone Python module with `hex_encode()` and `build_uri()` for vscode-remote URIs + - CLI: `devc_remote_uri.py ` prints URI to stdout + - Stdlib only (json, argparse); called by devc-remote.sh (sibling sub-issue) - **CI status column in just gh-issues PR table** ([#143](https://github.com/vig-os/devcontainer/issues/143)) - PR table shows CI column with pass/fail/pending summary (✓ 6/6, ⏳ 3/6, ✗ 5/6) - Failed check names visible when checks fail From a31e038bd743f35563da63e5d554693abb7940b0 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 22 Feb 2026 10:51:13 +0100 Subject: [PATCH 014/215] feat(scripts): implement remote_compose_up, open_editor, and Python URI stub Refs: #152 Co-authored-by: Cursor --- CHANGELOG.md | 4 +++ scripts/devc-remote.sh | 25 ++++++++++++-- scripts/devc_remote_uri.py | 25 ++++++++++++++ tests/bats/devc-remote.bats | 66 +++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100755 scripts/devc_remote_uri.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2431306f..0b49bf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **devc-remote.sh — bash orchestrator for remote devcontainer** ([#152](https://github.com/vig-os/devcontainer/issues/152)) + - `scripts/devc-remote.sh`: parse_args, detect_editor_cli, check_ssh, remote_preflight, remote_compose_up, open_editor + - `scripts/devc_remote_uri.py`: stub for URI construction (sibling sub-issue) + - BATS unit tests with mocked commands - **CI status column in just gh-issues PR table** ([#143](https://github.com/vig-os/devcontainer/issues/143)) - PR table shows CI column with pass/fail/pending summary (✓ 6/6, ⏳ 3/6, ✗ 5/6) - Failed check names visible when checks fail diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 2c14d67b..e1ee9e8a 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -169,6 +169,11 @@ REMOTEEOF log_error "No container runtime found on $SSH_HOST. Install podman or docker." exit 1 fi + if [[ "$RUNTIME" == "podman" ]]; then + COMPOSE_CMD="podman compose" + else + COMPOSE_CMD="docker compose" + fi if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." exit 1 @@ -190,11 +195,27 @@ REMOTEEOF } remote_compose_up() { - : + local ps_output state health + # shellcheck disable=SC2029 + ps_output=$(ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD ps --format json 2>/dev/null" || true) + state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4) + health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4) + + if [[ "$state" == "running" && "${health:-}" == "healthy" ]]; then + log_success "Devcontainer already running on $SSH_HOST. Opening..." + else + log_info "Starting devcontainer on $SSH_HOST..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d" >/dev/null 2>&1 + # Health poll (simplified: assume success for now) + sleep 2 + fi } open_editor() { - : + local uri + uri=$(python3 "$SCRIPT_DIR/devc_remote_uri.py" --ssh-host "$SSH_HOST" --path "$REMOTE_PATH") + "$EDITOR_CLI" --folder-uri "$uri" } # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/scripts/devc_remote_uri.py b/scripts/devc_remote_uri.py new file mode 100755 index 00000000..bf56948d --- /dev/null +++ b/scripts/devc_remote_uri.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +""" +Stub for devcontainer remote URI construction. + +Returns a placeholder URI for testing. Full implementation in sibling sub-issue. +Accepts --ssh-host and --path. +""" + +import argparse +import sys + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--ssh-host", required=True) + parser.add_argument("--path", required=True) + args = parser.parse_args() + # Placeholder URI for orchestration flow testing + uri = f"vscode-remote://dev-container+placeholder@ssh-remote+{args.ssh_host}/{args.path}" + print(uri) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index db518002..08ec8e77 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -215,3 +215,69 @@ SSHEOF assert_output --partial "No container runtime" rm -rf "$mock_bin" } + +# ── open_editor ────────────────────────────────────────────────────────────── + +@test "open_editor calls URI helper and editor" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "2" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" + chmod +x "$mock_bin/python3" + printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + assert_success + assert_output --partial "Devcontainer already running" + rm -rf "$mock_bin" +} + +# ── remote_compose_up ──────────────────────────────────────────────────────── + +@test "remote_compose_up skips when container running and healthy" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +# check_ssh=0, preflight=1, compose_ps=2 +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "2" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +else + : +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + refute_output --partial "compose up" + rm -rf "$mock_bin" +} From f7f10705387e2058f2c8b039ba4a2a18f3d3eb2b Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 00:16:55 +0100 Subject: [PATCH 015/215] fix(scripts): correct devc-remote.sh URI construction and add justfile recipe - Fix open_editor() to read workspaceFolder from remote devcontainer.json - Call devc_remote_uri.py with correct positional arguments - Add devc-remote recipe to justfile.base for convenient access Refs: #70 Co-authored-by: Cursor --- justfile.base | 12 ++++++++++++ scripts/devc-remote.sh | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/justfile.base b/justfile.base index 80950329..a0fdf552 100644 --- a/justfile.base +++ b/justfile.base @@ -131,3 +131,15 @@ sidecar name *args: # Execute the recipe in the sidecar podman exec {{name}} just {{args}} + +# ------------------------------------------------------------------------------- +# REMOTE DEVCONTAINER +# ------------------------------------------------------------------------------- + +# Start a devcontainer on a remote host and open Cursor/VS Code +# Usage: just devc-remote [--path ] +# Example: just devc-remote myserver +# just devc-remote user@host --path /opt/projects/myrepo +[group('devcontainer')] +devc-remote host *args: + bash scripts/devc-remote.sh {{host}} {{args}} diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index e1ee9e8a..b1589b49 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -213,8 +213,23 @@ remote_compose_up() { } open_editor() { - local uri - uri=$(python3 "$SCRIPT_DIR/devc_remote_uri.py" --ssh-host "$SSH_HOST" --path "$REMOTE_PATH") + local container_workspace uri + # Read workspaceFolder from devcontainer.json on remote host + # shellcheck disable=SC2029 + container_workspace=$(ssh "$SSH_HOST" \ + "grep -o '\"workspaceFolder\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \ + ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" \ + | sed 's/.*: *"//;s/"//' || echo "/workspace") + + # Default to /workspace if workspaceFolder not found + container_workspace="${container_workspace:-/workspace}" + + # Build URI using Python helper + uri=$(python3 "$SCRIPT_DIR/devc_remote_uri.py" \ + "$REMOTE_PATH" \ + "$SSH_HOST" \ + "$container_workspace") + "$EDITOR_CLI" --folder-uri "$uri" } From f23b2814fd3089c152b92d4642cc04b2e6b5c348 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 01:05:39 +0100 Subject: [PATCH 016/215] refactor(scripts): use SSH-style path format for devc-remote Change argument format from '--path' flag to SSH-style 'host:path' syntax: - Accept user@host:path or host:path format - Default to $HOME if no path specified - Update justfile recipe to match new format This follows familiar SSH conventions and simplifies usage. Refs: #70 Co-authored-by: Cursor --- justfile.base | 9 +++++---- scripts/devc-remote.sh | 28 ++++++++++++++++------------ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/justfile.base b/justfile.base index 890aa007..a912d722 100644 --- a/justfile.base +++ b/justfile.base @@ -373,9 +373,10 @@ sidecar name *args: # ------------------------------------------------------------------------------- # Start a devcontainer on a remote host and open Cursor/VS Code -# Usage: just devc-remote [--path ] +# Usage: just devc-remote [:] # Example: just devc-remote myserver -# just devc-remote user@host --path /opt/projects/myrepo +# just devc-remote user@host:/opt/projects/myrepo +# just devc-remote myserver:/home/user/repo [group('devcontainer')] -devc-remote host *args: - bash scripts/devc-remote.sh {{host}} {{args}} +devc-remote host_path: + bash scripts/devc-remote.sh {{host_path}} diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index b1589b49..9d44f46c 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -7,9 +7,14 @@ # and compose lifecycle. URI construction delegated to Python helper. # # USAGE: -# ./scripts/devc-remote.sh [--path ] +# ./scripts/devc-remote.sh [:] # ./scripts/devc-remote.sh --help # +# Examples: +# ./scripts/devc-remote.sh myserver +# ./scripts/devc-remote.sh user@host:/opt/projects/myrepo +# ./scripts/devc-remote.sh myserver:/home/user/repo +# # Part of #70. See issue #152 for design. ############################################################################### @@ -63,15 +68,6 @@ parse_args() { --help|-h) show_help ;; - --path) - if [[ $# -lt 2 ]]; then - log_error "Missing value for --path" - exit 1 - fi - # shellcheck disable=SC2034 - REMOTE_PATH="$2" - shift 2 - ;; -*) log_error "Unknown option: $1" echo "Use --help for usage information" @@ -82,14 +78,22 @@ parse_args() { log_error "Unexpected argument: $1" exit 1 fi - SSH_HOST="$1" + # Parse SSH-style format: user@host:path or host:path + if [[ "$1" =~ ^([^:]+):(.+)$ ]]; then + SSH_HOST="${BASH_REMATCH[1]}" + REMOTE_PATH="${BASH_REMATCH[2]}" + else + SSH_HOST="$1" + # Default to $HOME if no path specified + REMOTE_PATH="$HOME" + fi shift ;; esac done if [[ -z "$SSH_HOST" ]]; then - log_error "Missing required argument: " + log_error "Missing required argument: [:]" echo "Use --help for usage information" exit 1 fi From 79297b5cf84337801a3311468e0b4a685859995c Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 01:06:50 +0100 Subject: [PATCH 017/215] fix(scripts): default remote path to ~ instead of $HOME When no path is specified, use ~ which will be expanded by the remote shell to the remote user's home directory, matching SSH conventions. Refs: #70 Co-authored-by: Cursor --- scripts/devc-remote.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 9d44f46c..e6d69cd8 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -61,7 +61,7 @@ show_help() { parse_args() { SSH_HOST="" - REMOTE_PATH="$HOME" + REMOTE_PATH="~" while [[ $# -gt 0 ]]; do case "$1" in @@ -84,8 +84,8 @@ parse_args() { REMOTE_PATH="${BASH_REMATCH[2]}" else SSH_HOST="$1" - # Default to $HOME if no path specified - REMOTE_PATH="$HOME" + # Default to ~ (expanded by remote shell) if no path specified + REMOTE_PATH="~" fi shift ;; From 9a4b1cce8a5a58af867a3299765d6ba6abd4f010 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 16:15:17 +0100 Subject: [PATCH 018/215] fix(ci): include devc_remote_uri tests in project checks coverage Refs: #70 Co-authored-by: Cursor --- .github/actions/test-project/action.yml | 1 + CHANGELOG.md | 3 +++ tests/test_devc_remote_uri.py | 30 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/.github/actions/test-project/action.yml b/.github/actions/test-project/action.yml index 5285339d..b5e047cb 100644 --- a/.github/actions/test-project/action.yml +++ b/.github/actions/test-project/action.yml @@ -95,6 +95,7 @@ runs: run: | uv run pytest \ tests/test_utils.py \ + tests/test_devc_remote_uri.py \ packages/vig-utils/tests \ --cov --cov-report=term-missing --cov-report=xml \ $TEST_ARGS diff --git a/CHANGELOG.md b/CHANGELOG.md index 62484fd5..f4b2c03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **CI Project Checks coverage includes devc_remote_uri tests** ([#70](https://github.com/vig-os/devcontainer/issues/70)) + - Add `tests/test_devc_remote_uri.py` to test-project action pytest run + - Add build_uri validation tests for empty devcontainer_path, ssh_host, container_workspace - **gh-issues cross-ref detects Refs: #N in PR bodies** ([#121](https://github.com/vig-os/devcontainer/issues/121)) - `_build_cross_refs` now parses `Refs: #102` and comma-separated variants (`Refs: #102, #103`) alongside Closes/Fixes/Resolves - **PR table Reviewer column distinguishes requested vs completed reviewers** ([#105](https://github.com/vig-os/devcontainer/issues/105)) diff --git a/tests/test_devc_remote_uri.py b/tests/test_devc_remote_uri.py index 75762e39..bc8c78e6 100644 --- a/tests/test_devc_remote_uri.py +++ b/tests/test_devc_remote_uri.py @@ -140,3 +140,33 @@ def test_build_uri_empty_string_raises(self): ssh_host="host", container_workspace="/workspace", ) + + def test_build_uri_empty_devcontainer_path_raises(self): + """Empty devcontainer_path raises ValueError.""" + with pytest.raises(ValueError, match="devcontainer_path"): + devc_remote_uri.build_uri( + workspace_path="/repo", + devcontainer_path="", + ssh_host="host", + container_workspace="/workspace", + ) + + def test_build_uri_empty_ssh_host_raises(self): + """Empty ssh_host raises ValueError.""" + with pytest.raises(ValueError, match="ssh_host"): + devc_remote_uri.build_uri( + workspace_path="/repo", + devcontainer_path="/repo/.devcontainer/devcontainer.json", + ssh_host="", + container_workspace="/workspace", + ) + + def test_build_uri_empty_container_workspace_raises(self): + """Empty container_workspace raises ValueError.""" + with pytest.raises(ValueError, match="container_workspace"): + devc_remote_uri.build_uri( + workspace_path="/repo", + devcontainer_path="/repo/.devcontainer/devcontainer.json", + ssh_host="host", + container_workspace="", + ) From 1e955c9f06bb551f124ff61860a40ed2369f0200 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 17:33:04 +0100 Subject: [PATCH 019/215] test: add red test for bandit installation Refs: #186 Co-authored-by: Cursor --- tests/test_image.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_image.py b/tests/test_image.py index 342603a9..6f58a1b7 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -23,6 +23,7 @@ "pre_commit": "4.5.", # Minor version check (installed via uv pip) "ruff": "0.15.", # Minor version check (installed via uv pip) "pip_licenses": "5.", # Major version check (installed via uv pip) + "bandit": "1.", # Major version check (installed via uv pip) "just": "1.46.", # Minor version check (manually installed from latest release) "cargo-binstall": "1.17.", # Minor version check (installed from latest release), "typstyle": "0.14.", # Minor version check (installed from latest release) @@ -342,6 +343,16 @@ def test_pip_licenses_installed(self, host): f"Expected pip-licenses {expected}, got: {result.stdout}" ) + def test_bandit_installed(self, host): + """Test that bandit is installed.""" + result = host.run("bandit --version") + assert result.rc == 0, "bandit --version failed" + assert "bandit" in result.stdout.lower() + expected = EXPECTED_VERSIONS["bandit"] + assert expected in result.stdout, ( + f"Expected bandit {expected}, got: {result.stdout}" + ) + def test_vig_utils_installed(self, host): """Test that vig-utils is installed and importable.""" result = host.run("python3 -c 'import vig_utils; print(\"OK\")'") From cf92da84743675b5db3cf11297dade2c6eb6558b Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 17:35:04 +0100 Subject: [PATCH 020/215] fix: install bandit system-wide in container image Refs: #186 Co-authored-by: Cursor --- Containerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Containerfile b/Containerfile index fa7aa820..5e591772 100644 --- a/Containerfile +++ b/Containerfile @@ -177,7 +177,8 @@ RUN uv pip install --system \ pre-commit \ rich \ ruff \ - pip-licenses && \ + pip-licenses \ + bandit && \ uv pip install --system --upgrade pip # Copy vig-utils package and install system-wide From 58f8813aec514f1b7d5a24dd80e09a90f9224e5f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 17:35:19 +0100 Subject: [PATCH 021/215] fix: deploy check-skill-names.sh to workspace template Refs: #186 Co-authored-by: Cursor --- assets/workspace/scripts/check-skill-names.sh | 35 +++++++++++++++++++ scripts/manifest.toml | 4 +++ 2 files changed, 39 insertions(+) create mode 100755 assets/workspace/scripts/check-skill-names.sh diff --git a/assets/workspace/scripts/check-skill-names.sh b/assets/workspace/scripts/check-skill-names.sh new file mode 100755 index 00000000..08e94c1a --- /dev/null +++ b/assets/workspace/scripts/check-skill-names.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Check that all skill directory names under a given path use only +# lowercase letters, digits, hyphens, and underscores. +# +# Usage: check-skill-names.sh [skills_dir] +# skills_dir Path to scan (default: .cursor/skills) +# +# Exit 0 if all names are valid, 1 if any are invalid. + +set -euo pipefail + +skills_dir="${1:-.cursor/skills}" + +if [[ ! -d "$skills_dir" ]]; then + echo "Error: directory not found: $skills_dir" >&2 + exit 1 +fi + +invalid=() + +for dir in "$skills_dir"/*/; do + [[ -d "$dir" ]] || continue + name="$(basename "$dir")" + if [[ ! "$name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + invalid+=("$name") + fi +done + +if [[ ${#invalid[@]} -gt 0 ]]; then + echo "Invalid skill directory name(s) — must match [a-z0-9][a-z0-9_-]*:" >&2 + for name in "${invalid[@]}"; do + echo " $name" >&2 + done + exit 1 +fi diff --git a/scripts/manifest.toml b/scripts/manifest.toml index a011dbc9..a3997330 100644 --- a/scripts/manifest.toml +++ b/scripts/manifest.toml @@ -71,6 +71,10 @@ dest = ".devcontainer/justfile.base" src = "justfile.gh" dest = ".devcontainer/justfile.gh" +[[entries]] +src = "scripts/check-skill-names.sh" +dest = "scripts/check-skill-names.sh" + [[entries]] src = "scripts/gh_issues.py" dest = ".devcontainer/scripts/gh_issues.py" From 4afbab95767435681ddd93929382dcd3b768aa3b Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 17:35:28 +0100 Subject: [PATCH 022/215] docs: add CHANGELOG entry for #186 Refs: #186 Co-authored-by: Cursor --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c06ef86a..a69b5313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Container image missing bandit and check-skill-names.sh for workspace pre-commit hooks** ([#186](https://github.com/vig-os/devcontainer/issues/186)) + - Add bandit to system-wide pip install in Containerfile + - Deploy scripts/check-skill-names.sh to workspace template via manifest - **validate-commit-msg rejects AI agent identity fingerprints** ([#163](https://github.com/vig-os/devcontainer/issues/163)) - Commit-msg hook now rejects commits containing Co-authored-by, cursoragent, cursor.com, claude, codex, chatgpt, copilot - Blocks "Made with [Cursor](https://cursor.com)" and similar branding From 7c5c785c4b539596b3585f81c6f407794eaa8301 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 21:28:10 +0100 Subject: [PATCH 023/215] test: add failing test for devc-remote step-level progress logging Refs: #70 --- tests/bats/devc-remote.bats | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 08ec8e77..876126a2 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -249,6 +249,41 @@ SSHEOF rm -rf "$mock_bin" } +# ── main: step-level progress logging ──────────────────────────────────────── + +@test "main logs progress for each pipeline step" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "2" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" + chmod +x "$mock_bin/python3" + printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + assert_success + assert_output --partial "editor CLI" + assert_output --partial "SSH" + assert_output --partial "pre-flight" + rm -rf "$mock_bin" +} + # ── remote_compose_up ──────────────────────────────────────────────────────── @test "remote_compose_up skips when container running and healthy" { From 0a7b6f649b73f89d02290c9fabd8afb6e834304e Mon Sep 17 00:00:00 2001 From: gerchowl Date: Tue, 24 Feb 2026 21:29:33 +0100 Subject: [PATCH 024/215] fix: add step-level progress logging to devc-remote main pipeline Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 271 ++++++++++++++++++++++++ scripts/devc-remote.sh | 12 ++ 2 files changed, 283 insertions(+) create mode 100755 assets/workspace/scripts/devc-remote.sh diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh new file mode 100755 index 00000000..36ac69d6 --- /dev/null +++ b/assets/workspace/scripts/devc-remote.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +############################################################################### +# devc-remote.sh - Remote devcontainer orchestrator +# +# Starts a devcontainer on a remote host via SSH and opens Cursor/VS Code. +# Handles SSH connectivity, pre-flight checks, container state detection, +# and compose lifecycle. URI construction delegated to Python helper. +# +# USAGE: +# ./scripts/devc-remote.sh [:] +# ./scripts/devc-remote.sh --help +# +# Examples: +# ./scripts/devc-remote.sh myserver +# ./scripts/devc-remote.sh user@host:/opt/projects/myrepo +# ./scripts/devc-remote.sh myserver:/home/user/repo +# +# Part of #70. See issue #152 for design. +############################################################################### + +set -euo pipefail + +# ═══════════════════════════════════════════════════════════════════════════════ +# CONFIGURATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# shellcheck disable=SC2034 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ═══════════════════════════════════════════════════════════════════════════════ +# LOGGING (matches init.sh patterns) +# ═══════════════════════════════════════════════════════════════════════════════ + +log_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +log_error() { + echo -e "${RED}✗${NC} $1" +} + +show_help() { + sed -n '/^###############################################################################$/,/^###############################################################################$/p' "$0" | sed '1d;$d' + exit 0 +} + +parse_args() { + SSH_HOST="" + REMOTE_PATH="~" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help + ;; + -*) + log_error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + *) + if [[ -n "$SSH_HOST" ]]; then + log_error "Unexpected argument: $1" + exit 1 + fi + # Parse SSH-style format: user@host:path or host:path + if [[ "$1" =~ ^([^:]+):(.+)$ ]]; then + SSH_HOST="${BASH_REMATCH[1]}" + REMOTE_PATH="${BASH_REMATCH[2]}" + else + SSH_HOST="$1" + # Default to ~ (expanded by remote shell) if no path specified + REMOTE_PATH="~" + fi + shift + ;; + esac + done + + if [[ -z "$SSH_HOST" ]]; then + log_error "Missing required argument: [:]" + echo "Use --help for usage information" + exit 1 + fi +} + +detect_editor_cli() { + if command -v cursor &>/dev/null; then + # shellcheck disable=SC2034 + EDITOR_CLI="cursor" + elif command -v code &>/dev/null; then + # shellcheck disable=SC2034 + EDITOR_CLI="code" + else + log_error "Neither cursor nor code CLI found. Install Cursor or VS Code and enable the shell command." + exit 1 + fi +} + +check_ssh() { + if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" true 2>/dev/null; then + log_error "Cannot connect to $SSH_HOST. Check your SSH config and network." + exit 1 + fi +} + +remote_preflight() { + local preflight_output + # shellcheck disable=SC2029 + preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' +REPO_PATH="${1:-$HOME}" +if command -v podman &>/dev/null; then + echo "RUNTIME=podman" +elif command -v docker &>/dev/null; then + echo "RUNTIME=docker" +else + echo "RUNTIME=" +fi +if (command -v podman &>/dev/null && podman compose version &>/dev/null) || \ + (command -v docker &>/dev/null && docker compose version &>/dev/null); then + echo "COMPOSE_AVAILABLE=1" +else + echo "COMPOSE_AVAILABLE=0" +fi +if [ -d "$REPO_PATH" ]; then + echo "REPO_PATH_EXISTS=1" +else + echo "REPO_PATH_EXISTS=0" +fi +if [ -d "$REPO_PATH/.devcontainer" ]; then + echo "DEVCONTAINER_EXISTS=1" +else + echo "DEVCONTAINER_EXISTS=0" +fi +AVAIL_GB=$(df -BG "$REPO_PATH" 2>/dev/null | awk 'NR==2 {gsub(/G/,""); print $4}') +echo "DISK_AVAILABLE_GB=${AVAIL_GB:-0}" +if [ "$(uname -s)" = "Darwin" ]; then + echo "OS_TYPE=macos" +else + echo "OS_TYPE=linux" +fi +REMOTEEOF + ) + + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + RUNTIME) RUNTIME="${BASH_REMATCH[2]}" ;; + COMPOSE_AVAILABLE) COMPOSE_AVAILABLE="${BASH_REMATCH[2]}" ;; + REPO_PATH_EXISTS) REPO_PATH_EXISTS="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; + DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; + OS_TYPE) OS_TYPE="${BASH_REMATCH[2]}" ;; + esac + done <<< "$preflight_output" + + if [[ -z "${RUNTIME:-}" ]]; then + log_error "No container runtime found on $SSH_HOST. Install podman or docker." + exit 1 + fi + if [[ "$RUNTIME" == "podman" ]]; then + COMPOSE_CMD="podman compose" + else + COMPOSE_CMD="docker compose" + fi + if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then + log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." + exit 1 + fi + if [[ "${REPO_PATH_EXISTS:-0}" != "1" ]]; then + log_error "Repository not found at $REMOTE_PATH on $SSH_HOST." + exit 1 + fi + if [[ "${DEVCONTAINER_EXISTS:-0}" != "1" ]]; then + log_error "No .devcontainer/ found in $REMOTE_PATH. Is this a devcontainer-enabled project?" + exit 1 + fi + if [[ "${DISK_AVAILABLE_GB:-0}" -lt 2 ]] 2>/dev/null; then + log_warning "Low disk space on $SSH_HOST (${DISK_AVAILABLE_GB:-0}GB). At least 2GB recommended." + fi + if [[ "${OS_TYPE:-}" == "macos" ]]; then + log_warning "Remote host is macOS. Devcontainer support may be limited." + fi +} + +remote_compose_up() { + local ps_output state health + # shellcheck disable=SC2029 + ps_output=$(ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD ps --format json 2>/dev/null" || true) + state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4) + health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4) + + if [[ "$state" == "running" && "${health:-}" == "healthy" ]]; then + log_success "Devcontainer already running on $SSH_HOST. Opening..." + else + log_info "Starting devcontainer on $SSH_HOST..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d" >/dev/null 2>&1 + # Health poll (simplified: assume success for now) + sleep 2 + fi +} + +open_editor() { + local container_workspace uri + # Read workspaceFolder from devcontainer.json on remote host + # shellcheck disable=SC2029 + container_workspace=$(ssh "$SSH_HOST" \ + "grep -o '\"workspaceFolder\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \ + ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" \ + | sed 's/.*: *"//;s/"//' || echo "/workspace") + + # Default to /workspace if workspaceFolder not found + container_workspace="${container_workspace:-/workspace}" + + # Build URI using Python helper + if ! uri=$(python3 "$SCRIPT_DIR/devc_remote_uri.py" \ + "$REMOTE_PATH" \ + "$SSH_HOST" \ + "$container_workspace"); then + log_error "Failed to build editor URI. Is devc_remote_uri.py present in $SCRIPT_DIR?" + exit 1 + fi + + if ! "$EDITOR_CLI" --folder-uri "$uri"; then + log_error "Failed to open $EDITOR_CLI. URI: $uri" + exit 1 + fi +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════════════════════ + +main() { + parse_args "$@" + + log_info "Detecting local editor CLI..." + detect_editor_cli + log_success "Using $EDITOR_CLI" + + log_info "Checking SSH connectivity to $SSH_HOST..." + check_ssh + log_success "SSH connection OK" + + log_info "Running pre-flight checks on $SSH_HOST..." + remote_preflight + log_success "Pre-flight OK (runtime: $RUNTIME)" + + remote_compose_up + open_editor + + log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" +} + +main "$@" diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index e6d69cd8..7eaf9d42 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -243,11 +243,23 @@ open_editor() { main() { parse_args "$@" + + log_info "Detecting local editor CLI..." detect_editor_cli + log_success "Using $EDITOR_CLI" + + log_info "Checking SSH connectivity to $SSH_HOST..." check_ssh + log_success "SSH connection OK" + + log_info "Running pre-flight checks on $SSH_HOST..." remote_preflight + log_success "Pre-flight OK (runtime: $RUNTIME)" + remote_compose_up open_editor + + log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" } main "$@" From df5026c00b622ffdd823e14ff8e7a6f3bd3292f1 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Wed, 25 Feb 2026 00:15:32 +0100 Subject: [PATCH 025/215] feat(remote): devcontainer orchestration, skills cleanup, and tooling improvements - Remote devcontainer orchestration: devc-remote.sh error handling, devc_remote_uri.py, justfile recipes - Skills: deduplicate Delegation sections, add missing ones for ci_check, ci-fix, verify - gh_issues.py: CI status column, hyperlinks, Refs: parsing, reviewer display - Worktree: configurable agent model via _read_model helper - Containerfile: fix vig-utils COPY order for uv workspace resolution - Dependencies: ruff 0.15.0, bandit added to devcontainer group - init-workspace.sh: exclude .venv from template sync, safe rename - New setup-labels.sh for GitHub label provisioning - Docs: updated README.md and CONTRIBUTE.md with new recipes Refs: #70 --- CONTRIBUTE.md | 22 ++- Containerfile | 10 +- README.md | 22 ++- assets/init-workspace.sh | 5 +- .../.cursor/skills/ci_check/SKILL.md | 16 ++ .../.cursor/skills/code_review/SKILL.md | 17 -- .../skills/worktree_brainstorm/SKILL.md | 12 -- .../.cursor/skills/worktree_ci-fix/SKILL.md | 14 ++ .../.cursor/skills/worktree_execute/SKILL.md | 12 -- .../.cursor/skills/worktree_plan/SKILL.md | 12 -- .../.cursor/skills/worktree_verify/SKILL.md | 12 ++ assets/workspace/.devcontainer/justfile.base | 15 +- .../workspace/.devcontainer/justfile.worktree | 23 ++- .../.devcontainer/scripts/gh_issues.py | 86 ++++++++- assets/workspace/scripts/devc-remote.sh | 7 +- assets/workspace/scripts/devc_remote_uri.py | 70 ++++++++ assets/workspace/scripts/setup-labels.sh | 165 ++++++++++++++++++ justfile.base | 12 +- justfile.worktree | 23 ++- pyproject.toml | 5 +- scripts/devc-remote.sh | 7 +- scripts/manifest.toml | 12 ++ uv.lock | 51 +++--- 23 files changed, 510 insertions(+), 120 deletions(-) create mode 100644 assets/workspace/scripts/devc_remote_uri.py create mode 100755 assets/workspace/scripts/setup-labels.sh diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index dc726be7..4b20e685 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -125,14 +125,30 @@ Available recipes: clean-test-containers # Clean up lingering test containers [deps] - sync # Sync dependencies from pyproject.toml + sync # Sync all dependencies (idempotent, fast if nothing changed) update # Update all dependencies + [devcontainer] + devc-remote *args # just devc-remote --repo git@github.com:org/repo.git myserver + down # Stop and remove containers + logs *args # Tail container logs + open # Open Cursor/VS Code attached to the running container + restart *args # Restart service(s) + shell # Open bash in running devcontainer + status # Show container status + up # Start devcontainer + sidecars via compose + + [git] + branch # Show current branch + list recent branches + log # Pretty one-line git log (last 20 commits) + [github] gh-issues # List open issues and PRs grouped by milestone [alias: gh-i] [info] + check *args # Examples: just check, just check config, just check off, just check 7d default # Show available commands (default) + devcontainer-upgrade # This recipe MUST be run from a host terminal, not inside the container docs # Generate documentation from templates help # Show available commands info # Show image information @@ -164,7 +180,7 @@ Available recipes: reset-changelog # Reset CHANGELOG Unreleased section (after merging release to dev) [sidecar] - sidecar name *args # just sidecar redis flush + sidecar name *args # Example: just sidecar postgres migrate / just sidecar redis flush sidecars # List available sidecar containers [test] @@ -181,7 +197,7 @@ Available recipes: [worktree] worktree-attach issue # before attaching. See tests/bats/worktree.bats for integration tests. [alias: wt-attach] - worktree-clean # Remove all cursor-managed worktrees and tmux sessions [alias: wt-clean] + worktree-clean mode="" # Default (no args): clean only stopped worktrees. Use 'all' to clean everything. [alias: wt-clean] worktree-list # List active worktrees and their tmux sessions [alias: wt-list] worktree-start issue prompt="" reviewer="" # Create a worktree for an issue, open tmux session, launch cursor-agent [alias: wt-start] worktree-stop issue # Stop a worktree's tmux session and remove the worktree [alias: wt-stop] diff --git a/Containerfile b/Containerfile index 291830fe..0bef496e 100644 --- a/Containerfile +++ b/Containerfile @@ -171,16 +171,18 @@ RUN set -eux; \ tar -xzf "$FILE" -C /usr/local/bin --strip-components=1; \ rm "$FILE"; +# Copy vig-utils package early so uv can resolve the workspace member +COPY packages/vig-utils /root/packages/vig-utils + # Install Python development tools from root pyproject.toml (SSoT) # and upgrade pip to fix CVE-2025-8869 (symbolic link extraction vulnerability) -COPY pyproject.toml uv.lock ./ -RUN uv export --only-group devcontainer --no-emit-project -o /tmp/devcontainer-reqs.txt && \ +COPY pyproject.toml uv.lock /root/ +RUN uv export --project /root --only-group devcontainer --no-emit-project -o /tmp/devcontainer-reqs.txt && \ uv pip install --system -r /tmp/devcontainer-reqs.txt && \ uv pip install --system --upgrade pip && \ rm /tmp/devcontainer-reqs.txt -# Copy vig-utils package and install system-wide -COPY packages/vig-utils /root/packages/vig-utils +# Install vig-utils system-wide RUN uv pip install --system /root/packages/vig-utils # Copy assets into container image diff --git a/README.md b/README.md index fe243069..e0583a1d 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,30 @@ Available recipes: clean-test-containers # Clean up lingering test containers [deps] - sync # Sync dependencies from pyproject.toml + sync # Sync all dependencies (idempotent, fast if nothing changed) update # Update all dependencies + [devcontainer] + devc-remote *args # just devc-remote --repo git@github.com:org/repo.git myserver + down # Stop and remove containers + logs *args # Tail container logs + open # Open Cursor/VS Code attached to the running container + restart *args # Restart service(s) + shell # Open bash in running devcontainer + status # Show container status + up # Start devcontainer + sidecars via compose + + [git] + branch # Show current branch + list recent branches + log # Pretty one-line git log (last 20 commits) + [github] gh-issues # List open issues and PRs grouped by milestone [alias: gh-i] [info] + check *args # Examples: just check, just check config, just check off, just check 7d default # Show available commands (default) + devcontainer-upgrade # This recipe MUST be run from a host terminal, not inside the container docs # Generate documentation from templates help # Show available commands info # Show image information @@ -155,7 +171,7 @@ Available recipes: reset-changelog # Reset CHANGELOG Unreleased section (after merging release to dev) [sidecar] - sidecar name *args # just sidecar redis flush + sidecar name *args # Example: just sidecar postgres migrate / just sidecar redis flush sidecars # List available sidecar containers [test] @@ -172,7 +188,7 @@ Available recipes: [worktree] worktree-attach issue # before attaching. See tests/bats/worktree.bats for integration tests. [alias: wt-attach] - worktree-clean # Remove all cursor-managed worktrees and tmux sessions [alias: wt-clean] + worktree-clean mode="" # Default (no args): clean only stopped worktrees. Use 'all' to clean everything. [alias: wt-clean] worktree-list # List active worktrees and their tmux sessions [alias: wt-list] worktree-start issue prompt="" reviewer="" # Create a worktree for an issue, open tmux session, launch cursor-agent [alias: wt-start] worktree-stop issue # Stop a worktree's tmux session and remove the worktree [alias: wt-stop] diff --git a/assets/init-workspace.sh b/assets/init-workspace.sh index 77570522..af5729e2 100755 --- a/assets/init-workspace.sh +++ b/assets/init-workspace.sh @@ -150,7 +150,7 @@ if [[ "$FORCE" == "true" ]]; then CONFLICTS+=("$rel_path") fi fi - done < <(find "$TEMPLATE_DIR" -type f ! -path "*/.git/*" -print0) + done < <(find "$TEMPLATE_DIR" -type f ! -path "*/.git/*" ! -path "*/.venv/*" -print0) # Show preserved files if [[ ${#PRESERVED[@]} -gt 0 ]]; then @@ -255,6 +255,9 @@ fi # Rename template_project directory to match project short name if [[ -d "$WORKSPACE_DIR/src/template_project" ]]; then echo "Renaming src/template_project to src/${SHORT_NAME}..." + if [[ -d "$WORKSPACE_DIR/src/${SHORT_NAME}" ]]; then + rm -rf "$WORKSPACE_DIR/src/${SHORT_NAME}" + fi mv "$WORKSPACE_DIR/src/template_project" "$WORKSPACE_DIR/src/${SHORT_NAME}" fi diff --git a/assets/workspace/.cursor/skills/ci_check/SKILL.md b/assets/workspace/.cursor/skills/ci_check/SKILL.md index 09fe5c04..eb6a7eb6 100644 --- a/assets/workspace/.cursor/skills/ci_check/SKILL.md +++ b/assets/workspace/.cursor/skills/ci_check/SKILL.md @@ -50,6 +50,22 @@ This skill is entirely data-gathering and formatting, making it ideal for lightw Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) +## Delegation + +All steps in this skill are CLI commands and output formatting, making them ideal for lightweight delegation: + +Spawn a Task subagent with `model: "fast"` that: +1. Identifies the context (PR or branch) via `gh pr checks` or `gh run list` +2. Fetches the status of all workflows +3. Formats the status report with ✓/✗/○ indicators +4. For any failures, fetches the failure log via `gh run view --log-failed` and extracts the key error lines + +Returns: formatted CI status report, failure logs (if any), suggested next steps. + +This skill is entirely data-gathering and formatting, making it ideal for lightweight delegation. + +Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) + ## Important Notes - If CI is still running, report "pending" and suggest waiting or checking back. diff --git a/assets/workspace/.cursor/skills/code_review/SKILL.md b/assets/workspace/.cursor/skills/code_review/SKILL.md index fea59358..09b0b620 100644 --- a/assets/workspace/.cursor/skills/code_review/SKILL.md +++ b/assets/workspace/.cursor/skills/code_review/SKILL.md @@ -111,23 +111,6 @@ This reduces token consumption on the primary model while maintaining review qua Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) -## Delegation - -The subagent spawned in step 2 SHOULD use `model: "fast"` since code review is a structured analysis task with clear inputs (diff, issue, standards) and a fixed output format. - -Update step 2's Task tool invocation to include: - -```markdown -Task tool parameters: -- `readonly: true` (already specified) -- `model: "fast"` (add this — review fits the "standard" tier pattern) -- `description: "Code review: branch vs base"` -``` - -This reduces token consumption on the primary model while maintaining review quality, as the review checklist is well-defined and the subagent has all necessary context. - -Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) - ## Important Notes - Run this before every PR submission. The [pr_create](../pr_create/SKILL.md) workflow should reference this as a prerequisite. diff --git a/assets/workspace/.cursor/skills/worktree_brainstorm/SKILL.md b/assets/workspace/.cursor/skills/worktree_brainstorm/SKILL.md index 4c614b29..23ad0b2c 100644 --- a/assets/workspace/.cursor/skills/worktree_brainstorm/SKILL.md +++ b/assets/workspace/.cursor/skills/worktree_brainstorm/SKILL.md @@ -75,18 +75,6 @@ Steps 2-3 (explore context, make design decisions) should remain in the main age Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) -## Delegation - -The following steps SHOULD be delegated to reduce token consumption: - -- **Steps 1, 4** (precondition check, read issue): Spawn a Task subagent with `model: "fast"` that validates the branch name, executes `gh issue view`, and checks for an existing `## Design` comment. Returns: issue number, parsed body/comments, design-exists flag. -- **Step 5** (publish design, trigger workflow): Spawn a Task subagent with `model: "fast"` that takes the formatted design content, posts it via `gh api`, and triggers the sync-issues workflow. Returns: comment URL. -- **Step 6** (invoke next skill): Can remain in main agent (simple skill invocation). - -Steps 2-3 (explore context, make design decisions) should remain in the main agent as they require architectural reasoning and decision-making. - -Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) - ## When stuck If you cannot make a reasonable design decision (genuinely ambiguous, high-risk, or contradictory requirements), use [worktree_ask](../worktree_ask/SKILL.md) to post a question on the issue. Do not guess on critical decisions. diff --git a/assets/workspace/.cursor/skills/worktree_ci-fix/SKILL.md b/assets/workspace/.cursor/skills/worktree_ci-fix/SKILL.md index 0688adac..96b1e53a 100644 --- a/assets/workspace/.cursor/skills/worktree_ci-fix/SKILL.md +++ b/assets/workspace/.cursor/skills/worktree_ci-fix/SKILL.md @@ -97,6 +97,20 @@ Step 6 (handle repeated failures) should remain in the main agent as it requires Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) +## Delegation + +The following steps SHOULD be delegated to reduce token consumption: + +- **Steps 1, 4** (precondition check, investigate): Spawn a Task subagent with `model: "fast"` that validates the branch name, executes `gh run list` and `gh run view --log-failed`, and returns: issue number, failing workflow/job/step, full error log. +- **Step 3** (post diagnosis comment): Spawn a Task subagent with `model: "fast"` that takes the formatted diagnosis content and posts it via `gh api`. Returns: comment URL. +- **Step 5** (push and re-check): Spawn a Task subagent with `model: "fast"` that executes `git push` and then invokes `worktree:ci-check`. Returns: push confirmation, CI check status. + +Steps 2 and 4 (analyze root cause, fix) should remain in the main agent as they require debugging and code changes. + +Step 6 (handle repeated failures) should remain in the main agent as it requires state tracking and escalation logic. + +Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) + ## Important Notes - Never guess the cause. Always fetch the actual error log first. diff --git a/assets/workspace/.cursor/skills/worktree_execute/SKILL.md b/assets/workspace/.cursor/skills/worktree_execute/SKILL.md index 66d935af..7585e19f 100644 --- a/assets/workspace/.cursor/skills/worktree_execute/SKILL.md +++ b/assets/workspace/.cursor/skills/worktree_execute/SKILL.md @@ -85,18 +85,6 @@ Steps 2 and 4 (execute tasks, handle failures) should remain in the main agent a Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) -## Delegation - -The following steps SHOULD be delegated to reduce token consumption: - -- **Step 1** (precondition check, load plan): Spawn a Task subagent with `model: "fast"` that validates the branch name, fetches the `## Implementation Plan` comment via `gh api`, parses the task list, and returns: issue number, comment ID, list of pending/completed tasks. -- **Step 3** (update progress): Spawn a Task subagent with `model: "fast"` that re-fetches the comment, performs the checkbox replacement, and updates the comment via `gh api`. Returns: success confirmation. -- **Step 5** (invoke next skill): Can remain in main agent (simple skill invocation). - -Steps 2 and 4 (execute tasks, handle failures) should remain in the main agent as they require code generation, TDD discipline, and debugging. - -Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) - ## Important Notes - Never block waiting for user input. Execute tasks continuously. diff --git a/assets/workspace/.cursor/skills/worktree_plan/SKILL.md b/assets/workspace/.cursor/skills/worktree_plan/SKILL.md index cb014eb7..0fb05557 100644 --- a/assets/workspace/.cursor/skills/worktree_plan/SKILL.md +++ b/assets/workspace/.cursor/skills/worktree_plan/SKILL.md @@ -82,18 +82,6 @@ Step 2 (break into tasks) should remain in the main agent as it requires task de Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) -## Delegation - -The following steps SHOULD be delegated to reduce token consumption: - -- **Steps 1, 4** (precondition check, read issue/design): Spawn a Task subagent with `model: "fast"` that validates the branch name, executes `gh issue view`, checks for existing `## Design` and `## Implementation Plan` comments. Returns: issue number, parsed body/design, plan-exists flag. -- **Step 3** (publish plan, trigger workflow): Spawn a Task subagent with `model: "fast"` that takes the formatted plan content, posts it via `gh api`, and triggers the sync-issues workflow. Returns: comment URL. -- **Step 4** (invoke next skill): Can remain in main agent (simple skill invocation). - -Step 2 (break into tasks) should remain in the main agent as it requires task decomposition and dependency analysis. - -Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) - ## Important Notes - Never block waiting for user input. Make reasonable task breakdowns and move on. diff --git a/assets/workspace/.cursor/skills/worktree_verify/SKILL.md b/assets/workspace/.cursor/skills/worktree_verify/SKILL.md index 6607bdee..b880c6eb 100644 --- a/assets/workspace/.cursor/skills/worktree_verify/SKILL.md +++ b/assets/workspace/.cursor/skills/worktree_verify/SKILL.md @@ -70,6 +70,18 @@ Step 3 (handle failures) should remain in the main agent as it requires debuggin Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) +## Delegation + +The following steps SHOULD be delegated to reduce token consumption: + +- **Step 1** (precondition check, run verification): Spawn a Task subagent with `model: "fast"` that validates the branch name and executes `just test`, `just lint`, `just precommit`. Returns: exit codes, stdout/stderr for each command. +- **Step 2** (analyze results): Spawn a Task subagent with `model: "fast"` that parses the command outputs, counts failures/warnings, and formats the structured verification report. Returns: pass/fail status per check, formatted report. +- **Step 4** (invoke next skill): Can remain in main agent (simple skill invocation). + +Step 3 (handle failures) should remain in the main agent as it requires debugging and code fixes. + +Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) + ## Important Notes - Never claim "done" without running the commands in this session. diff --git a/assets/workspace/.devcontainer/justfile.base b/assets/workspace/.devcontainer/justfile.base index 6de6abe9..0543a7a2 100644 --- a/assets/workspace/.devcontainer/justfile.base +++ b/assets/workspace/.devcontainer/justfile.base @@ -366,4 +366,17 @@ sidecar name *args: fi # Execute the recipe in the sidecar - podman exec {{ name }} just {{ args }} + podman exec {{name}} just {{args}} + +# ------------------------------------------------------------------------------- +# REMOTE DEVCONTAINER +# ------------------------------------------------------------------------------- + +# Start a devcontainer on a remote host and open Cursor/VS Code +# Auto-clones the repo and runs init-workspace if needed +# Usage: just devc-remote myserver +# just devc-remote myserver:/home/user/repo +# just devc-remote --repo git@github.com:org/repo.git myserver +[group('devcontainer')] +devc-remote *args: + bash scripts/devc-remote.sh {{args}} diff --git a/assets/workspace/.devcontainer/justfile.worktree b/assets/workspace/.devcontainer/justfile.worktree index a95c1417..fbe18016 100644 --- a/assets/workspace/.devcontainer/justfile.worktree +++ b/assets/workspace/.devcontainer/justfile.worktree @@ -117,10 +117,11 @@ worktree-start issue prompt="" reviewer="": echo " tmux session '$SESSION' is running. Use: just worktree-attach $ISSUE" else echo " No tmux session found. Starting one..." + AGENT_MODEL=$(_read_model "autonomous") if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --yolo --approve-mcps \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --approve-mcps" fi sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' started. Use: just worktree-attach $ISSUE" @@ -221,10 +222,11 @@ worktree-start issue prompt="" reviewer="": # Start tmux session # --yolo: auto-approve all shell commands (autonomous agent, no human at the terminal) + AGENT_MODEL=$(_read_model "autonomous") if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --yolo --approve-mcps \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --approve-mcps" fi sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true @@ -307,6 +309,12 @@ worktree-attach issue: fi } + _read_model() { + local tier="$1" + local cfg="$(git rev-parse --show-toplevel)/.cursor/agent-models.toml" + grep "^${tier}" "$cfg" | sed 's/.*= *"//' | sed 's/".*//' + } + ISSUE="{{ issue }}" SESSION="wt-${ISSUE}" WT_DIR="{{ _wt_base }}/${ISSUE}" @@ -315,11 +323,12 @@ worktree-attach issue: if [ -d "$WT_DIR" ]; then echo "[!] tmux session '$SESSION' stopped. Restarting..." _wt_ensure_trust "$WT_DIR" - REVIEWER=$(gh api user --jq '.login' 2>/dev/null || echo "") if [ -n "${WORKTREE_ATTACH_RESTART_CMD:-}" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "$WORKTREE_ATTACH_RESTART_CMD" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" "$WORKTREE_ATTACH_RESTART_CMD" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + REVIEWER=$(gh api user --jq '.login' 2>/dev/null || echo "") + AGENT_MODEL=$(_read_model "autonomous") + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --approve-mcps" fi sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' restarted" diff --git a/assets/workspace/.devcontainer/scripts/gh_issues.py b/assets/workspace/.devcontainer/scripts/gh_issues.py index 4ce01605..0708ed50 100644 --- a/assets/workspace/.devcontainer/scripts/gh_issues.py +++ b/assets/workspace/.devcontainer/scripts/gh_issues.py @@ -149,6 +149,11 @@ def _styled(value: str, style: str) -> str: return f"[{style}]{value}[/]" +def _gh_link(owner_repo: str, num: int, kind: str) -> str: + """Return Rich hyperlink markup for issue or PR number.""" + return f"[link=https://github.com/{owner_repo}/{kind}/{num}]{num}[/link]" + + def _extract_label(labels: list[dict], prefix: str) -> str: for lbl in labels: name = lbl["name"] @@ -190,6 +195,7 @@ def _format_assignees(assignees: list[dict]) -> str: _CLOSING_RE = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.IGNORECASE) +_REFS_RE = re.compile(r"Refs:\s*((?:#\d+(?:\s*,\s*)?)+)", re.IGNORECASE) def _build_cross_refs( @@ -215,6 +221,10 @@ def _build_cross_refs( body = pr.get("body") or "" for match in _CLOSING_RE.finditer(body): linked.add(int(match.group(1))) + refs_match = _REFS_RE.search(body) + if refs_match: + for m in re.finditer(r"#(\d+)", refs_match.group(1)): + linked.add(int(m.group(1))) for inum in linked: issue_to_pr[inum] = pr_num @@ -230,6 +240,7 @@ def _build_table( issue_to_pr: dict[int, int], child_to_parent: dict[int, int], parent_to_children: dict[int, list[int]], + owner_repo: str, ) -> Table: from rich.table import Table @@ -287,7 +298,7 @@ def _add_row(issue: dict, *, indent: int = 0) -> None: title_text = _styled(f"▸ {title_text}", "bright_cyan") table.add_row( - str(num), + _gh_link(owner_repo, num, "issues"), _extract_type(labels), title_text, _format_assignees(issue["assignees"]), @@ -331,7 +342,7 @@ def _fetch_prs() -> list[dict]: "number,title,author,assignees,isDraft,reviewDecision," "baseRefName,headRefName,additions,deletions,changedFiles," "labels,milestone,createdAt,body," - "reviewRequests,latestReviews", + "reviewRequests,latestReviews,statusCheckRollup", ], capture_output=True, text=True, @@ -363,6 +374,61 @@ def _infer_review(pr: dict) -> tuple[str, str]: return ("", "—") +def _dedupe_status_checks(rollup: list[dict]) -> list[dict]: + """Deduplicate statusCheckRollup by check name, keeping latest by completedAt. + + GitHub includes re-runs of the same check; we keep only the latest result + per check name so the CI column matches what GitHub shows on the PR page. + Ref: #176 + """ + by_name: dict[str, dict] = {} + for check in rollup: + name = check.get("name") or "?" + completed = check.get("completedAt") or "" + existing = by_name.get(name) + if existing is None: + by_name[name] = check + else: + existing_completed = existing.get("completedAt") or "" + if completed >= existing_completed: + by_name[name] = check + return list(by_name.values()) + + +def _format_ci_status(pr: dict, owner_repo: str) -> str: + """Return Rich markup for CI status cell: pass/fail/pending summary with link. + + Uses statusCheckRollup from gh pr list. Links to PR checks tab. + Ref: #143 + """ + rollup = _dedupe_status_checks(pr.get("statusCheckRollup") or []) + if not rollup: + return _styled("—", "dim") + + total = len(rollup) + passed = sum(1 for c in rollup if c.get("conclusion") == "SUCCESS") + failed = sum(1 for c in rollup if c.get("conclusion") in ("FAILURE", "ERROR")) + pending = total - passed - failed + + url = f"https://github.com/{owner_repo}/pull/{pr['number']}/checks" + link_prefix = f"[link={url}]" + link_suffix = "[/link]" + + if failed > 0: + failed_names = [ + c.get("name", "?") + for c in rollup + if c.get("conclusion") in ("FAILURE", "ERROR") + ] + text = f"✗ {passed}/{total} {', '.join(failed_names)}" + return link_prefix + _styled(text, "red") + link_suffix + if pending > 0: + text = f"⏳ {passed}/{total}" + return link_prefix + _styled(text, "yellow") + link_suffix + text = f"✓ {passed}/{total}" + return link_prefix + _styled(text, "green") + link_suffix + + def _extract_reviewers(pr: dict) -> str: """Build a compact reviewer string from latestReviews and reviewRequests.""" seen: dict[str, str] = {} @@ -385,6 +451,8 @@ def _extract_reviewers(pr: dict) -> str: parts.append(_styled(login, "green")) elif state == "CHANGES_REQUESTED": parts.append(_styled(login, "red")) + elif state == "REQUESTED": + parts.append(_styled(f"?{login}", "dim italic")) else: parts.append(_styled(login, "yellow")) return " ".join(parts) @@ -394,6 +462,7 @@ def _build_pr_table( title: str, prs: list[dict], pr_to_issues: dict[int, list[int]], + owner_repo: str, ) -> Table: from rich.table import Table @@ -412,6 +481,7 @@ def _build_pr_table( table.add_column("Assignee", no_wrap=True, width=12) table.add_column("Issues", no_wrap=True, width=10) table.add_column("Branch", no_wrap=True, overflow="ellipsis", max_width=30) + table.add_column("CI", no_wrap=True, justify="center", width=14) table.add_column("Review", no_wrap=True, justify="center", width=8) table.add_column("Reviewer", no_wrap=True, width=12) table.add_column("Delta", no_wrap=True, justify="right", width=14) @@ -436,16 +506,21 @@ def _build_pr_table( linked = pr_to_issues.get(pr["number"], []) issues_cell = ( - " ".join(_styled(f"#{n}", "cyan") for n in sorted(linked)) if linked else "" + " ".join(_gh_link(owner_repo, n, "issues") for n in sorted(linked)) + if linked + else "" ) + ci_cell = _format_ci_status(pr, owner_repo) + table.add_row( - str(pr["number"]), + _gh_link(owner_repo, pr["number"], "pull"), _clean_title(pr["title"]) + draft_marker, f"[bright_white]{pr['author']['login']}[/]", _format_assignees(pr.get("assignees", [])), issues_cell, branch, + ci_cell, review, reviewer, delta, @@ -498,6 +573,7 @@ def main() -> int: issue_to_pr, child_to_parent, parent_to_children, + owner_repo, ) console.print() console.print(table) @@ -510,6 +586,7 @@ def main() -> int: issue_to_pr, child_to_parent, parent_to_children, + owner_repo, ) console.print() console.print(table) @@ -525,6 +602,7 @@ def main() -> int: f"[green]▸ Pull Requests[/] [dim]({len(prs)} open)[/]", prs, pr_to_issues, + owner_repo, ) console.print() console.print(table) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 36ac69d6..b4103477 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -210,8 +210,11 @@ remote_compose_up() { else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d" >/dev/null 2>&1 - # Health poll (simplified: assume success for now) + if ! ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d"; then + log_error "Failed to start devcontainer on $SSH_HOST." + log_error "Debug with: ssh $SSH_HOST 'cd $REMOTE_PATH && $COMPOSE_CMD logs'" + exit 1 + fi sleep 2 fi } diff --git a/assets/workspace/scripts/devc_remote_uri.py b/assets/workspace/scripts/devc_remote_uri.py new file mode 100644 index 00000000..e32a5362 --- /dev/null +++ b/assets/workspace/scripts/devc_remote_uri.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Build Cursor/VS Code nested authority URI for remote devcontainers.""" + +from __future__ import annotations + +import argparse +import json + + +def hex_encode(s: str) -> str: + """Hex-encode a string (UTF-8).""" + return s.encode().hex() + + +def build_uri( + workspace_path: str, + devcontainer_path: str, + ssh_host: str, + container_workspace: str, +) -> str: + """Build vscode-remote URI for dev-container over SSH. + + Format: vscode-remote://dev-container+{DC_HEX}@ssh-remote+{SSH_SPEC}/{container_workspace} + """ + if not workspace_path: + raise ValueError("workspace_path cannot be empty") + if not devcontainer_path: + raise ValueError("devcontainer_path cannot be empty") + if not ssh_host: + raise ValueError("ssh_host cannot be empty") + if not container_workspace: + raise ValueError("container_workspace cannot be empty") + spec = { + "settingType": "config", + "workspacePath": workspace_path, + "devcontainerPath": devcontainer_path, + } + dc_hex = hex_encode(json.dumps(spec, separators=(",", ":"))) + path = "/" + container_workspace.lstrip("/") + return f"vscode-remote://dev-container+{dc_hex}@ssh-remote+{ssh_host}{path}" + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Build Cursor/VS Code URI for remote devcontainers" + ) + parser.add_argument("workspace_path", help="Workspace path on the remote host") + parser.add_argument("ssh_host", help="SSH host from ~/.ssh/config") + parser.add_argument("container_workspace", help="Container workspace path") + parser.add_argument( + "--devcontainer-path", + help="Path to devcontainer.json (default: {workspace_path}/.devcontainer/devcontainer.json)", + ) + args = parser.parse_args() + + devcontainer_path = args.devcontainer_path or ( + f"{args.workspace_path.rstrip('/')}/.devcontainer/devcontainer.json" + ) + uri = build_uri( + workspace_path=args.workspace_path, + devcontainer_path=devcontainer_path, + ssh_host=args.ssh_host, + container_workspace=args.container_workspace, + ) + print(uri) + + +if __name__ == "__main__": + main() diff --git a/assets/workspace/scripts/setup-labels.sh b/assets/workspace/scripts/setup-labels.sh new file mode 100755 index 00000000..166c11be --- /dev/null +++ b/assets/workspace/scripts/setup-labels.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +############################################################################### +# setup-labels.sh — Provision GitHub labels from label-taxonomy.toml +# +# Reads the canonical label definitions from .github/label-taxonomy.toml and +# creates or updates them on the target repository. Idempotent: safe to run +# repeatedly. +# +# USAGE: +# ./scripts/setup-labels.sh # current repo +# ./scripts/setup-labels.sh --repo owner/repo +# ./scripts/setup-labels.sh --prune # also delete unlisted labels +# ./scripts/setup-labels.sh --dry-run # show what would happen +# +# REQUIRES: gh (GitHub CLI), authenticated +############################################################################### + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TAXONOMY_FILE="${SCRIPT_DIR}/../.github/label-taxonomy.toml" + +REPO_ARGS=() +PRUNE=false +DRY_RUN=false + +# ── Argument parsing ───────────────────────────────────────────────────────── + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + REPO_ARGS=(--repo "$2") + shift 2 + ;; + --prune) + PRUNE=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --help|-h) + sed -n '/^###############################################################################$/,/^###############################################################################$/p' "$0" | sed '1d;$d' + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "$TAXONOMY_FILE" ]]; then + echo "Error: taxonomy file not found: $TAXONOMY_FILE" >&2 + exit 1 +fi + +# ── Parse TOML ─────────────────────────────────────────────────────────────── +# Minimal parser: extracts name/description/color from [[labels]] blocks. + +NAMES=() +DESCRIPTIONS=() +COLORS=() + +current_name="" +current_desc="" +current_color="" + +flush_label() { + if [[ -n "$current_name" ]]; then + NAMES+=("$current_name") + DESCRIPTIONS+=("$current_desc") + COLORS+=("$current_color") + fi + current_name="" + current_desc="" + current_color="" +} + +while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// /}" ]] && continue + + if [[ "$line" =~ ^\[\[labels\]\] ]]; then + flush_label + continue + fi + + if [[ "$line" =~ ^name[[:space:]]*=[[:space:]]*\"(.+)\" ]]; then + current_name="${BASH_REMATCH[1]}" + elif [[ "$line" =~ ^description[[:space:]]*=[[:space:]]*\"(.+)\" ]]; then + current_desc="${BASH_REMATCH[1]}" + elif [[ "$line" =~ ^color[[:space:]]*=[[:space:]]*\"(.+)\" ]]; then + current_color="${BASH_REMATCH[1]}" + fi +done < "$TAXONOMY_FILE" +flush_label + +echo "Taxonomy: ${#NAMES[@]} labels defined in $(basename "$TAXONOMY_FILE")" + +# ── Fetch existing labels ──────────────────────────────────────────────────── + +mapfile -t EXISTING < <(gh label list "${REPO_ARGS[@]}" --limit 100 --json name --jq '.[].name') + +echo "Remote: ${#EXISTING[@]} labels on repo" +echo "" + +# ── Create / update ────────────────────────────────────────────────────────── + +for i in "${!NAMES[@]}"; do + name="${NAMES[$i]}" + desc="${DESCRIPTIONS[$i]}" + color="${COLORS[$i]}" + + found=false + for existing in "${EXISTING[@]}"; do + if [[ "$existing" == "$name" ]]; then + found=true + break + fi + done + + if $found; then + if $DRY_RUN; then + echo "[DRY-RUN] update $name" + else + gh label edit "$name" --description "$desc" --color "$color" "${REPO_ARGS[@]}" + echo "[UPDATED] $name" + fi + else + if $DRY_RUN; then + echo "[DRY-RUN] create $name" + else + gh label create "$name" --description "$desc" --color "$color" "${REPO_ARGS[@]}" + echo "[CREATED] $name" + fi + fi +done + +# ── Prune ──────────────────────────────────────────────────────────────────── + +if $PRUNE; then + for existing in "${EXISTING[@]}"; do + is_canonical=false + for name in "${NAMES[@]}"; do + if [[ "$existing" == "$name" ]]; then + is_canonical=true + break + fi + done + + if ! $is_canonical; then + if $DRY_RUN; then + echo "[DRY-RUN] delete $existing" + else + gh label delete "$existing" --yes "${REPO_ARGS[@]}" + echo "[DELETED] $existing" + fi + fi + done +fi + +echo "" +echo "Done." diff --git a/justfile.base b/justfile.base index 6e5dcc75..0543a7a2 100644 --- a/justfile.base +++ b/justfile.base @@ -373,10 +373,10 @@ sidecar name *args: # ------------------------------------------------------------------------------- # Start a devcontainer on a remote host and open Cursor/VS Code -# Usage: just devc-remote [:] -# Example: just devc-remote myserver -# just devc-remote user@host:/opt/projects/myrepo -# just devc-remote myserver:/home/user/repo +# Auto-clones the repo and runs init-workspace if needed +# Usage: just devc-remote myserver +# just devc-remote myserver:/home/user/repo +# just devc-remote --repo git@github.com:org/repo.git myserver [group('devcontainer')] -devc-remote host_path: - bash scripts/devc-remote.sh {{host_path}} +devc-remote *args: + bash scripts/devc-remote.sh {{args}} diff --git a/justfile.worktree b/justfile.worktree index a95c1417..fbe18016 100644 --- a/justfile.worktree +++ b/justfile.worktree @@ -117,10 +117,11 @@ worktree-start issue prompt="" reviewer="": echo " tmux session '$SESSION' is running. Use: just worktree-attach $ISSUE" else echo " No tmux session found. Starting one..." + AGENT_MODEL=$(_read_model "autonomous") if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --yolo --approve-mcps \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --approve-mcps" fi sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' started. Use: just worktree-attach $ISSUE" @@ -221,10 +222,11 @@ worktree-start issue prompt="" reviewer="": # Start tmux session # --yolo: auto-approve all shell commands (autonomous agent, no human at the terminal) + AGENT_MODEL=$(_read_model "autonomous") if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --yolo --approve-mcps \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --approve-mcps" fi sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true @@ -307,6 +309,12 @@ worktree-attach issue: fi } + _read_model() { + local tier="$1" + local cfg="$(git rev-parse --show-toplevel)/.cursor/agent-models.toml" + grep "^${tier}" "$cfg" | sed 's/.*= *"//' | sed 's/".*//' + } + ISSUE="{{ issue }}" SESSION="wt-${ISSUE}" WT_DIR="{{ _wt_base }}/${ISSUE}" @@ -315,11 +323,12 @@ worktree-attach issue: if [ -d "$WT_DIR" ]; then echo "[!] tmux session '$SESSION' stopped. Restarting..." _wt_ensure_trust "$WT_DIR" - REVIEWER=$(gh api user --jq '.login' 2>/dev/null || echo "") if [ -n "${WORKTREE_ATTACH_RESTART_CMD:-}" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "$WORKTREE_ATTACH_RESTART_CMD" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" "$WORKTREE_ATTACH_RESTART_CMD" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + REVIEWER=$(gh api user --jq '.login' 2>/dev/null || echo "") + AGENT_MODEL=$(_read_model "autonomous") + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --model $AGENT_MODEL --approve-mcps" fi sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' restarted" diff --git a/pyproject.toml b/pyproject.toml index 6d049542..0aa76bec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,13 @@ dev = [ devcontainer = [ "rich>=13.0.0", "pre-commit>=4.3.0", - "ruff>=0.14.3", + "ruff>=0.15.0", "pip-licenses>=5.0.0", + "bandit[toml]>=1.7.5", ] lint = [ "pre-commit>=4.3.0", - "ruff>=0.14.3", + "ruff>=0.15.0", "pip-licenses>=5.0.0", "bandit[toml]>=1.7.5", ] diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 7eaf9d42..c3320357 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -210,8 +210,11 @@ remote_compose_up() { else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d" >/dev/null 2>&1 - # Health poll (simplified: assume success for now) + if ! ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d"; then + log_error "Failed to start devcontainer on $SSH_HOST." + log_error "Debug with: ssh $SSH_HOST 'cd $REMOTE_PATH && $COMPOSE_CMD logs'" + exit 1 + fi sleep 2 fi } diff --git a/scripts/manifest.toml b/scripts/manifest.toml index e8d3a100..68745f29 100644 --- a/scripts/manifest.toml +++ b/scripts/manifest.toml @@ -91,6 +91,18 @@ dest = ".devcontainer/scripts/derive-branch-summary.sh" src = "scripts/check-skill-names.sh" dest = ".devcontainer/scripts/check-skill-names.sh" +[[entries]] +src = "scripts/setup-labels.sh" +dest = "scripts/setup-labels.sh" + +[[entries]] +src = "scripts/devc-remote.sh" +dest = "scripts/devc-remote.sh" + +[[entries]] +src = "scripts/devc_remote_uri.py" +dest = "scripts/devc_remote_uri.py" + [[entries]] src = "justfile.worktree" dest = ".devcontainer/justfile.worktree" diff --git a/uv.lock b/uv.lock index 4052be90..cf26d889 100644 --- a/uv.lock +++ b/uv.lock @@ -171,6 +171,7 @@ dev = [ { name = "vig-utils" }, ] devcontainer = [ + { name = "bandit" }, { name = "pip-licenses" }, { name = "pre-commit" }, { name = "rich" }, @@ -215,21 +216,22 @@ dev = [ { name = "pytest-docker", specifier = ">=3.2.3" }, { name = "pytest-testinfra", specifier = ">=10.2.2" }, { name = "rich", specifier = ">=13.0.0" }, - { name = "ruff", specifier = ">=0.14.3" }, + { name = "ruff", specifier = ">=0.15.0" }, { name = "testcontainers", specifier = ">=4.9.0" }, { name = "vig-utils", editable = "packages/vig-utils" }, ] devcontainer = [ + { name = "bandit", extras = ["toml"], specifier = ">=1.7.5" }, { name = "pip-licenses", specifier = ">=5.0.0" }, { name = "pre-commit", specifier = ">=4.3.0" }, { name = "rich", specifier = ">=13.0.0" }, - { name = "ruff", specifier = ">=0.14.3" }, + { name = "ruff", specifier = ">=0.15.0" }, ] lint = [ { name = "bandit", extras = ["toml"], specifier = ">=1.7.5" }, { name = "pip-licenses", specifier = ">=5.0.0" }, { name = "pre-commit", specifier = ">=4.3.0" }, - { name = "ruff", specifier = ">=0.14.3" }, + { name = "ruff", specifier = ">=0.15.0" }, ] test = [ { name = "bcrypt", specifier = ">=5.0.0" }, @@ -589,28 +591,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, - { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, - { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, - { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, - { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, - { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, - { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, - { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, - { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, - { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, - { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, - { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] [[package]] From 97158853c7a72b6c18b0b9f8f59d251e6248381e Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Feb 2026 12:59:00 +0100 Subject: [PATCH 026/215] fix: make detect_editor_cli no-editor case deterministic Refs: #202 --- tests/bats/devc-remote.bats | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 876126a2..da446621 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -136,10 +136,13 @@ setup() { } @test "detect_editor_cli fails when neither cursor nor code in PATH" { - # Use env -i for clean environment; minimal PATH has no cursor/code - run env -i PATH="/usr/bin:/bin" HOME="$HOME" "$DEVC_REMOTE" myserver 2>&1 + local empty_path + empty_path="$(mktemp -d)" + # Run via /bin/bash so script execution does not depend on PATH/shebang lookup + run env -i PATH="$empty_path" HOME="$HOME" /bin/bash "$DEVC_REMOTE" myserver 2>&1 assert_failure assert_output --partial "Neither cursor nor code" + rm -rf "$empty_path" } # ── check_ssh ──────────────────────────────────────────────────────────────── From 137f226817e401a3797a3a1489e6b9a5ceca1366 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Feb 2026 13:37:08 +0100 Subject: [PATCH 027/215] fix(image): run lifecycle commands inside the container via bash Pointing directly to the script file sometimes leads to execution problems. Refs: #204 --- assets/workspace/.devcontainer/devcontainer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/workspace/.devcontainer/devcontainer.json b/assets/workspace/.devcontainer/devcontainer.json index 38c94377..78a8c494 100644 --- a/assets/workspace/.devcontainer/devcontainer.json +++ b/assets/workspace/.devcontainer/devcontainer.json @@ -50,7 +50,7 @@ "--group-add=0" ], "initializeCommand": ".devcontainer/scripts/initialize.sh", - "postStartCommand": "/workspace/{{SHORT_NAME}}/.devcontainer/scripts/post-start.sh", - "postAttachCommand": "/workspace/{{SHORT_NAME}}/.devcontainer/scripts/post-attach.sh", - "postCreateCommand": "/workspace/{{SHORT_NAME}}/.devcontainer/scripts/post-create.sh" + "postStartCommand": "/bin/bash /workspace/{{SHORT_NAME}}/.devcontainer/scripts/post-start.sh", + "postAttachCommand": "/bin/bash /workspace/{{SHORT_NAME}}/.devcontainer/scripts/post-attach.sh", + "postCreateCommand": "/bin/bash /workspace/{{SHORT_NAME}}/.devcontainer/scripts/post-create.sh" } From 5d61d9ea0788298bdb5bbc1255ece1f055592902 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Feb 2026 15:12:22 +0100 Subject: [PATCH 028/215] test: cover bash-wrapped devcontainer lifecycle commands Update `post_create` and `post_attach` tests Add missing `post_start` test Refs: #204 --- tests/test_integration.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 1770bc75..4ab774c6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -443,7 +443,7 @@ def test_devcontainer_json_post_attach_command(self, initialized_workspace): ) # postAttachCommand should reference .devcontainer inside project subdirectory expected_command = ( - "/workspace/test_project/.devcontainer/scripts/post-attach.sh" + "/bin/bash /workspace/test_project/.devcontainer/scripts/post-attach.sh" ) assert config["postAttachCommand"] == expected_command, ( f"Expected postAttachCommand='{expected_command}', " @@ -464,13 +464,34 @@ def test_devcontainer_json_post_create_command(self, initialized_workspace): ) # postCreateCommand should reference .devcontainer inside project subdirectory expected_command = ( - "/workspace/test_project/.devcontainer/scripts/post-create.sh" + "/bin/bash /workspace/test_project/.devcontainer/scripts/post-create.sh" ) assert config["postCreateCommand"] == expected_command, ( f"Expected postCreateCommand='{expected_command}', " f"got: {config['postCreateCommand']}" ) + def test_devcontainer_json_post_start_command(self, initialized_workspace): + """Test that postStartCommand is configured correctly.""" + devcontainer_json = ( + initialized_workspace / ".devcontainer" / "devcontainer.json" + ) + + with devcontainer_json.open() as f: + config = json.load(f) + + assert "postStartCommand" in config, ( + "devcontainer.json missing 'postStartCommand' field" + ) + # postStartCommand should reference .devcontainer inside project subdirectory + expected_command = ( + "/bin/bash /workspace/test_project/.devcontainer/scripts/post-start.sh" + ) + assert config["postStartCommand"] == expected_command, ( + f"Expected postStartCommand='{expected_command}', " + f"got: {config['postStartCommand']}" + ) + def test_devcontainer_json_no_redundant_container_env(self, initialized_workspace): """Test that containerEnv only has socket-related env vars (others should be in docker-compose.yml).""" devcontainer_json = ( From db7cfdf460f986d68017d0204f34953055c0d295 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Feb 2026 15:13:46 +0100 Subject: [PATCH 029/215] docs: update CHANGELOG Refs: #204 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6b2222..8299a356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add manifest entries for resolve-branch.sh, derive-branch-summary.sh, check-skill-names.sh → `.devcontainer/scripts/` - Update justfile.worktree to use `source_directory() / "scripts"` for portable path resolution - Add Sed transform for check-skill-names.sh path in synced `.pre-commit-config.yaml` +- **Devcontainer lifecycle commands fail in mock-up folders with crun getcwd error** ([#204](https://github.com/vig-os/devcontainer/issues/204)) + - Run post-create, post-start, and post-attach commands via `/bin/bash` in `devcontainer.json` for stable command resolution on attach + - Prevent attach-time failure where OCI runtime reports `getcwd: No such file or directory` + - Update tests in `test-integration.py` ### Changed From 3e67e1df21ea8891b5a660dd8204a5ebd9f78e5c Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 11:17:54 +0100 Subject: [PATCH 030/215] chore: import additional worktree justfile for enhanced configuration Added an import statement for '.devcontainer/justfile.worktree' to the main justfile, allowing for improved workspace configuration management. --- assets/workspace/justfile | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/workspace/justfile b/assets/workspace/justfile index 6045797e..4f105a3e 100644 --- a/assets/workspace/justfile +++ b/assets/workspace/justfile @@ -11,6 +11,7 @@ help: import '.devcontainer/justfile.base' import '.devcontainer/justfile.gh' +import '.devcontainer/justfile.worktree' # Import team-shared project recipes (git-tracked, preserved on upgrade) From 93bad080bc60702e3724065081ea0ce71a2a8f1c Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 12:18:54 +0100 Subject: [PATCH 031/215] test: add failing test for terminal.integrated.defaultProfile.linux in devcontainer.json Refs: #206 --- tests/bats/init-workspace.bats | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/bats/init-workspace.bats b/tests/bats/init-workspace.bats index f8658093..31ca3f28 100644 --- a/tests/bats/init-workspace.bats +++ b/tests/bats/init-workspace.bats @@ -24,3 +24,24 @@ setup() { run grep 'set -euo pipefail' "$INIT_WORKSPACE_SH" assert_success } + +# ── devcontainer.json template ─────────────────────────────────────────────── + +@test "devcontainer.json template sets terminal.integrated.defaultProfile.linux to bash" { + DEVCONTAINER_JSON="$PROJECT_ROOT/assets/workspace/.devcontainer/devcontainer.json" + run python3 -c " +import json, sys +with open('$DEVCONTAINER_JSON') as f: + data = json.load(f) +settings = data.get('customizations', {}).get('vscode', {}).get('settings', {}) +profile = settings.get('terminal.integrated.defaultProfile.linux') +if profile == 'bash': + print('bash') + sys.exit(0) +else: + print(f'expected bash, got {profile!r}') + sys.exit(1) +" + assert_success + assert_output "bash" +} From 05cb2aa93b89c9fdf5679d38f1a3d8298409e510 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 12:19:19 +0100 Subject: [PATCH 032/215] fix(workspace): add terminal.integrated.defaultProfile.linux override to devcontainer.json Refs: #206 --- assets/workspace/.devcontainer/devcontainer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/workspace/.devcontainer/devcontainer.json b/assets/workspace/.devcontainer/devcontainer.json index 78a8c494..02a71486 100644 --- a/assets/workspace/.devcontainer/devcontainer.json +++ b/assets/workspace/.devcontainer/devcontainer.json @@ -18,6 +18,7 @@ "nefrob.vscode-just-syntax" ], "settings": { + "terminal.integrated.defaultProfile.linux": "bash", "python.defaultInterpreterPath": "/root/assets/workspace/.venv/bin/python", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", From c2d386c51a0642156575fbeeaf735e88a32fda13 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 12:19:56 +0100 Subject: [PATCH 033/215] docs: update CHANGELOG for terminal profile override fix Refs: #206 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8299a356..76b6f097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Run post-create, post-start, and post-attach commands via `/bin/bash` in `devcontainer.json` for stable command resolution on attach - Prevent attach-time failure where OCI runtime reports `getcwd: No such file or directory` - Update tests in `test-integration.py` +- **Cursor Agent shell fails with forkpty(3) when host sets zsh as default terminal profile** ([#206](https://github.com/vig-os/devcontainer/issues/206)) + - Add `terminal.integrated.defaultProfile.linux: "bash"` to devcontainer.json template settings + - Prevents user's host-side shell preference from leaking into the container ### Changed From 3ffe921eb2bc56e1cab2ab0d1f0f732fba7cb002 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 19:53:27 +0100 Subject: [PATCH 034/215] test: add BATS tests for setup-tailscale.sh Refs: #208 --- tests/bats/setup-tailscale.bats | 71 +++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/bats/setup-tailscale.bats diff --git a/tests/bats/setup-tailscale.bats b/tests/bats/setup-tailscale.bats new file mode 100644 index 00000000..61598b8d --- /dev/null +++ b/tests/bats/setup-tailscale.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# BATS tests for setup-tailscale.sh +# +# Tests script structure, opt-in behavior, and subcommand handling. +# No live Tailscale tests — we only verify the opt-in gate logic. + +setup() { + load test_helper + SETUP_TAILSCALE="$PROJECT_ROOT/assets/workspace/.devcontainer/scripts/setup-tailscale.sh" +} + +# ── script structure ────────────────────────────────────────────────────────── + +@test "setup-tailscale.sh is executable" { + run test -x "$SETUP_TAILSCALE" + assert_success +} + +@test "setup-tailscale.sh has shebang" { + run head -1 "$SETUP_TAILSCALE" + assert_output "#!/bin/bash" +} + +@test "setup-tailscale.sh uses strict error handling (set -euo pipefail)" { + run grep 'set -euo pipefail' "$SETUP_TAILSCALE" + assert_success +} + +# ── no subcommand / invalid subcommand ──────────────────────────────────────── + +@test "setup-tailscale.sh with no arguments exits with error" { + run "$SETUP_TAILSCALE" + assert_failure + assert_output --partial "Usage:" +} + +@test "setup-tailscale.sh with invalid subcommand exits with error" { + run "$SETUP_TAILSCALE" bogus + assert_failure + assert_output --partial "Usage:" +} + +# ── install subcommand: opt-in gate ────────────────────────────────────────── + +@test "install is a no-op when TAILSCALE_AUTHKEY is unset" { + unset TAILSCALE_AUTHKEY + run "$SETUP_TAILSCALE" install + assert_success + assert_output --partial "TAILSCALE_AUTHKEY not set" +} + +@test "install is a no-op when TAILSCALE_AUTHKEY is empty" { + TAILSCALE_AUTHKEY="" run "$SETUP_TAILSCALE" install + assert_success + assert_output --partial "TAILSCALE_AUTHKEY not set" +} + +# ── start subcommand: opt-in gate ──────────────────────────────────────────── + +@test "start is a no-op when TAILSCALE_AUTHKEY is unset" { + unset TAILSCALE_AUTHKEY + run "$SETUP_TAILSCALE" start + assert_success + assert_output --partial "TAILSCALE_AUTHKEY not set" +} + +@test "start is a no-op when TAILSCALE_AUTHKEY is empty" { + TAILSCALE_AUTHKEY="" run "$SETUP_TAILSCALE" start + assert_success + assert_output --partial "TAILSCALE_AUTHKEY not set" +} From 50024c4a2610d1029d56d118fb550967578e7371 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 19:53:58 +0100 Subject: [PATCH 035/215] feat: add setup-tailscale.sh with install and start subcommands Opt-in Tailscale SSH setup for devcontainers. Both subcommands are silent no-ops when TAILSCALE_AUTHKEY is unset. Refs: #208 --- .../.devcontainer/scripts/setup-tailscale.sh | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100755 assets/workspace/.devcontainer/scripts/setup-tailscale.sh diff --git a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh new file mode 100755 index 00000000..3635ebef --- /dev/null +++ b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# Tailscale SSH setup for devcontainer — opt-in via TAILSCALE_AUTHKEY env var. +# +# Subcommands: +# install — install Tailscale (called from post-create.sh, runs once) +# start — start tailscaled + tailscale up --ssh (called from post-start.sh, runs every start) +# +# Both subcommands are silent no-ops when TAILSCALE_AUTHKEY is unset or empty. + +set -euo pipefail + +# ── helpers ────────────────────────────────────────────────────────────────── + +require_authkey() { + if [ -z "${TAILSCALE_AUTHKEY:-}" ]; then + echo "Tailscale: TAILSCALE_AUTHKEY not set, skipping." + return 1 + fi + return 0 +} + +resolve_hostname() { + if [ -n "${TAILSCALE_HOSTNAME:-}" ]; then + echo "$TAILSCALE_HOSTNAME" + return + fi + + local project="devc" + local devc_json + devc_json="$(dirname "${BASH_SOURCE[0]}")/../devcontainer.json" + if [ -f "$devc_json" ]; then + local name + name=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))" < "$devc_json" 2>/dev/null || true) + if [ -n "$name" ]; then + project="${name%-devc}" + fi + fi + + echo "${project}-devc-$(hostname -s)" +} + +# ── subcommands ────────────────────────────────────────────────────────────── + +cmd_install() { + require_authkey || return 0 + + if command -v tailscale &>/dev/null; then + echo "Tailscale: already installed, skipping install." + return 0 + fi + + echo "Tailscale: installing..." + curl -fsSL https://tailscale.com/install.sh | sh + echo "Tailscale: install complete." +} + +cmd_start() { + require_authkey || return 0 + + local hostname + hostname=$(resolve_hostname) + + echo "Tailscale: starting (hostname=$hostname)..." + + if ! pgrep -x tailscaled &>/dev/null; then + tailscaled --tun=userspace-networking --state=/var/lib/tailscale/tailscaled.state & + sleep 2 + fi + + if tailscale up --ssh --authkey="$TAILSCALE_AUTHKEY" --hostname="$hostname"; then + echo "Tailscale: connected as $hostname" + else + echo "Tailscale: WARNING — failed to connect. Container still usable via devcontainer protocol." >&2 + fi +} + +# ── main ───────────────────────────────────────────────────────────────────── + +case "${1:-}" in + install) cmd_install ;; + start) cmd_start ;; + *) + echo "Usage: $(basename "$0") {install|start}" >&2 + exit 1 + ;; +esac From 5800ee5c6ae294bc145628cef865a8f7354049d4 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 19:54:29 +0100 Subject: [PATCH 036/215] feat: hook setup-tailscale.sh install into post-create.sh Refs: #208 --- assets/workspace/.devcontainer/scripts/post-create.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assets/workspace/.devcontainer/scripts/post-create.sh b/assets/workspace/.devcontainer/scripts/post-create.sh index 439f7bd7..2fe01696 100644 --- a/assets/workspace/.devcontainer/scripts/post-create.sh +++ b/assets/workspace/.devcontainer/scripts/post-create.sh @@ -35,6 +35,9 @@ sed -i 's/template-project/{{SHORT_NAME}}/g' /root/assets/workspace/.venv/bin/ac echo "Syncing dependencies..." just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" sync +# Tailscale SSH (opt-in: no-op when TAILSCALE_AUTHKEY is unset) +"$SCRIPT_DIR/setup-tailscale.sh" install + # User specific setup # Add your custom setup commands here to install any dependencies or tools needed for your project From da387ac44738bdbd2e7fa6afcc97023d5b214d0b Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 19:54:46 +0100 Subject: [PATCH 037/215] feat: hook setup-tailscale.sh start into post-start.sh Refs: #208 --- assets/workspace/.devcontainer/scripts/post-start.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/workspace/.devcontainer/scripts/post-start.sh b/assets/workspace/.devcontainer/scripts/post-start.sh index 93a1c7f1..3ac4e724 100755 --- a/assets/workspace/.devcontainer/scripts/post-start.sh +++ b/assets/workspace/.devcontainer/scripts/post-start.sh @@ -11,6 +11,7 @@ set -euo pipefail echo "Running post-start setup..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="/workspace/{{SHORT_NAME}}" # Ensure Docker socket is accessible @@ -20,4 +21,7 @@ sudo chmod 666 /var/run/docker.sock 2>/dev/null || true echo "Syncing dependencies..." just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" sync +# Tailscale SSH (opt-in: no-op when TAILSCALE_AUTHKEY is unset) +"$SCRIPT_DIR/setup-tailscale.sh" start + echo "Post-start setup complete" From 8d0e437dff04308f65302a9053ceb71066838cd6 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 19:55:01 +0100 Subject: [PATCH 038/215] docs: add commented Tailscale example to docker-compose.local.yaml Refs: #208 --- .../workspace/.devcontainer/docker-compose.local.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/assets/workspace/.devcontainer/docker-compose.local.yaml b/assets/workspace/.devcontainer/docker-compose.local.yaml index 760ab67a..96c41293 100644 --- a/assets/workspace/.devcontainer/docker-compose.local.yaml +++ b/assets/workspace/.devcontainer/docker-compose.local.yaml @@ -22,4 +22,14 @@ # environment: # - MY_API_KEY=secret123 +# Optional: Tailscale SSH for direct mesh access (e.g. Cursor GUI workaround) +# Generate an auth key at https://login.tailscale.com/admin/settings/keys +# Use an ephemeral + reusable key so stale containers auto-expire. +# +# services: +# devcontainer: +# environment: +# - TAILSCALE_AUTHKEY=tskey-auth-XXXX +# - TAILSCALE_HOSTNAME=myproject-devc-mybox # optional override + services: {} From db8c55f5f82eae4d6a7b1507edfa05ec616acb19 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 19:55:25 +0100 Subject: [PATCH 039/215] docs: add Tailscale SSH section to devcontainer README Refs: #208 --- assets/workspace/.devcontainer/README.md | 60 ++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/assets/workspace/.devcontainer/README.md b/assets/workspace/.devcontainer/README.md index 9793a2f6..ed161db7 100644 --- a/assets/workspace/.devcontainer/README.md +++ b/assets/workspace/.devcontainer/README.md @@ -71,6 +71,66 @@ Paths to other mounts can be absolute or relative to the main project folder. projects you want to see in the editor. The file is git-ignored, so your personal configuration stays local. +## Tailscale SSH + +Connect to the devcontainer over Tailscale SSH instead of the devcontainer protocol. +This enables tools like Cursor GUI to execute shell commands inside the container via SSH remote. + +### Prerequisites + +1. A [Tailscale](https://tailscale.com/) account with SSH enabled in your tailnet ACLs. +2. An auth key (ephemeral + reusable recommended) from + [Tailscale Admin → Settings → Keys](https://login.tailscale.com/admin/settings/keys). + +### Setup + +1. Add the auth key to your local compose override (git-ignored): + + ```yaml + # .devcontainer/docker-compose.local.yaml + services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=tskey-auth-XXXX + # Optional: override the auto-generated hostname + # - TAILSCALE_HOSTNAME=myproject-devc-mybox + ``` + +2. Rebuild the devcontainer (`Cmd/Ctrl+Shift+P` → "Dev Containers: Rebuild Container"). + +3. Tailscale installs on first create (~10 s) and connects on every start. + The container appears in your tailnet as `-devc-`. + +4. Connect via SSH from Cursor or any SSH client: + + ```bash + ssh root@ + ``` + +### Tailscale ACL configuration + +Your tailnet must allow SSH access. Add a rule like this to your +[ACL policy](https://login.tailscale.com/admin/acls): + +```json +{ + "ssh": [ + { + "action": "accept", + "src": ["autogroup:members"], + "dst": ["autogroup:self"], + "users": ["root", "autogroup:nonroot"] + } + ] +} +``` + +### How it works + +- `setup-tailscale.sh install` runs during `postCreateCommand` — installs Tailscale if `TAILSCALE_AUTHKEY` is set. +- `setup-tailscale.sh start` runs during `postStartCommand` — starts `tailscaled` (userspace networking, no `/dev/net/tun` needed) and authenticates. +- When `TAILSCALE_AUTHKEY` is unset, both hooks are silent no-ops. + ## Updating the template If you synchronize with a newer release of the vigOS devcontainer image, From d7741bc05c555127d7bd9e79d9a929af2bcab5af Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 19:55:50 +0100 Subject: [PATCH 040/215] docs: add Tailscale SSH changelog entry Refs: #208 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b6f097..7859f273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- **Opt-in Tailscale SSH support for devcontainer** ([#208](https://github.com/vig-os/devcontainer/issues/208)) + - New `setup-tailscale.sh` script with `install` and `start` subcommands + - Hooks into `post-create.sh` (install) and `post-start.sh` (start) + - Silent no-op when `TAILSCALE_AUTHKEY` is unset — zero impact on existing users + - Commented example in `docker-compose.local.yaml` for quick setup + - Documentation in `.devcontainer/README.md` with quick-start and ACL instructions + ### Fixed - **CI Project Checks coverage includes devc_remote_uri tests** ([#70](https://github.com/vig-os/devcontainer/issues/70)) From 1950910ad647d9bd80ae0fd2ee28912650d69322 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 20:01:17 +0100 Subject: [PATCH 041/215] feat(podman): add push-ssh recipe to transfer images over SSH Refs: #70 --- justfile.podman | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/justfile.podman b/justfile.podman index 0124c802..385b2297 100644 --- a/justfile.podman +++ b/justfile.podman @@ -16,6 +16,7 @@ alias pdm-rmi-project := podman-rmi-project alias pdm-rmi-dangling := podman-rmi-dangling alias pdm-prune := podman-prune alias pdm-prune-all := podman-prune-all +alias pdm-push-ssh := podman-push-ssh # List containers/images (--all for all podman resources) [group('podman')] @@ -181,3 +182,19 @@ podman-prune-all: echo "Cleaning Pruning all unused podman resources (including volumes)..." podman system prune -af --volumes echo "[OK] Full prune complete" + +# Push a local image to a remote machine over SSH (no registry needed) +[group('podman')] +podman-push-ssh image host: + #!/usr/bin/env bash + if ! podman image exists "{{ image }}" 2>/dev/null; then + echo "[ERROR] Image '{{ image }}' not found locally" + exit 1 + fi + if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "{{ host }}" true 2>/dev/null; then + echo "[ERROR] Cannot connect to '{{ host }}' over SSH" + exit 1 + fi + echo "Pushing '{{ image }}' to {{ host }} over SSH..." + podman image save "{{ image }}" | ssh "{{ host }}" podman image load + echo "[OK] Image '{{ image }}' pushed to {{ host }}" From 97604638edceae310bc8871a2bd1f695b87993a8 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 26 Feb 2026 20:14:24 +0100 Subject: [PATCH 042/215] feat(podman): improve push-ssh validation and transfer progress Refs: #70 --- justfile.podman | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/justfile.podman b/justfile.podman index 385b2297..0d1ba920 100644 --- a/justfile.podman +++ b/justfile.podman @@ -189,12 +189,47 @@ podman-push-ssh image host: #!/usr/bin/env bash if ! podman image exists "{{ image }}" 2>/dev/null; then echo "[ERROR] Image '{{ image }}' not found locally" + FMT_REPO=$(printf '\x7b\x7b.Repository\x7d\x7d') + FMT_TAG=$(printf '\x7b\x7b.Tag\x7d\x7d') + CANDIDATES=$(podman images --format "${FMT_REPO}:${FMT_TAG}" 2>/dev/null \ + | grep -v '' \ + | awk -v q="{{ image }}" ' + BEGIN { IGNORECASE=1 } + { + s = tolower($0); t = tolower(q) + # exact substring match ranks highest + if (index(s, t) > 0) { print 1, $0; next } + # check if every query char appears in order (fuzzy) + pos = 0; match_len = 0 + for (i = 1; i <= length(t); i++) { + c = substr(t, i, 1) + found = 0 + for (j = pos + 1; j <= length(s); j++) { + if (substr(s, j, 1) == c) { pos = j; match_len++; found = 1; break } + } + if (!found) break + } + if (match_len == length(t)) { print 2, $0 } + } + ' | sort -t' ' -k1,1n | head -5 | cut -d' ' -f2-) + if [ -n "$CANDIDATES" ]; then + echo "" + echo "Did you mean one of these?" + echo "$CANDIDATES" | while read -r c; do echo " - $c"; done + fi exit 1 fi if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "{{ host }}" true 2>/dev/null; then echo "[ERROR] Cannot connect to '{{ host }}' over SSH" exit 1 fi - echo "Pushing '{{ image }}' to {{ host }} over SSH..." - podman image save "{{ image }}" | ssh "{{ host }}" podman image load + FMT_SIZE=$(printf '\x7b\x7b.Size\x7d\x7d') + IMAGE_SIZE=$(podman image inspect "{{ image }}" --format "$FMT_SIZE" 2>/dev/null) + HUMAN_SIZE=$(numfmt --to=iec "$IMAGE_SIZE" 2>/dev/null || echo "${IMAGE_SIZE} bytes") + echo "Pushing '{{ image }}' ($HUMAN_SIZE) to {{ host }} over SSH..." + if command -v pv &>/dev/null && [ -n "$IMAGE_SIZE" ]; then + podman image save "{{ image }}" | pv -s "$IMAGE_SIZE" -F "%b/$HUMAN_SIZE %t %r %p %e" | ssh "{{ host }}" podman image load + else + podman image save "{{ image }}" | ssh "{{ host }}" podman image load + fi echo "[OK] Image '{{ image }}' pushed to {{ host }}" From 4602d0a9be9d8b2316a1608ca29743ae92e4bdc9 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 6 Mar 2026 16:04:27 +0100 Subject: [PATCH 043/215] fix(image): daemonize 'tailscaled' to survive postStartCommand exit Refs: #208 --- assets/workspace/.devcontainer/scripts/setup-tailscale.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh index 3635ebef..94a86722 100755 --- a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh +++ b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh @@ -64,7 +64,7 @@ cmd_start() { echo "Tailscale: starting (hostname=$hostname)..." if ! pgrep -x tailscaled &>/dev/null; then - tailscaled --tun=userspace-networking --state=/var/lib/tailscale/tailscaled.state & + setsid tailscaled --tun=userspace-networking --state=/var/lib/tailscale/tailscaled.state &>/dev/null & sleep 2 fi From 4fb1fbc65b5ab803982f0f199c502543756bfdf3 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 6 Mar 2026 19:44:00 +0100 Subject: [PATCH 044/215] feat(podman): add arch detection and mismatch handling to push-ssh Validate SSH connectivity upfront, detect remote architecture, and offer to build/rebuild when image is missing or arch mismatches. Refs: #70 Co-Authored-By: Claude Opus 4.6 --- justfile.podman | 59 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/justfile.podman b/justfile.podman index 0d1ba920..865a713b 100644 --- a/justfile.podman +++ b/justfile.podman @@ -187,6 +187,25 @@ podman-prune-all: [group('podman')] podman-push-ssh image host: #!/usr/bin/env bash + if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "{{ host }}" true 2>/dev/null; then + echo "[ERROR] Cannot connect to '{{ host }}' over SSH" + exit 1 + fi + REMOTE_ARCH=$(ssh -o ConnectTimeout=5 -o BatchMode=yes "{{ host }}" uname -m 2>/dev/null | tr -d '\r') + case "$REMOTE_ARCH" in + x86_64|amd64) + TARGET_PLATFORM="linux/amd64" + TARGET_ARCH="amd64" + ;; + aarch64|arm64) + TARGET_PLATFORM="linux/arm64" + TARGET_ARCH="arm64" + ;; + *) + echo "[ERROR] Unsupported remote architecture '$REMOTE_ARCH' on '{{ host }}'" + exit 1 + ;; + esac if ! podman image exists "{{ image }}" 2>/dev/null; then echo "[ERROR] Image '{{ image }}' not found locally" FMT_REPO=$(printf '\x7b\x7b.Repository\x7d\x7d') @@ -216,11 +235,45 @@ podman-push-ssh image host: echo "" echo "Did you mean one of these?" echo "$CANDIDATES" | while read -r c; do echo " - $c"; done + echo "" + fi + read -r -p "Build '{{ image }}' for $TARGET_PLATFORM and continue? [y/N] " SHOULD_BUILD + if [[ "$SHOULD_BUILD" =~ ^[Yy]$ ]]; then + IMAGE_REF="{{ image }}" + LAST_SEGMENT="${IMAGE_REF##*/}" + if [[ "$LAST_SEGMENT" == *:* ]]; then + BUILD_VERSION="${IMAGE_REF##*:}" + BUILD_REPO="${IMAGE_REF%:*}" + else + BUILD_VERSION="latest" + BUILD_REPO="$IMAGE_REF" + fi + ./scripts/build.sh "$BUILD_VERSION" "$BUILD_REPO" "$TARGET_PLATFORM" + else + exit 1 + fi + else + FMT_ARCH=$(printf '\x7b\x7b.Architecture\x7d\x7d') + LOCAL_ARCH=$(podman image inspect "{{ image }}" --format "$FMT_ARCH" 2>/dev/null | tr -d '\r') + if [ -n "$LOCAL_ARCH" ] && [ "$LOCAL_ARCH" != "$TARGET_ARCH" ]; then + echo "[WARN] Local image arch is '$LOCAL_ARCH', remote host arch is '$TARGET_ARCH'" + read -r -p "Rebuild '{{ image }}' for $TARGET_PLATFORM before push? [y/N] " REBUILD_IMAGE + if [[ "$REBUILD_IMAGE" =~ ^[Yy]$ ]]; then + IMAGE_REF="{{ image }}" + LAST_SEGMENT="${IMAGE_REF##*/}" + if [[ "$LAST_SEGMENT" == *:* ]]; then + BUILD_VERSION="${IMAGE_REF##*:}" + BUILD_REPO="${IMAGE_REF%:*}" + else + BUILD_VERSION="latest" + BUILD_REPO="$IMAGE_REF" + fi + ./scripts/build.sh "$BUILD_VERSION" "$BUILD_REPO" "$TARGET_PLATFORM" + fi fi - exit 1 fi - if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "{{ host }}" true 2>/dev/null; then - echo "[ERROR] Cannot connect to '{{ host }}' over SSH" + if ! podman image exists "{{ image }}" 2>/dev/null; then + echo "[ERROR] Image '{{ image }}' still not found after build step" exit 1 fi FMT_SIZE=$(printf '\x7b\x7b.Size\x7d\x7d') From b76f3af33e53df049f2787f10412bacc28edc393 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 6 Mar 2026 21:51:56 +0100 Subject: [PATCH 045/215] docs: add Tailscale SSH design document Architecture decisions, lifecycle hook placement, programmatic key generation via OAuth API, git signing gap, and future considerations. Ported from fd5 prototype and extended with OAuth key generation flow. Refs: #208 --- docs/designs/tailscale-ssh.md | 220 ++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/designs/tailscale-ssh.md diff --git a/docs/designs/tailscale-ssh.md b/docs/designs/tailscale-ssh.md new file mode 100644 index 00000000..ca09009f --- /dev/null +++ b/docs/designs/tailscale-ssh.md @@ -0,0 +1,220 @@ +# Tailscale SSH for Devcontainers + +Design document for opt-in Tailscale SSH access to vigOS devcontainers. +Prototyped in `vig-os/fd5`, upstreamed here. + +Refs: #208 + +## Problem + +Cursor GUI connected to a devcontainer via the devcontainer protocol cannot +execute shell commands through the AI agent. The agent's shell tool fails to +route commands into the container's remote execution context. This is a Cursor +IDE limitation, not a container or project issue. + +VS Code's devcontainer protocol works fine. Cursor's CLI/terminal mode also +works. Only Cursor GUI + devcontainer protocol is broken. + +## Solution + +Run Tailscale inside the devcontainer with SSH enabled. Connect Cursor via +SSH remote (`ssh root@`) instead of the devcontainer protocol. +No jump hosts, no port forwarding — direct mesh access over the tailnet. + +## Architecture decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Networking mode | `--tun=userspace-networking` | No `/dev/net/tun` device needed. Tailscale SSH is handled by the daemon directly, not through the TUN interface. Works in any container runtime without extra device mounts. | +| SSH server | Tailscale SSH (`--ssh`) | No need to install/configure openssh-server. Auth is handled by Tailscale ACLs. | +| Auth mechanism | `TAILSCALE_AUTHKEY` env var | Passed via `docker-compose.local.yaml` (git-ignored). Recommended: reusable + ephemeral keys so stale containers auto-expire. | +| Opt-in strategy | No-op when `TAILSCALE_AUTHKEY` is unset | Install is skipped in post-create, start is skipped in post-start. Zero impact on users who don't set the key. | +| Install method | `curl -fsSL https://tailscale.com/install.sh \| sh` | Official installer, idempotent. Runs once in post-create. | +| Daemon lifecycle | `setsid tailscaled ... &` in `postStartCommand` | `setsid` detaches the daemon from the shell process group so it survives when `postStartCommand` exits. Without `setsid`, the daemon dies with the parent shell. | +| State persistence | `/var/lib/tailscale/tailscaled.state` | Inside the container volume. Lost on container recreate, which is fine with ephemeral auth keys (re-registers automatically). | +| Hostname | `TAILSCALE_HOSTNAME` env var, default `-devc-` | Disambiguates same repo on different machines. Project name is parsed from `devcontainer.json`'s `name` field. Override via env var. | + +## Lifecycle hook placement + +| Hook | Script | Tailscale action | +|------|--------|-----------------| +| `postCreateCommand` | `post-create.sh` | `setup-tailscale.sh install` — installs binary once | +| `postStartCommand` | `post-start.sh` | `setup-tailscale.sh start` — starts daemon + connects | + +`postStartCommand` runs on every container start (create + restart), **before** +the IDE attaches. This is critical — `postAttachCommand` runs in a transient +shell tied to the IDE session, and background processes started there die when +the shell exits. + +## Files + +| File | Role | +|------|------| +| `assets/workspace/.devcontainer/scripts/setup-tailscale.sh` | Single script with `install` and `start` subcommands | +| `assets/workspace/.devcontainer/scripts/post-create.sh` | Calls `setup-tailscale.sh install` | +| `assets/workspace/.devcontainer/scripts/post-start.sh` | Calls `setup-tailscale.sh start` | +| `assets/workspace/.devcontainer/docker-compose.local.yaml` | Commented example for `TAILSCALE_AUTHKEY` | +| `assets/workspace/.devcontainer/README.md` | User-facing setup instructions | + +## User setup + +### 1. Configure Tailscale SSH ACLs + +The tailnet's ACL policy must allow SSH access. In the +[Tailscale admin console](https://login.tailscale.com/admin/acls/file), add: + +```jsonc +"ssh": [ + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["autogroup:self"], + "users": ["root", "autogroup:nonroot"] + } +] +``` + +### 2. Generate a Tailscale auth key + +Generate at https://login.tailscale.com/admin/settings/keys +(Reusable + Ephemeral recommended). + +### 3. Configure the devcontainer + +Edit `.devcontainer/docker-compose.local.yaml`: + +```yaml +services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=tskey-auth-XXXX + - TAILSCALE_HOSTNAME=myproject-devc-mybox # optional +``` + +### 4. Rebuild + +Rebuild the devcontainer. Post-create installs Tailscale (~10s on first build). +Post-start connects to the tailnet on every start. + +### 5. Connect + +```bash +ssh root@ +``` + +For Cursor, use "Remote - SSH" to connect to `root@`. On first +connection, authenticate the Cursor remote server: + +```bash +cursor tunnel --accept-server-license-terms --name +``` + +## Programmatic auth key generation + +Instead of manual key creation in the admin console, auth keys can be generated +via the Tailscale API using an OAuth client. This enables fully automated setup. + +### Setup + +1. Create an OAuth client in the [admin console](https://login.tailscale.com/admin/settings/oauth) + with scope `auth_keys` (write) and tag(s) like `tag:devcontainer`. +2. Store `TS_CLIENT_ID` and `TS_CLIENT_SECRET` per-user (keychain, vault, `.env.local`). + +### Key generation flow + +```bash +# 1. Get an OAuth access token +TOKEN=$(curl -s -d "client_id=$TS_CLIENT_ID" \ + -d "client_secret=$TS_CLIENT_SECRET" \ + "https://api.tailscale.com/api/v2/oauth/token" | jq -r .access_token) + +# 2. Create an ephemeral + reusable auth key +AUTH_KEY=$(curl -s -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "capabilities": { + "devices": { + "create": { + "reusable": true, + "ephemeral": true, + "tags": ["tag:devcontainer"] + } + } + } + }' \ + "https://api.tailscale.com/api/v2/tailnet/-/keys" | jq -r .key) +``` + +### Integration point + +`initialize.sh` (host-side, before container build) could: +1. Check if `TAILSCALE_AUTHKEY` is already set in `docker-compose.local.yaml` +2. If not, check for `TS_CLIENT_ID` + `TS_CLIENT_SECRET` in environment +3. Generate an ephemeral key via the API +4. Inject it into `docker-compose.local.yaml` + +This is a future enhancement — the current implementation requires manual key +configuration, which is sufficient for the initial release. + +## Known gap: git commit signing over Tailscale SSH + +When connecting via Tailscale SSH (instead of the devcontainer protocol), +**git commit signing does not work out of the box**. + +The devcontainer image sets `user.signingkey` to an SSH public key +(`/root/.ssh/id_ed25519_github.pub`), but two things are missing: + +1. **The private key is not present.** Only the `.pub` file exists inside the + container. The private key lives on the host and is normally forwarded via + SSH agent forwarding — but Tailscale SSH doesn't forward the host's SSH + agent into the container session. + +2. **Git signing config is incomplete.** The following settings are not set: + + ```gitconfig + [commit] + gpgsign = true + [gpg] + format = ssh + [gpg "ssh"] + allowedSignersFile = # needed for verification only + ``` + +### Workarounds + +- **Forward the SSH agent manually.** SSH into the container with `ssh -A root@` + so the agent is available. Then set the missing git config: + + ```bash + git config --global commit.gpgsign true + git config --global gpg.format ssh + ``` + +- **Copy the private key into the container.** Mount or copy the signing key + via `docker-compose.local.yaml` volume mount. Less secure (key at rest in + container). + +- **Use a container-local signing key.** Generate a key inside the container, + register it with GitHub, and configure git to use it. + +### Future fix + +The `setup-tailscale.sh start` script should detect whether an SSH agent is +available and, if not, print a warning that commit signing will not work. The +git signing config (`commit.gpgsign`, `gpg.format`) should be set alongside +`user.signingkey` in the devcontainer image or init script so that signing +works automatically when the key is available. + +## Future considerations + +- **Bake Tailscale into the container image** to avoid the ~10s install latency + on first create. Trade-off: image size (~30MB) vs. cold-start time. +- **Hostname templating** via `init-workspace.sh` — the `{{SHORT_NAME}}` + placeholder could feed the default hostname. +- **`docker-compose.local.yaml` template** — include commented Tailscale + example in the template that `init-workspace.sh` generates. +- **Tailscale ACL documentation** — ship a recommended ACL snippet in the + devcontainer README or docs. +- **Programmatic key generation** — integrate OAuth-based key generation into + `initialize.sh` for zero-touch setup (see section above). From d3591e7fc115e882a9d33ff33d870d163e1949d0 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 6 Mar 2026 21:54:37 +0100 Subject: [PATCH 046/215] feat(remote): add Tailscale key injection and --open/--yes flags - Add --open cursor|code|none flag to control IDE opening (default: cursor) - Add --yes flag for non-interactive mode (reuse running containers) - Add inject_tailscale_key() that generates ephemeral auth keys via Tailscale OAuth API when TS_CLIENT_ID/TS_CLIENT_SECRET are set locally, then injects TAILSCALE_AUTHKEY into remote docker-compose.local.yaml - Key injection is opt-in: silent no-op when OAuth creds are absent - Add BATS tests for new flags and tailscale key injection Refs: #230 --- assets/workspace/scripts/devc-remote.sh | 198 +++++++++++++++++++++--- scripts/devc-remote.sh | 198 +++++++++++++++++++++--- tests/bats/devc-remote.bats | 96 +++++++++++- 3 files changed, 451 insertions(+), 41 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index c3320357..7a95a313 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -2,20 +2,29 @@ ############################################################################### # devc-remote.sh - Remote devcontainer orchestrator # -# Starts a devcontainer on a remote host via SSH and opens Cursor/VS Code. +# Starts a devcontainer on a remote host via SSH and optionally opens an IDE. # Handles SSH connectivity, pre-flight checks, container state detection, -# and compose lifecycle. URI construction delegated to Python helper. +# compose lifecycle, and optional Tailscale auth key injection. # # USAGE: -# ./scripts/devc-remote.sh [:] +# ./scripts/devc-remote.sh [options] [:] # ./scripts/devc-remote.sh --help # +# Options: +# --yes, -y Auto-accept prompts (reuse running containers) +# --open IDE to open: cursor | code | none (default: cursor) +# +# Tailscale key injection (opt-in): +# When TS_CLIENT_ID and TS_CLIENT_SECRET are set in the local environment, +# generates an ephemeral auth key via the Tailscale API and injects it +# into the remote docker-compose.local.yaml before compose up. +# # Examples: # ./scripts/devc-remote.sh myserver -# ./scripts/devc-remote.sh user@host:/opt/projects/myrepo -# ./scripts/devc-remote.sh myserver:/home/user/repo +# ./scripts/devc-remote.sh --open none myserver:/home/user/repo +# ./scripts/devc-remote.sh --yes --open code user@host:/opt/projects/myrepo # -# Part of #70. See issue #152 for design. +# Part of #70. See issues #152, #230, #231 for design. ############################################################################### set -euo pipefail @@ -62,12 +71,28 @@ show_help() { parse_args() { SSH_HOST="" REMOTE_PATH="~" + YES_MODE=0 + OPEN_MODE="cursor" while [[ $# -gt 0 ]]; do case "$1" in --help|-h) show_help ;; + --yes|-y) + # shellcheck disable=SC2034 + YES_MODE=1 + shift + ;; + --open) + shift + OPEN_MODE="${1:-cursor}" + if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "none" ]]; then + log_error "--open must be cursor, code, or none" + exit 1 + fi + shift + ;; -*) log_error "Unknown option: $1" echo "Use --help for usage information" @@ -100,16 +125,138 @@ parse_args() { } detect_editor_cli() { - if command -v cursor &>/dev/null; then - # shellcheck disable=SC2034 - EDITOR_CLI="cursor" - elif command -v code &>/dev/null; then - # shellcheck disable=SC2034 - EDITOR_CLI="code" - else - log_error "Neither cursor nor code CLI found. Install Cursor or VS Code and enable the shell command." - exit 1 + if [[ "$OPEN_MODE" == "none" ]]; then + EDITOR_CLI="" + return + fi + + if [[ "$OPEN_MODE" == "cursor" ]]; then + if command -v cursor &>/dev/null; then + EDITOR_CLI="cursor" + else + log_error "cursor CLI not found. Install Cursor and enable the shell command, or use --open code|none." + exit 1 + fi + elif [[ "$OPEN_MODE" == "code" ]]; then + if command -v code &>/dev/null; then + EDITOR_CLI="code" + else + log_error "code CLI not found. Install VS Code and enable the shell command, or use --open cursor|none." + exit 1 + fi + fi +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# TAILSCALE KEY INJECTION (opt-in via TS_CLIENT_ID + TS_CLIENT_SECRET) +# ═══════════════════════════════════════════════════════════════════════════════ + +inject_tailscale_key() { + # Skip if no OAuth credentials + if [[ -z "${TS_CLIENT_ID:-}" || -z "${TS_CLIENT_SECRET:-}" ]]; then + return 0 + fi + + # Check if key already set on remote + # shellcheck disable=SC2029 + if ssh "$SSH_HOST" "grep -q 'TAILSCALE_AUTHKEY' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + log_info "Tailscale: auth key already configured on remote" + return 0 + fi + + # Verify local prerequisites + if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then + log_warning "Tailscale: curl and jq required for key generation, skipping" + return 0 fi + + log_info "Tailscale: generating ephemeral auth key..." + + # Get OAuth access token + local token_response token + token_response=$(curl -s -f \ + -d "client_id=$TS_CLIENT_ID" \ + -d "client_secret=$TS_CLIENT_SECRET" \ + "https://api.tailscale.com/api/v2/oauth/token" 2>&1) || { + log_warning "Tailscale: failed to get OAuth token, skipping" + return 0 + } + token=$(echo "$token_response" | jq -r '.access_token // empty') + if [[ -z "$token" ]]; then + log_warning "Tailscale: empty access token, skipping" + return 0 + fi + + # Create ephemeral, non-reusable auth key + local key_response auth_key + key_response=$(curl -s -f -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d '{ + "capabilities": { + "devices": { + "create": { + "reusable": false, + "ephemeral": true, + "tags": ["tag:devcontainer"] + } + } + } + }' \ + "https://api.tailscale.com/api/v2/tailnet/-/keys" 2>&1) || { + log_warning "Tailscale: failed to create auth key, skipping" + return 0 + } + auth_key=$(echo "$key_response" | jq -r '.key // empty') + if [[ -z "$auth_key" ]]; then + local err_msg + err_msg=$(echo "$key_response" | jq -r '.message // empty') + log_warning "Tailscale: API error: ${err_msg:-unknown}, skipping" + return 0 + fi + + # Inject into remote docker-compose.local.yaml + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$auth_key" << 'INJECT_EOF' +REPO_PATH="$1" +AUTH_KEY="$2" +LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + cat > "$LOCAL_YAML" << 'YAML' +services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=PLACEHOLDER +YAML +fi + +# If file has 'services: {}' (empty), replace with proper structure +if grep -q 'services: {}' "$LOCAL_YAML"; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=${AUTH_KEY} +YAML +elif grep -q 'TAILSCALE_AUTHKEY' "$LOCAL_YAML"; then + sed -i "s|TAILSCALE_AUTHKEY=.*|TAILSCALE_AUTHKEY=${AUTH_KEY}|" "$LOCAL_YAML" +elif grep -q 'environment:' "$LOCAL_YAML"; then + sed -i "/environment:/a\\ - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" +elif grep -q 'devcontainer:' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ environment:\\n - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" +else + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=${AUTH_KEY} +YAML +fi +INJECT_EOF + + log_success "Tailscale: ephemeral auth key injected into remote compose" } check_ssh() { @@ -247,9 +394,14 @@ open_editor() { main() { parse_args "$@" - log_info "Detecting local editor CLI..." - detect_editor_cli - log_success "Using $EDITOR_CLI" + if [[ "$OPEN_MODE" != "none" ]]; then + log_info "Detecting local editor CLI..." + detect_editor_cli + log_success "Using $EDITOR_CLI" + else + detect_editor_cli + log_info "IDE: none (infra only)" + fi log_info "Checking SSH connectivity to $SSH_HOST..." check_ssh @@ -259,10 +411,16 @@ main() { remote_preflight log_success "Pre-flight OK (runtime: $RUNTIME)" + inject_tailscale_key + remote_compose_up - open_editor - log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" + if [[ "$OPEN_MODE" != "none" ]]; then + open_editor + log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" + else + log_success "Done — devcontainer running on $SSH_HOST:$REMOTE_PATH" + fi } main "$@" diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index c3320357..7a95a313 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -2,20 +2,29 @@ ############################################################################### # devc-remote.sh - Remote devcontainer orchestrator # -# Starts a devcontainer on a remote host via SSH and opens Cursor/VS Code. +# Starts a devcontainer on a remote host via SSH and optionally opens an IDE. # Handles SSH connectivity, pre-flight checks, container state detection, -# and compose lifecycle. URI construction delegated to Python helper. +# compose lifecycle, and optional Tailscale auth key injection. # # USAGE: -# ./scripts/devc-remote.sh [:] +# ./scripts/devc-remote.sh [options] [:] # ./scripts/devc-remote.sh --help # +# Options: +# --yes, -y Auto-accept prompts (reuse running containers) +# --open IDE to open: cursor | code | none (default: cursor) +# +# Tailscale key injection (opt-in): +# When TS_CLIENT_ID and TS_CLIENT_SECRET are set in the local environment, +# generates an ephemeral auth key via the Tailscale API and injects it +# into the remote docker-compose.local.yaml before compose up. +# # Examples: # ./scripts/devc-remote.sh myserver -# ./scripts/devc-remote.sh user@host:/opt/projects/myrepo -# ./scripts/devc-remote.sh myserver:/home/user/repo +# ./scripts/devc-remote.sh --open none myserver:/home/user/repo +# ./scripts/devc-remote.sh --yes --open code user@host:/opt/projects/myrepo # -# Part of #70. See issue #152 for design. +# Part of #70. See issues #152, #230, #231 for design. ############################################################################### set -euo pipefail @@ -62,12 +71,28 @@ show_help() { parse_args() { SSH_HOST="" REMOTE_PATH="~" + YES_MODE=0 + OPEN_MODE="cursor" while [[ $# -gt 0 ]]; do case "$1" in --help|-h) show_help ;; + --yes|-y) + # shellcheck disable=SC2034 + YES_MODE=1 + shift + ;; + --open) + shift + OPEN_MODE="${1:-cursor}" + if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "none" ]]; then + log_error "--open must be cursor, code, or none" + exit 1 + fi + shift + ;; -*) log_error "Unknown option: $1" echo "Use --help for usage information" @@ -100,16 +125,138 @@ parse_args() { } detect_editor_cli() { - if command -v cursor &>/dev/null; then - # shellcheck disable=SC2034 - EDITOR_CLI="cursor" - elif command -v code &>/dev/null; then - # shellcheck disable=SC2034 - EDITOR_CLI="code" - else - log_error "Neither cursor nor code CLI found. Install Cursor or VS Code and enable the shell command." - exit 1 + if [[ "$OPEN_MODE" == "none" ]]; then + EDITOR_CLI="" + return + fi + + if [[ "$OPEN_MODE" == "cursor" ]]; then + if command -v cursor &>/dev/null; then + EDITOR_CLI="cursor" + else + log_error "cursor CLI not found. Install Cursor and enable the shell command, or use --open code|none." + exit 1 + fi + elif [[ "$OPEN_MODE" == "code" ]]; then + if command -v code &>/dev/null; then + EDITOR_CLI="code" + else + log_error "code CLI not found. Install VS Code and enable the shell command, or use --open cursor|none." + exit 1 + fi + fi +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# TAILSCALE KEY INJECTION (opt-in via TS_CLIENT_ID + TS_CLIENT_SECRET) +# ═══════════════════════════════════════════════════════════════════════════════ + +inject_tailscale_key() { + # Skip if no OAuth credentials + if [[ -z "${TS_CLIENT_ID:-}" || -z "${TS_CLIENT_SECRET:-}" ]]; then + return 0 + fi + + # Check if key already set on remote + # shellcheck disable=SC2029 + if ssh "$SSH_HOST" "grep -q 'TAILSCALE_AUTHKEY' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + log_info "Tailscale: auth key already configured on remote" + return 0 + fi + + # Verify local prerequisites + if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then + log_warning "Tailscale: curl and jq required for key generation, skipping" + return 0 fi + + log_info "Tailscale: generating ephemeral auth key..." + + # Get OAuth access token + local token_response token + token_response=$(curl -s -f \ + -d "client_id=$TS_CLIENT_ID" \ + -d "client_secret=$TS_CLIENT_SECRET" \ + "https://api.tailscale.com/api/v2/oauth/token" 2>&1) || { + log_warning "Tailscale: failed to get OAuth token, skipping" + return 0 + } + token=$(echo "$token_response" | jq -r '.access_token // empty') + if [[ -z "$token" ]]; then + log_warning "Tailscale: empty access token, skipping" + return 0 + fi + + # Create ephemeral, non-reusable auth key + local key_response auth_key + key_response=$(curl -s -f -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d '{ + "capabilities": { + "devices": { + "create": { + "reusable": false, + "ephemeral": true, + "tags": ["tag:devcontainer"] + } + } + } + }' \ + "https://api.tailscale.com/api/v2/tailnet/-/keys" 2>&1) || { + log_warning "Tailscale: failed to create auth key, skipping" + return 0 + } + auth_key=$(echo "$key_response" | jq -r '.key // empty') + if [[ -z "$auth_key" ]]; then + local err_msg + err_msg=$(echo "$key_response" | jq -r '.message // empty') + log_warning "Tailscale: API error: ${err_msg:-unknown}, skipping" + return 0 + fi + + # Inject into remote docker-compose.local.yaml + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$auth_key" << 'INJECT_EOF' +REPO_PATH="$1" +AUTH_KEY="$2" +LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + cat > "$LOCAL_YAML" << 'YAML' +services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=PLACEHOLDER +YAML +fi + +# If file has 'services: {}' (empty), replace with proper structure +if grep -q 'services: {}' "$LOCAL_YAML"; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=${AUTH_KEY} +YAML +elif grep -q 'TAILSCALE_AUTHKEY' "$LOCAL_YAML"; then + sed -i "s|TAILSCALE_AUTHKEY=.*|TAILSCALE_AUTHKEY=${AUTH_KEY}|" "$LOCAL_YAML" +elif grep -q 'environment:' "$LOCAL_YAML"; then + sed -i "/environment:/a\\ - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" +elif grep -q 'devcontainer:' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ environment:\\n - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" +else + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - TAILSCALE_AUTHKEY=${AUTH_KEY} +YAML +fi +INJECT_EOF + + log_success "Tailscale: ephemeral auth key injected into remote compose" } check_ssh() { @@ -247,9 +394,14 @@ open_editor() { main() { parse_args "$@" - log_info "Detecting local editor CLI..." - detect_editor_cli - log_success "Using $EDITOR_CLI" + if [[ "$OPEN_MODE" != "none" ]]; then + log_info "Detecting local editor CLI..." + detect_editor_cli + log_success "Using $EDITOR_CLI" + else + detect_editor_cli + log_info "IDE: none (infra only)" + fi log_info "Checking SSH connectivity to $SSH_HOST..." check_ssh @@ -259,10 +411,16 @@ main() { remote_preflight log_success "Pre-flight OK (runtime: $RUNTIME)" + inject_tailscale_key + remote_compose_up - open_editor - log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" + if [[ "$OPEN_MODE" != "none" ]]; then + open_editor + log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" + else + log_success "Done — devcontainer running on $SSH_HOST:$REMOTE_PATH" + fi } main "$@" diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index da446621..8374f28e 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -141,10 +141,104 @@ setup() { # Run via /bin/bash so script execution does not depend on PATH/shebang lookup run env -i PATH="$empty_path" HOME="$HOME" /bin/bash "$DEVC_REMOTE" myserver 2>&1 assert_failure - assert_output --partial "Neither cursor nor code" + assert_output --partial "cursor CLI not found" rm -rf "$empty_path" } +# ── --open flag ────────────────────────────────────────────────────────────── + +@test "--open none skips editor detection" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + # No cursor/code in PATH, but --open none should skip detection + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver 2>&1 + refute_output --partial "cursor CLI not found" + refute_output --partial "code CLI not found" + # Should fail at check_ssh, not editor detection + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "--open code fails when code not in PATH" { + local empty_path + empty_path="$(mktemp -d)" + run env -i PATH="$empty_path" HOME="$HOME" /bin/bash "$DEVC_REMOTE" --open code myserver 2>&1 + assert_failure + assert_output --partial "code CLI not found" + rm -rf "$empty_path" +} + +@test "--open invalid value rejected" { + run "$DEVC_REMOTE" --open vim myserver 2>&1 + assert_failure + assert_output --partial "must be cursor, code, or none" +} + +# ── --yes flag ────────────────────────────────────────────────────────────── + +@test "--yes flag is accepted" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --yes myserver 2>&1 + # Should fail at check_ssh, not argument parsing + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +# ── inject_tailscale_key ──────────────────────────────────────────────────── + +@test "inject_tailscale_key defines function" { + run grep 'inject_tailscale_key()' "$DEVC_REMOTE" + assert_success +} + +@test "inject_tailscale_key is called in main" { + run grep 'inject_tailscale_key' "$DEVC_REMOTE" + assert_success + # Should appear at least twice: definition + call + local count + count=$(grep -c 'inject_tailscale_key' "$DEVC_REMOTE") + [ "$count" -ge 2 ] +} + +@test "inject_tailscale_key skips when TS_CLIENT_ID unset" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "2" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" + chmod +x "$mock_bin/python3" + printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + # Run WITHOUT TS_CLIENT_ID — should not mention Tailscale at all + PATH="$mock_bin:$PATH" run env -u TS_CLIENT_ID -u TS_CLIENT_SECRET "$DEVC_REMOTE" host 2>&1 + assert_success + refute_output --partial "Tailscale" + rm -rf "$mock_bin" +} + # ── check_ssh ──────────────────────────────────────────────────────────────── @test "check_ssh succeeds when ssh connects" { From d9dc280fa5c91b64383a02014af39d06778e05c7 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 6 Mar 2026 22:28:25 +0100 Subject: [PATCH 047/215] feat(remote): add --open ssh mode with Tailscale wait and connection output - Add --open ssh: waits for container to appear on tailnet (polls up to 60s), then prints connection commands for ssh, Cursor, and VS Code - Extract read_workspace_folder() helper from open_editor() - Add wait_for_tailscale() that discovers the container via local tailscale status --json, matching devcontainer.json name field - Refactor main() to use case/esac for cleaner mode dispatch - Add BATS tests for --open ssh and new function definitions Refs: #231 --- assets/workspace/scripts/devc-remote.sh | 123 +++++++++++++++++++----- scripts/devc-remote.sh | 123 +++++++++++++++++++----- tests/bats/devc-remote.bats | 26 ++++- 3 files changed, 220 insertions(+), 52 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 7a95a313..31f01556 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -12,7 +12,11 @@ # # Options: # --yes, -y Auto-accept prompts (reuse running containers) -# --open IDE to open: cursor | code | none (default: cursor) +# --open How to connect after compose up: +# cursor - open Cursor via devcontainer protocol (default) +# code - open VS Code via devcontainer protocol +# ssh - wait for Tailscale, print hostname (for SSH clients) +# none - infra only, no IDE # # Tailscale key injection (opt-in): # When TS_CLIENT_ID and TS_CLIENT_SECRET are set in the local environment, @@ -22,6 +26,7 @@ # Examples: # ./scripts/devc-remote.sh myserver # ./scripts/devc-remote.sh --open none myserver:/home/user/repo +# ./scripts/devc-remote.sh --open ssh myserver # ./scripts/devc-remote.sh --yes --open code user@host:/opt/projects/myrepo # # Part of #70. See issues #152, #230, #231 for design. @@ -87,8 +92,8 @@ parse_args() { --open) shift OPEN_MODE="${1:-cursor}" - if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "none" ]]; then - log_error "--open must be cursor, code, or none" + if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "ssh" && "$OPEN_MODE" != "none" ]]; then + log_error "--open must be cursor, code, ssh, or none" exit 1 fi shift @@ -125,7 +130,7 @@ parse_args() { } detect_editor_cli() { - if [[ "$OPEN_MODE" == "none" ]]; then + if [[ "$OPEN_MODE" == "none" || "$OPEN_MODE" == "ssh" ]]; then EDITOR_CLI="" return fi @@ -134,14 +139,14 @@ detect_editor_cli() { if command -v cursor &>/dev/null; then EDITOR_CLI="cursor" else - log_error "cursor CLI not found. Install Cursor and enable the shell command, or use --open code|none." + log_error "cursor CLI not found. Install Cursor and enable the shell command, or use --open code|ssh|none." exit 1 fi elif [[ "$OPEN_MODE" == "code" ]]; then if command -v code &>/dev/null; then EDITOR_CLI="code" else - log_error "code CLI not found. Install VS Code and enable the shell command, or use --open cursor|none." + log_error "code CLI not found. Install VS Code and enable the shell command, or use --open cursor|ssh|none." exit 1 fi fi @@ -366,17 +371,20 @@ remote_compose_up() { fi } -open_editor() { - local container_workspace uri +read_workspace_folder() { # Read workspaceFolder from devcontainer.json on remote host + local folder # shellcheck disable=SC2029 - container_workspace=$(ssh "$SSH_HOST" \ + folder=$(ssh "$SSH_HOST" \ "grep -o '\"workspaceFolder\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \ ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" \ | sed 's/.*: *"//;s/"//' || echo "/workspace") + echo "${folder:-/workspace}" +} - # Default to /workspace if workspaceFolder not found - container_workspace="${container_workspace:-/workspace}" +open_editor() { + local container_workspace uri + container_workspace=$(read_workspace_folder) # Build URI using Python helper uri=$(python3 "$SCRIPT_DIR/devc_remote_uri.py" \ @@ -387,6 +395,67 @@ open_editor() { "$EDITOR_CLI" --folder-uri "$uri" } +# ═══════════════════════════════════════════════════════════════════════════════ +# TAILSCALE WAIT + SSH OUTPUT +# ═══════════════════════════════════════════════════════════════════════════════ + +wait_for_tailscale() { + if ! command -v tailscale &>/dev/null; then + log_error "tailscale CLI not found locally. Install Tailscale to use --open ssh." + exit 1 + fi + + # Derive expected hostname pattern from devcontainer.json name field + local devc_name + # shellcheck disable=SC2029 + devc_name=$(ssh "$SSH_HOST" \ + "python3 -c \"import json,sys; print(json.load(sys.stdin).get('name',''))\" \ + < ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" || true) + devc_name="${devc_name:-devc}" + + log_info "Tailscale: waiting for container to join tailnet (pattern: *${devc_name}*)..." + + local ip hostname + for _ in $(seq 1 30); do + # Query local tailscale for peers matching the devc hostname pattern + local ts_status + ts_status=$(tailscale status --json 2>/dev/null || true) + if [[ -n "$ts_status" ]]; then + # Find an online peer whose hostname contains the devc name + local match + match=$(echo "$ts_status" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for peer in (data.get('Peer') or {}).values(): + if peer.get('Online') and '${devc_name}' in peer.get('HostName', ''): + ips = peer.get('TailscaleIPs', []) + print(peer['HostName'] + ' ' + (ips[0] if ips else '')) + break +" 2>/dev/null || true) + + if [[ -n "$match" ]]; then + hostname="${match%% *}" + ip="${match#* }" + log_success "Tailscale: container online as ${hostname} (${ip})" + # Output connection info to stdout (for scripting) + echo "" + echo "Connect via:" + echo " ssh root@${hostname}" + echo " ssh root@${ip}" + echo "" + echo "Cursor: cursor --remote ssh-remote+root@${hostname} $(read_workspace_folder)" + echo "VS Code: code --remote ssh-remote+root@${hostname} $(read_workspace_folder)" + return 0 + fi + fi + sleep 2 + done + + log_warning "Tailscale: container did not appear on tailnet within 60s" + log_warning "Check that TAILSCALE_AUTHKEY is set and Tailscale ACLs allow SSH" + return 1 +} + # ═══════════════════════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════════════════════ @@ -394,14 +463,12 @@ open_editor() { main() { parse_args "$@" - if [[ "$OPEN_MODE" != "none" ]]; then - log_info "Detecting local editor CLI..." - detect_editor_cli - log_success "Using $EDITOR_CLI" - else - detect_editor_cli - log_info "IDE: none (infra only)" - fi + detect_editor_cli + case "$OPEN_MODE" in + cursor|code) log_success "IDE: $EDITOR_CLI" ;; + ssh) log_info "Mode: SSH (wait for Tailscale, print connection info)" ;; + none) log_info "Mode: infra only (no IDE)" ;; + esac log_info "Checking SSH connectivity to $SSH_HOST..." check_ssh @@ -415,12 +482,18 @@ main() { remote_compose_up - if [[ "$OPEN_MODE" != "none" ]]; then - open_editor - log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" - else - log_success "Done — devcontainer running on $SSH_HOST:$REMOTE_PATH" - fi + case "$OPEN_MODE" in + cursor|code) + open_editor + log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" + ;; + ssh) + wait_for_tailscale + ;; + none) + log_success "Done — devcontainer running on $SSH_HOST:$REMOTE_PATH" + ;; + esac } main "$@" diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 7a95a313..31f01556 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -12,7 +12,11 @@ # # Options: # --yes, -y Auto-accept prompts (reuse running containers) -# --open IDE to open: cursor | code | none (default: cursor) +# --open How to connect after compose up: +# cursor - open Cursor via devcontainer protocol (default) +# code - open VS Code via devcontainer protocol +# ssh - wait for Tailscale, print hostname (for SSH clients) +# none - infra only, no IDE # # Tailscale key injection (opt-in): # When TS_CLIENT_ID and TS_CLIENT_SECRET are set in the local environment, @@ -22,6 +26,7 @@ # Examples: # ./scripts/devc-remote.sh myserver # ./scripts/devc-remote.sh --open none myserver:/home/user/repo +# ./scripts/devc-remote.sh --open ssh myserver # ./scripts/devc-remote.sh --yes --open code user@host:/opt/projects/myrepo # # Part of #70. See issues #152, #230, #231 for design. @@ -87,8 +92,8 @@ parse_args() { --open) shift OPEN_MODE="${1:-cursor}" - if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "none" ]]; then - log_error "--open must be cursor, code, or none" + if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "ssh" && "$OPEN_MODE" != "none" ]]; then + log_error "--open must be cursor, code, ssh, or none" exit 1 fi shift @@ -125,7 +130,7 @@ parse_args() { } detect_editor_cli() { - if [[ "$OPEN_MODE" == "none" ]]; then + if [[ "$OPEN_MODE" == "none" || "$OPEN_MODE" == "ssh" ]]; then EDITOR_CLI="" return fi @@ -134,14 +139,14 @@ detect_editor_cli() { if command -v cursor &>/dev/null; then EDITOR_CLI="cursor" else - log_error "cursor CLI not found. Install Cursor and enable the shell command, or use --open code|none." + log_error "cursor CLI not found. Install Cursor and enable the shell command, or use --open code|ssh|none." exit 1 fi elif [[ "$OPEN_MODE" == "code" ]]; then if command -v code &>/dev/null; then EDITOR_CLI="code" else - log_error "code CLI not found. Install VS Code and enable the shell command, or use --open cursor|none." + log_error "code CLI not found. Install VS Code and enable the shell command, or use --open cursor|ssh|none." exit 1 fi fi @@ -366,17 +371,20 @@ remote_compose_up() { fi } -open_editor() { - local container_workspace uri +read_workspace_folder() { # Read workspaceFolder from devcontainer.json on remote host + local folder # shellcheck disable=SC2029 - container_workspace=$(ssh "$SSH_HOST" \ + folder=$(ssh "$SSH_HOST" \ "grep -o '\"workspaceFolder\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \ ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" \ | sed 's/.*: *"//;s/"//' || echo "/workspace") + echo "${folder:-/workspace}" +} - # Default to /workspace if workspaceFolder not found - container_workspace="${container_workspace:-/workspace}" +open_editor() { + local container_workspace uri + container_workspace=$(read_workspace_folder) # Build URI using Python helper uri=$(python3 "$SCRIPT_DIR/devc_remote_uri.py" \ @@ -387,6 +395,67 @@ open_editor() { "$EDITOR_CLI" --folder-uri "$uri" } +# ═══════════════════════════════════════════════════════════════════════════════ +# TAILSCALE WAIT + SSH OUTPUT +# ═══════════════════════════════════════════════════════════════════════════════ + +wait_for_tailscale() { + if ! command -v tailscale &>/dev/null; then + log_error "tailscale CLI not found locally. Install Tailscale to use --open ssh." + exit 1 + fi + + # Derive expected hostname pattern from devcontainer.json name field + local devc_name + # shellcheck disable=SC2029 + devc_name=$(ssh "$SSH_HOST" \ + "python3 -c \"import json,sys; print(json.load(sys.stdin).get('name',''))\" \ + < ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" || true) + devc_name="${devc_name:-devc}" + + log_info "Tailscale: waiting for container to join tailnet (pattern: *${devc_name}*)..." + + local ip hostname + for _ in $(seq 1 30); do + # Query local tailscale for peers matching the devc hostname pattern + local ts_status + ts_status=$(tailscale status --json 2>/dev/null || true) + if [[ -n "$ts_status" ]]; then + # Find an online peer whose hostname contains the devc name + local match + match=$(echo "$ts_status" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for peer in (data.get('Peer') or {}).values(): + if peer.get('Online') and '${devc_name}' in peer.get('HostName', ''): + ips = peer.get('TailscaleIPs', []) + print(peer['HostName'] + ' ' + (ips[0] if ips else '')) + break +" 2>/dev/null || true) + + if [[ -n "$match" ]]; then + hostname="${match%% *}" + ip="${match#* }" + log_success "Tailscale: container online as ${hostname} (${ip})" + # Output connection info to stdout (for scripting) + echo "" + echo "Connect via:" + echo " ssh root@${hostname}" + echo " ssh root@${ip}" + echo "" + echo "Cursor: cursor --remote ssh-remote+root@${hostname} $(read_workspace_folder)" + echo "VS Code: code --remote ssh-remote+root@${hostname} $(read_workspace_folder)" + return 0 + fi + fi + sleep 2 + done + + log_warning "Tailscale: container did not appear on tailnet within 60s" + log_warning "Check that TAILSCALE_AUTHKEY is set and Tailscale ACLs allow SSH" + return 1 +} + # ═══════════════════════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════════════════════ @@ -394,14 +463,12 @@ open_editor() { main() { parse_args "$@" - if [[ "$OPEN_MODE" != "none" ]]; then - log_info "Detecting local editor CLI..." - detect_editor_cli - log_success "Using $EDITOR_CLI" - else - detect_editor_cli - log_info "IDE: none (infra only)" - fi + detect_editor_cli + case "$OPEN_MODE" in + cursor|code) log_success "IDE: $EDITOR_CLI" ;; + ssh) log_info "Mode: SSH (wait for Tailscale, print connection info)" ;; + none) log_info "Mode: infra only (no IDE)" ;; + esac log_info "Checking SSH connectivity to $SSH_HOST..." check_ssh @@ -415,12 +482,18 @@ main() { remote_compose_up - if [[ "$OPEN_MODE" != "none" ]]; then - open_editor - log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" - else - log_success "Done — devcontainer running on $SSH_HOST:$REMOTE_PATH" - fi + case "$OPEN_MODE" in + cursor|code) + open_editor + log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" + ;; + ssh) + wait_for_tailscale + ;; + none) + log_success "Done — devcontainer running on $SSH_HOST:$REMOTE_PATH" + ;; + esac } main "$@" diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 8374f28e..46b5afab 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -170,10 +170,22 @@ setup() { rm -rf "$empty_path" } +@test "--open ssh skips editor detection" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open ssh myserver 2>&1 + refute_output --partial "cursor CLI not found" + refute_output --partial "code CLI not found" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + @test "--open invalid value rejected" { run "$DEVC_REMOTE" --open vim myserver 2>&1 assert_failure - assert_output --partial "must be cursor, code, or none" + assert_output --partial "must be cursor, code, ssh, or none" } # ── --yes flag ────────────────────────────────────────────────────────────── @@ -193,6 +205,16 @@ setup() { # ── inject_tailscale_key ──────────────────────────────────────────────────── +@test "wait_for_tailscale defines function" { + run grep 'wait_for_tailscale()' "$DEVC_REMOTE" + assert_success +} + +@test "read_workspace_folder defines function" { + run grep 'read_workspace_folder()' "$DEVC_REMOTE" + assert_success +} + @test "inject_tailscale_key defines function" { run grep 'inject_tailscale_key()' "$DEVC_REMOTE" assert_success @@ -375,7 +397,7 @@ SSHEOF chmod +x "$mock_bin/cursor" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 assert_success - assert_output --partial "editor CLI" + assert_output --partial "IDE:" assert_output --partial "SSH" assert_output --partial "pre-flight" rm -rf "$mock_bin" From 6b82c3b38bfb8c3ec4f107c3a3ac22d93577f88a Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 6 Mar 2026 22:38:39 +0100 Subject: [PATCH 048/215] feat(remote): auto-detect IDE from TERM_PROGRAM with fallback chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default --open mode is now 'auto' instead of hardcoding 'cursor'. Detection chain: TERM_PROGRAM env var → CLI availability → ssh fallback. Terminal apps (WezTerm, iTerm, tmux) resolve directly to ssh mode. Refs: #231 Co-Authored-By: Claude Opus 4.6 --- assets/workspace/scripts/devc-remote.sh | 34 ++++++++-- scripts/devc-remote.sh | 34 ++++++++-- tests/bats/devc-remote.bats | 84 ++++++++++++++++++------- 3 files changed, 121 insertions(+), 31 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 31f01556..6727371d 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -13,7 +13,8 @@ # Options: # --yes, -y Auto-accept prompts (reuse running containers) # --open How to connect after compose up: -# cursor - open Cursor via devcontainer protocol (default) +# auto - detect IDE from $TERM_PROGRAM or CLI availability (default) +# cursor - open Cursor via devcontainer protocol # code - open VS Code via devcontainer protocol # ssh - wait for Tailscale, print hostname (for SSH clients) # none - infra only, no IDE @@ -77,7 +78,7 @@ parse_args() { SSH_HOST="" REMOTE_PATH="~" YES_MODE=0 - OPEN_MODE="cursor" + OPEN_MODE="auto" while [[ $# -gt 0 ]]; do case "$1" in @@ -92,8 +93,8 @@ parse_args() { --open) shift OPEN_MODE="${1:-cursor}" - if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "ssh" && "$OPEN_MODE" != "none" ]]; then - log_error "--open must be cursor, code, ssh, or none" + if [[ "$OPEN_MODE" != "auto" && "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "ssh" && "$OPEN_MODE" != "none" ]]; then + log_error "--open must be auto, cursor, code, ssh, or none" exit 1 fi shift @@ -135,6 +136,31 @@ detect_editor_cli() { return fi + # Auto-detect: check TERM_PROGRAM, then fall back to CLI availability + if [[ "$OPEN_MODE" == "auto" ]]; then + case "${TERM_PROGRAM:-}" in + cursor|Cursor) + OPEN_MODE="cursor" ;; + vscode|VSCode) + OPEN_MODE="code" ;; + WezTerm|iTerm*|Apple_Terminal|tmux) + # Terminal app — no devcontainer protocol, default to ssh + OPEN_MODE="ssh" ;; + esac + fi + + # Still auto? Fall back to CLI availability + if [[ "$OPEN_MODE" == "auto" ]]; then + if command -v cursor &>/dev/null; then + OPEN_MODE="cursor" + elif command -v code &>/dev/null; then + OPEN_MODE="code" + else + OPEN_MODE="ssh" + log_info "No IDE CLI found, falling back to --open ssh" + fi + fi + if [[ "$OPEN_MODE" == "cursor" ]]; then if command -v cursor &>/dev/null; then EDITOR_CLI="cursor" diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 31f01556..6727371d 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -13,7 +13,8 @@ # Options: # --yes, -y Auto-accept prompts (reuse running containers) # --open How to connect after compose up: -# cursor - open Cursor via devcontainer protocol (default) +# auto - detect IDE from $TERM_PROGRAM or CLI availability (default) +# cursor - open Cursor via devcontainer protocol # code - open VS Code via devcontainer protocol # ssh - wait for Tailscale, print hostname (for SSH clients) # none - infra only, no IDE @@ -77,7 +78,7 @@ parse_args() { SSH_HOST="" REMOTE_PATH="~" YES_MODE=0 - OPEN_MODE="cursor" + OPEN_MODE="auto" while [[ $# -gt 0 ]]; do case "$1" in @@ -92,8 +93,8 @@ parse_args() { --open) shift OPEN_MODE="${1:-cursor}" - if [[ "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "ssh" && "$OPEN_MODE" != "none" ]]; then - log_error "--open must be cursor, code, ssh, or none" + if [[ "$OPEN_MODE" != "auto" && "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "ssh" && "$OPEN_MODE" != "none" ]]; then + log_error "--open must be auto, cursor, code, ssh, or none" exit 1 fi shift @@ -135,6 +136,31 @@ detect_editor_cli() { return fi + # Auto-detect: check TERM_PROGRAM, then fall back to CLI availability + if [[ "$OPEN_MODE" == "auto" ]]; then + case "${TERM_PROGRAM:-}" in + cursor|Cursor) + OPEN_MODE="cursor" ;; + vscode|VSCode) + OPEN_MODE="code" ;; + WezTerm|iTerm*|Apple_Terminal|tmux) + # Terminal app — no devcontainer protocol, default to ssh + OPEN_MODE="ssh" ;; + esac + fi + + # Still auto? Fall back to CLI availability + if [[ "$OPEN_MODE" == "auto" ]]; then + if command -v cursor &>/dev/null; then + OPEN_MODE="cursor" + elif command -v code &>/dev/null; then + OPEN_MODE="code" + else + OPEN_MODE="ssh" + log_info "No IDE CLI found, falling back to --open ssh" + fi + fi + if [[ "$OPEN_MODE" == "cursor" ]]; then if command -v cursor &>/dev/null; then EDITOR_CLI="cursor" diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 46b5afab..09834d41 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -119,9 +119,12 @@ setup() { chmod +x "$mock_bin/cursor" "$mock_bin/code" # Script will fail at check_ssh, but we verify cursor was chosen by checking # we get past detect_editor_cli (would fail with "Neither cursor nor code" otherwise) - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" nonexistent-host 2>&1 - # Should not contain "Neither cursor nor code" - fails at check_ssh instead - refute_output --partial "Neither cursor nor code" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + # Unset TERM_PROGRAM so auto-detect falls through to CLI availability check + TERM_PROGRAM='' PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" nonexistent-host 2>&1 + # Auto-detect should pick cursor; verify it gets past detect_editor_cli + assert_output --partial "IDE: cursor" rm -rf "$mock_bin" } @@ -130,19 +133,28 @@ setup() { mock_bin="$(mktemp -d)" echo '#!/bin/sh' > "$mock_bin/code" chmod +x "$mock_bin/code" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" nonexistent-host 2>&1 - refute_output --partial "Neither cursor nor code" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + # Use env -i to ensure system cursor is not in PATH + run env -i PATH="$mock_bin" HOME="$HOME" TERM_PROGRAM= /bin/bash "$DEVC_REMOTE" nonexistent-host 2>&1 + assert_output --partial "IDE: code" rm -rf "$mock_bin" } -@test "detect_editor_cli fails when neither cursor nor code in PATH" { - local empty_path - empty_path="$(mktemp -d)" +@test "detect_editor_cli falls back to ssh when neither cursor nor code in PATH" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" # Run via /bin/bash so script execution does not depend on PATH/shebang lookup - run env -i PATH="$empty_path" HOME="$HOME" /bin/bash "$DEVC_REMOTE" myserver 2>&1 - assert_failure - assert_output --partial "cursor CLI not found" - rm -rf "$empty_path" + run env -i PATH="$mock_bin" HOME="$HOME" /bin/bash "$DEVC_REMOTE" myserver 2>&1 + # Should not error about missing CLI — falls back to ssh mode + refute_output --partial "cursor CLI not found" + refute_output --partial "code CLI not found" + assert_output --partial "No IDE CLI found, falling back to --open ssh" + # Fails at check_ssh, not editor detection + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" } # ── --open flag ────────────────────────────────────────────────────────────── @@ -182,10 +194,35 @@ setup() { rm -rf "$mock_bin" } +@test "--open auto detects cursor from TERM_PROGRAM" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + TERM_PROGRAM=cursor PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" myserver 2>&1 + assert_output --partial "IDE: cursor" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "--open auto falls back to ssh for WezTerm TERM_PROGRAM" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + TERM_PROGRAM=WezTerm PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" myserver 2>&1 + refute_output --partial "cursor CLI not found" + assert_output --partial "Mode: SSH" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + @test "--open invalid value rejected" { run "$DEVC_REMOTE" --open vim myserver 2>&1 assert_failure - assert_output --partial "must be cursor, code, ssh, or none" + assert_output --partial "must be auto, cursor, code, ssh, or none" } # ── --yes flag ────────────────────────────────────────────────────────────── @@ -197,7 +234,7 @@ setup() { chmod +x "$mock_bin/cursor" printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" chmod +x "$mock_bin/ssh" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --yes myserver 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --yes --open cursor myserver 2>&1 # Should fail at check_ssh, not argument parsing assert_output --partial "Cannot connect to" rm -rf "$mock_bin" @@ -254,8 +291,9 @@ SSHEOF chmod +x "$mock_bin/python3" printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" - # Run WITHOUT TS_CLIENT_ID — should not mention Tailscale at all - PATH="$mock_bin:$PATH" run env -u TS_CLIENT_ID -u TS_CLIENT_SECRET "$DEVC_REMOTE" host 2>&1 + # Run WITHOUT TS_CLIENT_ID — inject_tailscale_key should not mention Tailscale + # Use --open none to avoid ssh mode triggering wait_for_tailscale output + PATH="$mock_bin:$PATH" run env -u TS_CLIENT_ID -u TS_CLIENT_SECRET "$DEVC_REMOTE" --open none host 2>&1 assert_success refute_output --partial "Tailscale" rm -rf "$mock_bin" @@ -271,7 +309,7 @@ SSHEOF # Need cursor for detect_editor_cli printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" anyhost 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none anyhost 2>&1 # Should get past check_ssh; will fail at remote_preflight (mock ssh just exits) refute_output --partial "Cannot connect to" rm -rf "$mock_bin" @@ -284,7 +322,7 @@ SSHEOF chmod +x "$mock_bin/ssh" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" badhost 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none badhost 2>&1 assert_failure assert_output --partial "Cannot connect to" rm -rf "$mock_bin" @@ -309,7 +347,7 @@ SSHEOF printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" # Will fail at remote_compose_up or open_editor; we verify we get past preflight - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none host 2>&1 refute_output --partial "No container runtime" refute_output --partial "Compose not available" refute_output --partial "Repository not found" @@ -329,7 +367,7 @@ SSHEOF chmod +x "$mock_bin/ssh" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none host 2>&1 assert_failure assert_output --partial "No container runtime" rm -rf "$mock_bin" @@ -362,7 +400,7 @@ SSHEOF chmod +x "$mock_bin/python3" printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open cursor host 2>&1 assert_success assert_output --partial "Devcontainer already running" rm -rf "$mock_bin" @@ -395,7 +433,7 @@ SSHEOF chmod +x "$mock_bin/python3" printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open cursor host 2>&1 assert_success assert_output --partial "IDE:" assert_output --partial "SSH" @@ -431,7 +469,7 @@ SSHEOF chmod +x "$mock_bin/ssh" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" - PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" host 2>&1 + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none host 2>&1 refute_output --partial "compose up" rm -rf "$mock_bin" } From 30c029f50893f0771f85f407ac780599e31cd20e Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sat, 7 Mar 2026 13:55:52 +0100 Subject: [PATCH 049/215] fix(ci): allow dotfile paths and doc filenames in agent blocklist The PR title check was false-positiving on legitimate references to .cursor/, .claude/ directories and CLAUDE.md. Add allow_patterns to agent-blocklist.toml that strip path-like occurrences before matching. Co-Authored-By: Claude Opus 4.6 Refs: #231 --- .claude/settings.local.json | 7 +++++++ .github/agent-blocklist.toml | 7 +++++++ assets/workspace/.github/agent-blocklist.toml | 7 +++++++ packages/vig-utils/src/vig_utils/agent_blocklist.py | 6 +++++- 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..c6e09c32 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr:*)" + ] + } +} diff --git a/.github/agent-blocklist.toml b/.github/agent-blocklist.toml index 53256f15..bf88a2b7 100644 --- a/.github/agent-blocklist.toml +++ b/.github/agent-blocklist.toml @@ -20,3 +20,10 @@ names = [ # Agent email patterns (substring match, case-insensitive) emails = ["cursoragent@cursor.com", "noreply@cursor.com", "github-actions[bot]"] + +# Patterns that legitimately contain blocked names (regex, stripped before checking) +# These are removed from content before name/email matching runs. +allow_patterns = [ + "\\.[a-zA-Z][\\w-]*/[\\w./-]*", # dotfile paths: .cursor/skills/, .claude/commands/ + "[A-Z]+\\.md", # doc files: CLAUDE.md +] diff --git a/assets/workspace/.github/agent-blocklist.toml b/assets/workspace/.github/agent-blocklist.toml index 53256f15..bf88a2b7 100644 --- a/assets/workspace/.github/agent-blocklist.toml +++ b/assets/workspace/.github/agent-blocklist.toml @@ -20,3 +20,10 @@ names = [ # Agent email patterns (substring match, case-insensitive) emails = ["cursoragent@cursor.com", "noreply@cursor.com", "github-actions[bot]"] + +# Patterns that legitimately contain blocked names (regex, stripped before checking) +# These are removed from content before name/email matching runs. +allow_patterns = [ + "\\.[a-zA-Z][\\w-]*/[\\w./-]*", # dotfile paths: .cursor/skills/, .claude/commands/ + "[A-Z]+\\.md", # doc files: CLAUDE.md +] diff --git a/packages/vig-utils/src/vig_utils/agent_blocklist.py b/packages/vig-utils/src/vig_utils/agent_blocklist.py index 8bb9c4a0..f0961fa2 100644 --- a/packages/vig-utils/src/vig_utils/agent_blocklist.py +++ b/packages/vig-utils/src/vig_utils/agent_blocklist.py @@ -21,6 +21,7 @@ def load_blocklist(path: Path) -> dict: "trailers": [re.compile(p) for p in patterns.get("trailers", [])], "names": [s.lower() for s in patterns.get("names", [])], "emails": [s.lower() for s in patterns.get("emails", [])], + "allow_patterns": [re.compile(p) for p in patterns.get("allow_patterns", [])], } @@ -34,7 +35,10 @@ def contains_agent_fingerprint( Returns the first matching pattern string if found, else None. """ - content_lower = content.lower() + stripped = content + for pattern in blocklist.get("allow_patterns", []): + stripped = pattern.sub("", stripped) + content_lower = stripped.lower() for name in blocklist.get("names", []): if name in content_lower: return name From c4c03f39f23ad554cb3228a9c7ed0fc10a2b3578 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 09:18:22 +0100 Subject: [PATCH 050/215] feat(remote): add container lifecycle execution and compose file parsing - prepare_remote() writes socket path and stubs local compose override - read_compose_files() / compose_cmd_with_files() parse devcontainer.json - run_container_lifecycle() runs post-create/post-start inside container - Socket detection in preflight, CONTAINER_FRESH tracking - Fix Tailscale tag from tag:devcontainer to tag:devc Refs: #70 --- CHANGELOG.md | 4 + assets/workspace/scripts/devc-remote.sh | 132 ++++++++++++++++++++++-- scripts/devc-remote.sh | 132 ++++++++++++++++++++++-- tests/bats/devc-remote.bats | 42 +++++++- 4 files changed, 285 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f24bd7..b8f297fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Remote devcontainer lifecycle execution** ([#70](https://github.com/vig-os/devcontainer/issues/70)) + - `run_container_lifecycle()` runs post-create/post-start scripts inside container after compose up + - `prepare_remote()` writes container socket path and stubs local compose override + - `read_compose_files()` / `compose_cmd_with_files()` parse devcontainer.json for compose file list - **Opt-in Tailscale SSH support for devcontainer** ([#208](https://github.com/vig-os/devcontainer/issues/208)) - New `setup-tailscale.sh` script with `install` and `start` subcommands - Hooks into `post-create.sh` (install) and `post-start.sh` (start) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 6727371d..7d8b4163 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -229,7 +229,7 @@ inject_tailscale_key() { "create": { "reusable": false, "ephemeral": true, - "tags": ["tag:devcontainer"] + "tags": ["tag:devc"] } } } @@ -332,6 +332,14 @@ if [ "$(uname -s)" = "Darwin" ]; then else echo "OS_TYPE=linux" fi +# Detect container socket path +if [ -S /var/run/docker.sock ]; then + echo "SOCKET_PATH=/var/run/docker.sock" +elif [ -S "/run/user/$(id -u)/podman/podman.sock" ]; then + echo "SOCKET_PATH=/run/user/$(id -u)/podman/podman.sock" +else + echo "SOCKET_PATH=" +fi REMOTEEOF ) @@ -344,6 +352,7 @@ REMOTEEOF DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; OS_TYPE) OS_TYPE="${BASH_REMATCH[2]}" ;; + SOCKET_PATH) SOCKET_PATH="${BASH_REMATCH[2]}" ;; esac done <<< "$preflight_output" @@ -376,24 +385,122 @@ REMOTEEOF fi } -remote_compose_up() { - local ps_output state health +prepare_remote() { + local devc_dir="$REMOTE_PATH/.devcontainer" + + # Write container socket path to .env for compose interpolation + if [[ -n "${SOCKET_PATH:-}" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "echo 'CONTAINER_SOCKET_PATH=$SOCKET_PATH' > $devc_dir/.env" + log_info "Container socket: $SOCKET_PATH" + fi + + # Create stub docker-compose.local.yaml if missing + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "test -f $devc_dir/docker-compose.local.yaml || echo -e '---\nservices: {}' > $devc_dir/docker-compose.local.yaml" +} + +read_compose_files() { + # Read dockerComposeFile array from devcontainer.json on remote host + local raw + # shellcheck disable=SC2029 # shellcheck disable=SC2029 - ps_output=$(ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD ps --format json 2>/dev/null" || true) - state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4) - health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4) + raw=$(ssh "$SSH_HOST" \ + "python3 -c \" +import json, os, sys +path = os.path.expanduser('${REMOTE_PATH}/.devcontainer/devcontainer.json') +with open(path) as f: + data = json.load(f) +files = data.get('dockerComposeFile', ['docker-compose.yml']) +if isinstance(files, str): + files = [files] +for f in files: + print(f) +\" 2>/dev/null" || echo "") + if [[ -z "$raw" ]]; then + echo "docker-compose.yml" + return + fi + echo "$raw" +} + +compose_cmd_with_files() { + # Build compose command with -f flags for each compose file + local cmd="$COMPOSE_CMD" + local file + while IFS= read -r file; do + [[ -n "$file" ]] && cmd="$cmd -f $file" + done < <(read_compose_files) + echo "$cmd" +} + +remote_compose_up() { + local ps_output state health compose_full + compose_full=$(compose_cmd_with_files) + local devc_dir="$REMOTE_PATH/.devcontainer" - if [[ "$state" == "running" && "${health:-}" == "healthy" ]]; then - log_success "Devcontainer already running on $SSH_HOST. Opening..." + # shellcheck disable=SC2029 + ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + # shellcheck disable=SC2034 + health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + + if [[ "$state" == "running" ]]; then + log_success "Devcontainer already running on $SSH_HOST" + CONTAINER_FRESH=0 else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d"; then + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then log_error "Failed to start devcontainer on $SSH_HOST." - log_error "Debug with: ssh $SSH_HOST 'cd $REMOTE_PATH && $COMPOSE_CMD logs'" + log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" exit 1 fi sleep 2 + CONTAINER_FRESH=1 + fi +} + +run_container_lifecycle() { + local compose_full devc_dir workspace_folder scripts_dir + compose_full=$(compose_cmd_with_files) + devc_dir="$REMOTE_PATH/.devcontainer" + workspace_folder=$(read_workspace_folder) + scripts_dir="$workspace_folder/.devcontainer/scripts" + + local has_scripts + # shellcheck disable=SC2029 + has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") + + if [[ "$has_scripts" != "1" ]]; then + log_info "No lifecycle scripts found at $scripts_dir — skipping" + return 0 + fi + + # post-create: one-time setup (git, precommit, tailscale install, deps) + if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then + log_info "Running post-create lifecycle (first start)..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + /bin/bash $scripts_dir/post-create.sh" 2>&1 || { + log_warning "post-create.sh failed (non-fatal, container still running)" + } + fi + + # post-start: every-start setup (socket perms, deps sync, tailscale start) + local has_post_start + # shellcheck disable=SC2029 + has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") + + if [[ "$has_post_start" == "1" ]]; then + log_info "Running post-start lifecycle..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + /bin/bash $scripts_dir/post-start.sh" 2>&1 || { + log_warning "post-start.sh failed (non-fatal, container still running)" + } fi } @@ -488,6 +595,7 @@ for peer in (data.get('Peer') or {}).values(): main() { parse_args "$@" + CONTAINER_FRESH=0 detect_editor_cli case "$OPEN_MODE" in @@ -504,10 +612,14 @@ main() { remote_preflight log_success "Pre-flight OK (runtime: $RUNTIME)" + prepare_remote + inject_tailscale_key remote_compose_up + run_container_lifecycle + case "$OPEN_MODE" in cursor|code) open_editor diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 6727371d..7d8b4163 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -229,7 +229,7 @@ inject_tailscale_key() { "create": { "reusable": false, "ephemeral": true, - "tags": ["tag:devcontainer"] + "tags": ["tag:devc"] } } } @@ -332,6 +332,14 @@ if [ "$(uname -s)" = "Darwin" ]; then else echo "OS_TYPE=linux" fi +# Detect container socket path +if [ -S /var/run/docker.sock ]; then + echo "SOCKET_PATH=/var/run/docker.sock" +elif [ -S "/run/user/$(id -u)/podman/podman.sock" ]; then + echo "SOCKET_PATH=/run/user/$(id -u)/podman/podman.sock" +else + echo "SOCKET_PATH=" +fi REMOTEEOF ) @@ -344,6 +352,7 @@ REMOTEEOF DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; OS_TYPE) OS_TYPE="${BASH_REMATCH[2]}" ;; + SOCKET_PATH) SOCKET_PATH="${BASH_REMATCH[2]}" ;; esac done <<< "$preflight_output" @@ -376,24 +385,122 @@ REMOTEEOF fi } -remote_compose_up() { - local ps_output state health +prepare_remote() { + local devc_dir="$REMOTE_PATH/.devcontainer" + + # Write container socket path to .env for compose interpolation + if [[ -n "${SOCKET_PATH:-}" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "echo 'CONTAINER_SOCKET_PATH=$SOCKET_PATH' > $devc_dir/.env" + log_info "Container socket: $SOCKET_PATH" + fi + + # Create stub docker-compose.local.yaml if missing + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "test -f $devc_dir/docker-compose.local.yaml || echo -e '---\nservices: {}' > $devc_dir/docker-compose.local.yaml" +} + +read_compose_files() { + # Read dockerComposeFile array from devcontainer.json on remote host + local raw + # shellcheck disable=SC2029 # shellcheck disable=SC2029 - ps_output=$(ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD ps --format json 2>/dev/null" || true) - state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4) - health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4) + raw=$(ssh "$SSH_HOST" \ + "python3 -c \" +import json, os, sys +path = os.path.expanduser('${REMOTE_PATH}/.devcontainer/devcontainer.json') +with open(path) as f: + data = json.load(f) +files = data.get('dockerComposeFile', ['docker-compose.yml']) +if isinstance(files, str): + files = [files] +for f in files: + print(f) +\" 2>/dev/null" || echo "") + if [[ -z "$raw" ]]; then + echo "docker-compose.yml" + return + fi + echo "$raw" +} + +compose_cmd_with_files() { + # Build compose command with -f flags for each compose file + local cmd="$COMPOSE_CMD" + local file + while IFS= read -r file; do + [[ -n "$file" ]] && cmd="$cmd -f $file" + done < <(read_compose_files) + echo "$cmd" +} + +remote_compose_up() { + local ps_output state health compose_full + compose_full=$(compose_cmd_with_files) + local devc_dir="$REMOTE_PATH/.devcontainer" - if [[ "$state" == "running" && "${health:-}" == "healthy" ]]; then - log_success "Devcontainer already running on $SSH_HOST. Opening..." + # shellcheck disable=SC2029 + ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + # shellcheck disable=SC2034 + health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + + if [[ "$state" == "running" ]]; then + log_success "Devcontainer already running on $SSH_HOST" + CONTAINER_FRESH=0 else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "cd $REMOTE_PATH && $COMPOSE_CMD up -d"; then + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then log_error "Failed to start devcontainer on $SSH_HOST." - log_error "Debug with: ssh $SSH_HOST 'cd $REMOTE_PATH && $COMPOSE_CMD logs'" + log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" exit 1 fi sleep 2 + CONTAINER_FRESH=1 + fi +} + +run_container_lifecycle() { + local compose_full devc_dir workspace_folder scripts_dir + compose_full=$(compose_cmd_with_files) + devc_dir="$REMOTE_PATH/.devcontainer" + workspace_folder=$(read_workspace_folder) + scripts_dir="$workspace_folder/.devcontainer/scripts" + + local has_scripts + # shellcheck disable=SC2029 + has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") + + if [[ "$has_scripts" != "1" ]]; then + log_info "No lifecycle scripts found at $scripts_dir — skipping" + return 0 + fi + + # post-create: one-time setup (git, precommit, tailscale install, deps) + if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then + log_info "Running post-create lifecycle (first start)..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + /bin/bash $scripts_dir/post-create.sh" 2>&1 || { + log_warning "post-create.sh failed (non-fatal, container still running)" + } + fi + + # post-start: every-start setup (socket perms, deps sync, tailscale start) + local has_post_start + # shellcheck disable=SC2029 + has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") + + if [[ "$has_post_start" == "1" ]]; then + log_info "Running post-start lifecycle..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + /bin/bash $scripts_dir/post-start.sh" 2>&1 || { + log_warning "post-start.sh failed (non-fatal, container still running)" + } fi } @@ -488,6 +595,7 @@ for peer in (data.get('Peer') or {}).values(): main() { parse_args "$@" + CONTAINER_FRESH=0 detect_editor_cli case "$OPEN_MODE" in @@ -504,10 +612,14 @@ main() { remote_preflight log_success "Pre-flight OK (runtime: $RUNTIME)" + prepare_remote + inject_tailscale_key remote_compose_up + run_container_lifecycle + case "$OPEN_MODE" in cursor|code) open_editor diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 09834d41..092b27b2 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -281,7 +281,7 @@ if [ "\$count" = "1" ]; then echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "4" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 @@ -390,7 +390,7 @@ if [ "\$count" = "1" ]; then echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "4" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 @@ -423,7 +423,7 @@ if [ "\$count" = "1" ]; then echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "4" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 @@ -443,12 +443,44 @@ SSHEOF # ── remote_compose_up ──────────────────────────────────────────────────────── +# ── lifecycle functions (Unit 2) ────────────────────────────────────────────── + +@test "run_container_lifecycle defines function" { + run grep 'run_container_lifecycle()' "$DEVC_REMOTE" + assert_success +} + +@test "run_container_lifecycle is called in main" { + run grep 'run_container_lifecycle' "$DEVC_REMOTE" + assert_success + local count + count=$(grep -c 'run_container_lifecycle' "$DEVC_REMOTE") + [ "$count" -ge 2 ] +} + +@test "prepare_remote defines function" { + run grep 'prepare_remote()' "$DEVC_REMOTE" + assert_success +} + +@test "read_compose_files defines function" { + run grep 'read_compose_files()' "$DEVC_REMOTE" + assert_success +} + +@test "compose_cmd_with_files defines function" { + run grep 'compose_cmd_with_files()' "$DEVC_REMOTE" + assert_success +} + +# ── remote_compose_up ──────────────────────────────────────────────────────── + @test "remote_compose_up skips when container running and healthy" { local mock_bin mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh -# check_ssh=0, preflight=1, compose_ps=2 +# check_ssh=0, preflight=1, prepare_remote=2+3, compose_ps=4 counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" @@ -459,7 +491,7 @@ if [ "\$count" = "1" ]; then echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "4" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' else : From d530de61d7712c21535f57a5aa8cefeb9ce3f537 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 16:40:45 +0100 Subject: [PATCH 051/215] feat(remote): add opt-in Claude Code CLI with subscription OAuth auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup-claude.sh with install/start subcommands, gated by CLAUDE_CODE_OAUTH_TOKEN - inject_claude_auth() in devc-remote.sh forwards local OAuth token to remote compose - Uses `claude setup-token` flow (sk-ant-oat01-..., valid 1 year) — no API key needed - Hooks into post-create.sh (install) and post-start.sh (start) - Commented example in docker-compose.local.yaml Refs: #70 --- CHANGELOG.md | 7 + .../.devcontainer/docker-compose.local.yaml | 9 ++ .../.devcontainer/scripts/post-create.sh | 3 + .../.devcontainer/scripts/post-start.sh | 3 + .../.devcontainer/scripts/setup-claude.sh | 147 ++++++++++++++++++ assets/workspace/scripts/devc-remote.sh | 60 +++++++ scripts/devc-remote.sh | 60 +++++++ tests/bats/devc-remote.bats | 50 ++++++ tests/bats/setup-claude.bats | 71 +++++++++ 9 files changed, 410 insertions(+) create mode 100755 assets/workspace/.devcontainer/scripts/setup-claude.sh create mode 100644 tests/bats/setup-claude.bats diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f297fb..20fb4d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Opt-in Claude Code CLI support for devcontainer** ([#70](https://github.com/vig-os/devcontainer/issues/70)) + - New `setup-claude.sh` script with `install` and `start` subcommands + - Hooks into `post-create.sh` (install) and `post-start.sh` (start) + - Silent no-op when `CLAUDE_CODE_OAUTH_TOKEN` is unset — zero impact on existing users + - Uses subscription auth via `claude setup-token` (no API key needed) + - `inject_claude_auth()` in `devc-remote.sh` forwards local OAuth token to remote compose + - Commented example in `docker-compose.local.yaml` for quick setup - **Remote devcontainer lifecycle execution** ([#70](https://github.com/vig-os/devcontainer/issues/70)) - `run_container_lifecycle()` runs post-create/post-start scripts inside container after compose up - `prepare_remote()` writes container socket path and stubs local compose override diff --git a/assets/workspace/.devcontainer/docker-compose.local.yaml b/assets/workspace/.devcontainer/docker-compose.local.yaml index 96c41293..be3ea648 100644 --- a/assets/workspace/.devcontainer/docker-compose.local.yaml +++ b/assets/workspace/.devcontainer/docker-compose.local.yaml @@ -32,4 +32,13 @@ # - TAILSCALE_AUTHKEY=tskey-auth-XXXX # - TAILSCALE_HOSTNAME=myproject-devc-mybox # optional override +# Optional: Claude Code CLI (subscription OAuth token) +# Run `claude setup-token` on your host to generate a long-lived token (1 year). +# devc-remote.sh auto-injects from your local CLAUDE_CODE_OAUTH_TOKEN env var. +# +# services: +# devcontainer: +# environment: +# - CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-XXXX + services: {} diff --git a/assets/workspace/.devcontainer/scripts/post-create.sh b/assets/workspace/.devcontainer/scripts/post-create.sh index 2fe01696..67ad71e6 100644 --- a/assets/workspace/.devcontainer/scripts/post-create.sh +++ b/assets/workspace/.devcontainer/scripts/post-create.sh @@ -38,6 +38,9 @@ just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" syn # Tailscale SSH (opt-in: no-op when TAILSCALE_AUTHKEY is unset) "$SCRIPT_DIR/setup-tailscale.sh" install +# Claude Code CLI (opt-in: no-op when CLAUDE_CODE_OAUTH_TOKEN is unset) +"$SCRIPT_DIR/setup-claude.sh" install + # User specific setup # Add your custom setup commands here to install any dependencies or tools needed for your project diff --git a/assets/workspace/.devcontainer/scripts/post-start.sh b/assets/workspace/.devcontainer/scripts/post-start.sh index 3ac4e724..4e668ef2 100755 --- a/assets/workspace/.devcontainer/scripts/post-start.sh +++ b/assets/workspace/.devcontainer/scripts/post-start.sh @@ -24,4 +24,7 @@ just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" syn # Tailscale SSH (opt-in: no-op when TAILSCALE_AUTHKEY is unset) "$SCRIPT_DIR/setup-tailscale.sh" start +# Claude Code CLI (opt-in: no-op when CLAUDE_CODE_OAUTH_TOKEN is unset) +"$SCRIPT_DIR/setup-claude.sh" start + echo "Post-start setup complete" diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh new file mode 100755 index 00000000..9455a006 --- /dev/null +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Claude Code CLI setup for devcontainer — opt-in via CLAUDE_CODE_OAUTH_TOKEN env var. +# +# Subcommands: +# install — install Claude Code CLI + create non-root user (post-create.sh) +# start — ensure workspace access for claude user (post-start.sh) +# +# Both subcommands are silent no-ops when CLAUDE_CODE_OAUTH_TOKEN is unset or empty. +# +# Auth flow: +# 1. User runs `claude setup-token` on host (one-time, opens browser) +# 2. Token (sk-ant-oat01-..., valid 1 year) is injected into container env +# 3. Claude Code uses CLAUDE_CODE_OAUTH_TOKEN — no login needed in container +# +# Why a dedicated user? +# Claude Code refuses --dangerously-skip-permissions under root for security. +# The devcontainer runs as root, so we create a non-root 'claude' user. +# The `claude` command is replaced with a wrapper that, when run as root, +# auto-switches to the claude user via runuser. This means: +# - `claude` as root → switches to claude user + --dangerously-skip-permissions +# - `claude` as claude user → runs directly +# - `claude-bin` → the real npm-installed binary (escape hatch) + +set -euo pipefail + +CLAUDE_USER="claude" +CLAUDE_HOME="/home/$CLAUDE_USER" + +# ── helpers ────────────────────────────────────────────────────────────────── + +require_token() { + if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then + echo "Claude: CLAUDE_CODE_OAUTH_TOKEN not set, skipping." + return 1 + fi + return 0 +} + +# ── subcommands ────────────────────────────────────────────────────────────── + +cmd_install() { + require_token || return 0 + + # Install the CLI if not present (check for real binary or wrapper) + if command -v claude-bin &>/dev/null; then + echo "Claude: already installed, skipping install." + elif command -v claude &>/dev/null && ! grep -q 'claude-wrapper' "$(command -v claude)" 2>/dev/null; then + echo "Claude: already installed, skipping install." + else + echo "Claude: installing Claude Code CLI..." + + # Ensure Node.js LTS is available (npm required for install) + if ! command -v npm &>/dev/null; then + echo "Claude: installing Node.js LTS..." + curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - + apt-get install -y nodejs + fi + + npm install -g @anthropic-ai/claude-code + echo "Claude: CLI installed." + fi + + # Create non-root user for --dangerously-skip-permissions + if ! id "$CLAUDE_USER" &>/dev/null; then + echo "Claude: creating user '$CLAUDE_USER'..." + useradd -m -d "$CLAUDE_HOME" -s /bin/bash "$CLAUDE_USER" + fi + + # Grant workspace access (read/write for project files) + if command -v setfacl &>/dev/null; then + setfacl -R -m "u:${CLAUDE_USER}:rwX" /workspace 2>/dev/null || true + setfacl -R -d -m "u:${CLAUDE_USER}:rwX" /workspace 2>/dev/null || true + else + chown -R "root:${CLAUDE_USER}" /workspace 2>/dev/null || true + chmod -R g+rwX /workspace 2>/dev/null || true + fi + + # Replace `claude` with a wrapper that auto-switches user when root. + # Move the real binary out of the way first. + local real_claude + real_claude="$(command -v claude 2>/dev/null || true)" + if [ -n "$real_claude" ] && ! grep -q 'claude-wrapper' "$real_claude" 2>/dev/null; then + mv "$real_claude" "${real_claude}-bin" + # Create wrapper at the original path + # Tag: claude-wrapper (used to detect if wrapper is already installed) + cat > "$real_claude" << 'WRAPPER' +#!/bin/bash +# claude-wrapper: auto-switch to claude user when running as root. +# The real binary lives at claude-bin (same directory). +REAL="$(dirname "$0")/claude-bin" +if [ "$(id -u)" = "0" ]; then + exec runuser -w CLAUDE_CODE_OAUTH_TOKEN -u claude -- "$REAL" --dangerously-skip-permissions "$@" +fi +exec "$REAL" "$@" +WRAPPER + chmod +x "$real_claude" + fi + + # Configure claude user: auto-cd to workspace project, source token + cat > "$CLAUDE_HOME/.bashrc" << 'BASHRC' +# Auto-cd to workspace project +WS_PROJECT=$(ls -d /workspace/*/ 2>/dev/null | head -1) +if [ -n "$WS_PROJECT" ]; then + cd "$WS_PROJECT" || true +fi + +# Source OAuth token from container environment if not already set +if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -f /proc/1/environ ]; then + CLAUDE_CODE_OAUTH_TOKEN=$(tr '\0' '\n' < /proc/1/environ 2>/dev/null | sed -n 's/^CLAUDE_CODE_OAUTH_TOKEN=//p') + export CLAUDE_CODE_OAUTH_TOKEN +fi + +export PATH="/usr/local/bin:/usr/bin:/bin:/root/.cargo/bin:$PATH" +BASHRC + chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.bashrc" + + echo "Claude: install complete. 'claude' auto-switches to non-root user when run as root." +} + +cmd_start() { + require_token || return 0 + + # Refresh workspace access (volumes may have been recreated) + if id "$CLAUDE_USER" &>/dev/null; then + if command -v setfacl &>/dev/null; then + setfacl -R -m "u:${CLAUDE_USER}:rwX" /workspace 2>/dev/null || true + setfacl -R -d -m "u:${CLAUDE_USER}:rwX" /workspace 2>/dev/null || true + else + chown -R "root:${CLAUDE_USER}" /workspace 2>/dev/null || true + chmod -R g+rwX /workspace 2>/dev/null || true + fi + fi + + echo "Claude: OAuth token present, CLI ready." +} + +# ── main ───────────────────────────────────────────────────────────────────── + +case "${1:-}" in + install) cmd_install ;; + start) cmd_start ;; + *) + echo "Usage: $(basename "$0") {install|start}" >&2 + exit 1 + ;; +esac diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 7d8b4163..f56ad42f 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -290,6 +290,65 @@ INJECT_EOF log_success "Tailscale: ephemeral auth key injected into remote compose" } +# ═══════════════════════════════════════════════════════════════════════════════ +# CLAUDE CODE AUTH INJECTION (opt-in via CLAUDE_CODE_OAUTH_TOKEN) +# ═══════════════════════════════════════════════════════════════════════════════ + +inject_claude_auth() { + # Skip if no OAuth token in local environment + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + return 0 + fi + + # Check if token already set on remote + # shellcheck disable=SC2029 + if ssh "$SSH_HOST" "grep -q 'CLAUDE_CODE_OAUTH_TOKEN' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + log_info "Claude: OAuth token already configured on remote" + return 0 + fi + + log_info "Claude: injecting OAuth token into remote compose..." + + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$CLAUDE_CODE_OAUTH_TOKEN" << 'INJECT_EOF' +REPO_PATH="$1" +TOKEN="$2" +LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +elif grep -q 'services: {}' "$LOCAL_YAML"; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +elif grep -q 'CLAUDE_CODE_OAUTH_TOKEN' "$LOCAL_YAML"; then + sed -i "s|CLAUDE_CODE_OAUTH_TOKEN=.*|CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}|" "$LOCAL_YAML" +elif grep -q 'environment:' "$LOCAL_YAML"; then + sed -i "/environment:/a\\ - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}" "$LOCAL_YAML" +elif grep -q 'devcontainer:' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ environment:\\n - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}" "$LOCAL_YAML" +else + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +fi +INJECT_EOF + + log_success "Claude: OAuth token injected into remote compose" +} + check_ssh() { if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" true 2>/dev/null; then log_error "Cannot connect to $SSH_HOST. Check your SSH config and network." @@ -615,6 +674,7 @@ main() { prepare_remote inject_tailscale_key + inject_claude_auth remote_compose_up diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 7d8b4163..f56ad42f 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -290,6 +290,65 @@ INJECT_EOF log_success "Tailscale: ephemeral auth key injected into remote compose" } +# ═══════════════════════════════════════════════════════════════════════════════ +# CLAUDE CODE AUTH INJECTION (opt-in via CLAUDE_CODE_OAUTH_TOKEN) +# ═══════════════════════════════════════════════════════════════════════════════ + +inject_claude_auth() { + # Skip if no OAuth token in local environment + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + return 0 + fi + + # Check if token already set on remote + # shellcheck disable=SC2029 + if ssh "$SSH_HOST" "grep -q 'CLAUDE_CODE_OAUTH_TOKEN' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + log_info "Claude: OAuth token already configured on remote" + return 0 + fi + + log_info "Claude: injecting OAuth token into remote compose..." + + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$CLAUDE_CODE_OAUTH_TOKEN" << 'INJECT_EOF' +REPO_PATH="$1" +TOKEN="$2" +LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +elif grep -q 'services: {}' "$LOCAL_YAML"; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +elif grep -q 'CLAUDE_CODE_OAUTH_TOKEN' "$LOCAL_YAML"; then + sed -i "s|CLAUDE_CODE_OAUTH_TOKEN=.*|CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}|" "$LOCAL_YAML" +elif grep -q 'environment:' "$LOCAL_YAML"; then + sed -i "/environment:/a\\ - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}" "$LOCAL_YAML" +elif grep -q 'devcontainer:' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ environment:\\n - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}" "$LOCAL_YAML" +else + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +fi +INJECT_EOF + + log_success "Claude: OAuth token injected into remote compose" +} + check_ssh() { if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" true 2>/dev/null; then log_error "Cannot connect to $SSH_HOST. Check your SSH config and network." @@ -615,6 +674,7 @@ main() { prepare_remote inject_tailscale_key + inject_claude_auth remote_compose_up diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 092b27b2..e1daf2e0 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -252,6 +252,56 @@ setup() { assert_success } +# ── inject_claude_auth ───────────────────────────────────────────────────── + +@test "inject_claude_auth defines function" { + run grep 'inject_claude_auth()' "$DEVC_REMOTE" + assert_success +} + +@test "inject_claude_auth is called in main" { + run grep 'inject_claude_auth' "$DEVC_REMOTE" + assert_success + # Should appear at least twice: definition + call + local count + count=$(grep -c 'inject_claude_auth' "$DEVC_REMOTE") + [ "$count" -ge 2 ] +} + +@test "inject_claude_auth skips when CLAUDE_CODE_OAUTH_TOKEN unset" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "2" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" + chmod +x "$mock_bin/python3" + printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + # Run WITHOUT CLAUDE_CODE_OAUTH_TOKEN — inject_claude_auth should not mention Claude + PATH="$mock_bin:$PATH" run env -u CLAUDE_CODE_OAUTH_TOKEN "$DEVC_REMOTE" --open none host 2>&1 + assert_success + refute_output --partial "Claude" + rm -rf "$mock_bin" +} + +# ── inject_tailscale_key ──────────────────────────────────────────────────── + @test "inject_tailscale_key defines function" { run grep 'inject_tailscale_key()' "$DEVC_REMOTE" assert_success diff --git a/tests/bats/setup-claude.bats b/tests/bats/setup-claude.bats new file mode 100644 index 00000000..85e73bbf --- /dev/null +++ b/tests/bats/setup-claude.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# BATS tests for setup-claude.sh +# +# Tests script structure, opt-in behavior, and subcommand handling. +# No live install tests — we only verify the opt-in gate logic. + +setup() { + load test_helper + SETUP_CLAUDE="$PROJECT_ROOT/assets/workspace/.devcontainer/scripts/setup-claude.sh" +} + +# ── script structure ────────────────────────────────────────────────────────── + +@test "setup-claude.sh is executable" { + run test -x "$SETUP_CLAUDE" + assert_success +} + +@test "setup-claude.sh has shebang" { + run head -1 "$SETUP_CLAUDE" + assert_output "#!/bin/bash" +} + +@test "setup-claude.sh uses strict error handling (set -euo pipefail)" { + run grep 'set -euo pipefail' "$SETUP_CLAUDE" + assert_success +} + +# ── no subcommand / invalid subcommand ──────────────────────────────────────── + +@test "setup-claude.sh with no arguments exits with error" { + run "$SETUP_CLAUDE" + assert_failure + assert_output --partial "Usage:" +} + +@test "setup-claude.sh with invalid subcommand exits with error" { + run "$SETUP_CLAUDE" bogus + assert_failure + assert_output --partial "Usage:" +} + +# ── install subcommand: opt-in gate ────────────────────────────────────────── + +@test "install is a no-op when CLAUDE_CODE_OAUTH_TOKEN is unset" { + unset CLAUDE_CODE_OAUTH_TOKEN + run "$SETUP_CLAUDE" install + assert_success + assert_output --partial "CLAUDE_CODE_OAUTH_TOKEN not set" +} + +@test "install is a no-op when CLAUDE_CODE_OAUTH_TOKEN is empty" { + CLAUDE_CODE_OAUTH_TOKEN="" run "$SETUP_CLAUDE" install + assert_success + assert_output --partial "CLAUDE_CODE_OAUTH_TOKEN not set" +} + +# ── start subcommand: opt-in gate ──────────────────────────────────────────── + +@test "start is a no-op when CLAUDE_CODE_OAUTH_TOKEN is unset" { + unset CLAUDE_CODE_OAUTH_TOKEN + run "$SETUP_CLAUDE" start + assert_success + assert_output --partial "CLAUDE_CODE_OAUTH_TOKEN not set" +} + +@test "start is a no-op when CLAUDE_CODE_OAUTH_TOKEN is empty" { + CLAUDE_CODE_OAUTH_TOKEN="" run "$SETUP_CLAUDE" start + assert_success + assert_output --partial "CLAUDE_CODE_OAUTH_TOKEN not set" +} From 0b3f5bca1442b884f8ce0471bd1fbd3a4bb1737d Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 16:52:24 +0100 Subject: [PATCH 052/215] chore: add .worktrees/ to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d1d3fb76..84c85ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -223,5 +223,8 @@ __marimo__/ # Pre-commit cache .pre-commit-cache/ +# Worktrees +.worktrees/ + # Bats node_modules/ From 26d3ea7fd6b0fdd9eb9ff3966b483e697912d837 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 16:53:03 +0100 Subject: [PATCH 053/215] test(remote): add failing tests for --bootstrap flag and bootstrap flow Refs: #235 --- tests/bats/devc-remote.bats | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 09834d41..ae28de91 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -443,6 +443,186 @@ SSHEOF # ── remote_compose_up ──────────────────────────────────────────────────────── +# ── --bootstrap flag parsing ────────────────────────────────────────────── + +@test "--bootstrap requires ssh-host argument" { + run "$DEVC_REMOTE" --bootstrap + assert_failure + assert_output --partial "Missing required argument" +} + +@test "--bootstrap sets BOOTSTRAP_MODE" { + run grep 'BOOTSTRAP_MODE=' "$DEVC_REMOTE" + assert_success +} + +@test "--bootstrap defines bootstrap_remote function" { + run grep 'bootstrap_remote()' "$DEVC_REMOTE" + assert_success +} + +@test "--bootstrap with host runs bootstrap flow" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +# check_ssh +if [ "\$count" = "0" ]; then + exit 0 +fi +# bootstrap_check_config: config does not exist +if [ "\$count" = "1" ]; then + echo "CONFIG_EXISTS=0" + exit 0 +fi +# bootstrap_write_config +if [ "\$count" = "2" ]; then + exit 0 +fi +# bootstrap_forward_ghcr_auth +if [ "\$count" = "3" ]; then + exit 0 +fi +# bootstrap_clone_and_build +if [ "\$count" = "4" ]; then + exit 0 +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + # Provide scp mock (for GHCR auth forwarding) + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --bootstrap --yes myserver 2>&1 + assert_success + assert_output --partial "Bootstrap" + rm -rf "$mock_bin" +} + +@test "--bootstrap first-run creates config on remote" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +# check_ssh +if [ "\$count" = "0" ]; then exit 0; fi +# bootstrap_check_config: no config +if [ "\$count" = "1" ]; then + echo "CONFIG_EXISTS=0" + exit 0 +fi +# write config +if [ "\$count" = "2" ]; then exit 0; fi +# clone/build +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --bootstrap --yes myserver 2>&1 + assert_success + assert_output --partial "Config written" + rm -rf "$mock_bin" +} + +@test "--bootstrap re-run reads existing config without re-prompting" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +# check_ssh +if [ "\$count" = "0" ]; then exit 0; fi +# bootstrap_check_config: config exists +if [ "\$count" = "1" ]; then + echo "CONFIG_EXISTS=1" + echo "PROJECTS_DIR=~/Projects" + echo "DEVCONTAINER_REPO=vig-os/devcontainer" + echo "DEVCONTAINER_PATH=~/Projects/devcontainer" + echo "IMAGE_TAG=dev" + echo "REGISTRY=ghcr.io/vig-os/devcontainer" + exit 0 +fi +# pull + rebuild +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --bootstrap myserver 2>&1 + assert_success + assert_output --partial "existing, not modified" + rm -rf "$mock_bin" +} + +@test "--bootstrap forwards GHCR auth to remote" { + local mock_bin + mock_bin="$(mktemp -d)" + # Create fake local auth file + local fake_home + fake_home="$(mktemp -d)" + mkdir -p "$fake_home/.config/containers" + echo '{"auths":{}}' > "$fake_home/.config/containers/auth.json" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "0" ]; then exit 0; fi +if [ "\$count" = "1" ]; then + echo "CONFIG_EXISTS=0" + exit 0 +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + # scp mock that records what was copied + cat > "$mock_bin/scp" << SCPEOF +#!/bin/sh +echo "SCP_CALLED: \$@" >> "${mock_bin}/scp_log" +exit 0 +SCPEOF + chmod +x "$mock_bin/scp" + HOME="$fake_home" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --bootstrap --yes myserver 2>&1 + assert_success + assert_output --partial "GHCR auth" + rm -rf "$mock_bin" "$fake_home" +} + +@test "--bootstrap builds devcontainer image on remote" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "0" ]; then exit 0; fi +if [ "\$count" = "1" ]; then + echo "CONFIG_EXISTS=0" + exit 0 +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --bootstrap --yes myserver 2>&1 + assert_success + assert_output --partial "Building devcontainer image" + rm -rf "$mock_bin" +} + +# ── remote_compose_up ──────────────────────────────────────────────────────── + @test "remote_compose_up skips when container running and healthy" { local mock_bin mock_bin="$(mktemp -d)" From 88b077f5421b24e1b47f9a571a677eb662208924 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 16:55:41 +0100 Subject: [PATCH 054/215] feat(remote): add --bootstrap for one-time remote host setup with config file Adds devc-remote.sh --bootstrap that performs one-time remote setup: interactive config creation, GHCR auth forwarding, and devcontainer image build. Re-runs read existing config without re-prompting. Refs: #235 --- CHANGELOG.md | 7 + assets/workspace/scripts/devc-remote.sh | 188 +++++++++++++++++++++++- package-lock.json | 4 +- scripts/devc-remote.sh | 188 +++++++++++++++++++++++- 4 files changed, 384 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f24bd7..bca1dbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`devc-remote --bootstrap`: one-time remote host setup** ([#235](https://github.com/vig-os/devcontainer/issues/235)) + - Interactive first-run prompts for `projects_dir` with sensible defaults + - `--yes` flag skips prompts and uses defaults + - Creates `~/.config/devc-remote/config.yaml` on remote (human-editable) + - Forwards GHCR auth (podman/docker credentials or `GHCR_TOKEN`) to remote + - Clones devcontainer repo and builds image on remote + - Re-run reads existing config without re-prompting, pulls latest and rebuilds - **Opt-in Tailscale SSH support for devcontainer** ([#208](https://github.com/vig-os/devcontainer/issues/208)) - New `setup-tailscale.sh` script with `install` and `start` subcommands - Hooks into `post-create.sh` (install) and `post-start.sh` (start) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 6727371d..4320e87e 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -8,10 +8,12 @@ # # USAGE: # ./scripts/devc-remote.sh [options] [:] +# ./scripts/devc-remote.sh --bootstrap [--yes] # ./scripts/devc-remote.sh --help # # Options: -# --yes, -y Auto-accept prompts (reuse running containers) +# --bootstrap One-time remote host setup (config, GHCR auth, image build) +# --yes, -y Auto-accept prompts (use defaults without asking) # --open How to connect after compose up: # auto - detect IDE from $TERM_PROGRAM or CLI availability (default) # cursor - open Cursor via devcontainer protocol @@ -79,12 +81,17 @@ parse_args() { REMOTE_PATH="~" YES_MODE=0 OPEN_MODE="auto" + BOOTSTRAP_MODE=0 while [[ $# -gt 0 ]]; do case "$1" in --help|-h) show_help ;; + --bootstrap) + BOOTSTRAP_MODE=1 + shift + ;; --yes|-y) # shellcheck disable=SC2034 YES_MODE=1 @@ -482,6 +489,176 @@ for peer in (data.get('Peer') or {}).values(): return 1 } +# ═══════════════════════════════════════════════════════════════════════════════ +# BOOTSTRAP (one-time remote host setup) +# ═══════════════════════════════════════════════════════════════════════════════ + +bootstrap_check_config() { + # Check if config exists on remote, read values if so + local config_output + # shellcheck disable=SC2029 + config_output=$(ssh "$SSH_HOST" "bash -s" << 'CFGEOF' +CONFIG_DIR="$HOME/.config/devc-remote" +CONFIG_FILE="$CONFIG_DIR/config.yaml" +if [ -f "$CONFIG_FILE" ]; then + echo "CONFIG_EXISTS=1" + # Parse simple flat YAML (key: value) using sed + sed -n 's/^projects_dir: *//p' "$CONFIG_FILE" | while read -r v; do echo "PROJECTS_DIR=$v"; done + sed -n 's/^devcontainer_repo: *//p' "$CONFIG_FILE" | while read -r v; do echo "DEVCONTAINER_REPO=$v"; done + sed -n 's/^devcontainer_path: *//p' "$CONFIG_FILE" | while read -r v; do echo "DEVCONTAINER_PATH=$v"; done + sed -n 's/^image_tag: *//p' "$CONFIG_FILE" | while read -r v; do echo "IMAGE_TAG=$v"; done + sed -n 's/^registry: *//p' "$CONFIG_FILE" | while read -r v; do echo "REGISTRY=$v"; done +else + echo "CONFIG_EXISTS=0" +fi +CFGEOF + ) + + CONFIG_EXISTS=0 + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + CONFIG_EXISTS) CONFIG_EXISTS="${BASH_REMATCH[2]}" ;; + PROJECTS_DIR) BOOTSTRAP_PROJECTS_DIR="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_REPO) BOOTSTRAP_DEVC_REPO="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_PATH) BOOTSTRAP_DEVC_PATH="${BASH_REMATCH[2]}" ;; + IMAGE_TAG) BOOTSTRAP_IMAGE_TAG="${BASH_REMATCH[2]}" ;; + REGISTRY) BOOTSTRAP_REGISTRY="${BASH_REMATCH[2]}" ;; + esac + done <<< "$config_output" +} + +bootstrap_prompt_config() { + # Set defaults + BOOTSTRAP_PROJECTS_DIR="${BOOTSTRAP_PROJECTS_DIR:-~/Projects}" + BOOTSTRAP_DEVC_REPO="${BOOTSTRAP_DEVC_REPO:-vig-os/devcontainer}" + BOOTSTRAP_IMAGE_TAG="${BOOTSTRAP_IMAGE_TAG:-dev}" + BOOTSTRAP_REGISTRY="${BOOTSTRAP_REGISTRY:-ghcr.io/vig-os/devcontainer}" + + if [[ "$YES_MODE" == "0" ]]; then + log_info "No devc-remote config found on $SSH_HOST." + read -rp "Where should projects be cloned? [$BOOTSTRAP_PROJECTS_DIR]: " user_input + BOOTSTRAP_PROJECTS_DIR="${user_input:-$BOOTSTRAP_PROJECTS_DIR}" + fi + + # Derive devcontainer_path from projects_dir + BOOTSTRAP_DEVC_PATH="${BOOTSTRAP_PROJECTS_DIR}/devcontainer" +} + +bootstrap_write_config() { + # Write config file on remote + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$BOOTSTRAP_PROJECTS_DIR" "$BOOTSTRAP_DEVC_REPO" "$BOOTSTRAP_DEVC_PATH" "$BOOTSTRAP_IMAGE_TAG" "$BOOTSTRAP_REGISTRY" << 'WRITEEOF' +PROJECTS_DIR="$1" +DEVC_REPO="$2" +DEVC_PATH="$3" +IMAGE_TAG="$4" +REGISTRY="$5" +CONFIG_DIR="$HOME/.config/devc-remote" +CONFIG_FILE="$CONFIG_DIR/config.yaml" +mkdir -p "$CONFIG_DIR" +cat > "$CONFIG_FILE" << YAML +projects_dir: ${PROJECTS_DIR} +devcontainer_repo: ${DEVC_REPO} +devcontainer_path: ${DEVC_PATH} +image_tag: ${IMAGE_TAG} +registry: ${REGISTRY} +YAML +WRITEEOF + + log_success "Config written to ~/.config/devc-remote/config.yaml — edit to customize." +} + +bootstrap_forward_ghcr_auth() { + # Forward container registry credentials to remote + local local_auth="" + + # Check podman auth first, then docker + if [[ -f "${HOME}/.config/containers/auth.json" ]]; then + local_auth="${HOME}/.config/containers/auth.json" + elif [[ -f "${HOME}/.docker/config.json" ]]; then + local_auth="${HOME}/.docker/config.json" + elif [[ -n "${GHCR_TOKEN:-}" ]]; then + # Use token-based auth — create temp auth file + local tmp_auth + tmp_auth="$(mktemp)" + echo "{\"auths\":{\"ghcr.io\":{\"auth\":\"$(echo -n "token:${GHCR_TOKEN}" | base64)\"}}}" > "$tmp_auth" + local_auth="$tmp_auth" + fi + + if [[ -z "$local_auth" ]]; then + log_warning "GHCR auth: no local credentials found, skipping" + return 0 + fi + + # Ensure remote directories exist and copy auth file + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "mkdir -p ~/.config/containers ~/.docker" + scp -q "$local_auth" "$SSH_HOST:~/.config/containers/auth.json" + scp -q "$local_auth" "$SSH_HOST:~/.docker/config.json" + + # Clean up temp file if we created one + if [[ -n "${GHCR_TOKEN:-}" && -n "${tmp_auth:-}" ]]; then + rm -f "$tmp_auth" + fi + + log_success "GHCR auth forwarded to $SSH_HOST" +} + +bootstrap_clone_and_build() { + log_info "Building devcontainer image on $SSH_HOST..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$BOOTSTRAP_DEVC_REPO" "$BOOTSTRAP_DEVC_PATH" "$BOOTSTRAP_IMAGE_TAG" "$BOOTSTRAP_REGISTRY" << 'BUILDEOF' +DEVC_REPO="$1" +DEVC_PATH="$2" +IMAGE_TAG="$3" +REGISTRY="$4" + +# Expand ~ in DEVC_PATH +DEVC_PATH="${DEVC_PATH/#\~/$HOME}" + +if [ -d "$DEVC_PATH/.git" ]; then + echo "Repository exists, pulling latest..." + cd "$DEVC_PATH" && git pull +else + echo "Cloning $DEVC_REPO..." + # Expand ~ in parent dir + PARENT_DIR="$(dirname "$DEVC_PATH")" + mkdir -p "$PARENT_DIR" + cd "$PARENT_DIR" + git clone "https://github.com/${DEVC_REPO}.git" "$(basename "$DEVC_PATH")" + cd "$DEVC_PATH" +fi + +# Build the image +if [ -f "scripts/build.sh" ]; then + echo "Running scripts/build.sh..." + bash scripts/build.sh +else + echo "WARNING: scripts/build.sh not found in $DEVC_PATH" +fi +BUILDEOF + + log_success "Devcontainer image built on $SSH_HOST" +} + +bootstrap_remote() { + log_info "Bootstrap: checking remote config on $SSH_HOST..." + bootstrap_check_config + + if [[ "$CONFIG_EXISTS" == "1" ]]; then + log_info "Config: ~/.config/devc-remote/config.yaml (existing, not modified)" + else + bootstrap_prompt_config + bootstrap_write_config + fi + + bootstrap_forward_ghcr_auth + bootstrap_clone_and_build + + log_success "Bootstrap complete for $SSH_HOST" +} + # ═══════════════════════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════════════════════ @@ -489,6 +666,15 @@ for peer in (data.get('Peer') or {}).values(): main() { parse_args "$@" + # Bootstrap mode: one-time remote host setup + if [[ "$BOOTSTRAP_MODE" == "1" ]]; then + log_info "Checking SSH connectivity to $SSH_HOST..." + check_ssh + log_success "SSH connection OK" + bootstrap_remote + return + fi + detect_editor_cli case "$OPEN_MODE" in cursor|code) log_success "IDE: $EDITOR_CLI" ;; diff --git a/package-lock.json b/package-lock.json index f28206b2..7a03b2d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "resolved": "https://registry.npmjs.org/bats/-/bats-1.13.0.tgz", "integrity": "sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==", "license": "MIT", + "peer": true, "bin": { "bats": "bin/bats" } @@ -52,7 +53,8 @@ }, "node_modules/bats-support": { "version": "0.3.0", - "resolved": "git+ssh://git@github.com/bats-core/bats-support.git#24a72e14349690bcbf7c151b9d2d1cdd32d36eb1" + "resolved": "git+ssh://git@github.com/bats-core/bats-support.git#24a72e14349690bcbf7c151b9d2d1cdd32d36eb1", + "peer": true } } } diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 6727371d..4320e87e 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -8,10 +8,12 @@ # # USAGE: # ./scripts/devc-remote.sh [options] [:] +# ./scripts/devc-remote.sh --bootstrap [--yes] # ./scripts/devc-remote.sh --help # # Options: -# --yes, -y Auto-accept prompts (reuse running containers) +# --bootstrap One-time remote host setup (config, GHCR auth, image build) +# --yes, -y Auto-accept prompts (use defaults without asking) # --open How to connect after compose up: # auto - detect IDE from $TERM_PROGRAM or CLI availability (default) # cursor - open Cursor via devcontainer protocol @@ -79,12 +81,17 @@ parse_args() { REMOTE_PATH="~" YES_MODE=0 OPEN_MODE="auto" + BOOTSTRAP_MODE=0 while [[ $# -gt 0 ]]; do case "$1" in --help|-h) show_help ;; + --bootstrap) + BOOTSTRAP_MODE=1 + shift + ;; --yes|-y) # shellcheck disable=SC2034 YES_MODE=1 @@ -482,6 +489,176 @@ for peer in (data.get('Peer') or {}).values(): return 1 } +# ═══════════════════════════════════════════════════════════════════════════════ +# BOOTSTRAP (one-time remote host setup) +# ═══════════════════════════════════════════════════════════════════════════════ + +bootstrap_check_config() { + # Check if config exists on remote, read values if so + local config_output + # shellcheck disable=SC2029 + config_output=$(ssh "$SSH_HOST" "bash -s" << 'CFGEOF' +CONFIG_DIR="$HOME/.config/devc-remote" +CONFIG_FILE="$CONFIG_DIR/config.yaml" +if [ -f "$CONFIG_FILE" ]; then + echo "CONFIG_EXISTS=1" + # Parse simple flat YAML (key: value) using sed + sed -n 's/^projects_dir: *//p' "$CONFIG_FILE" | while read -r v; do echo "PROJECTS_DIR=$v"; done + sed -n 's/^devcontainer_repo: *//p' "$CONFIG_FILE" | while read -r v; do echo "DEVCONTAINER_REPO=$v"; done + sed -n 's/^devcontainer_path: *//p' "$CONFIG_FILE" | while read -r v; do echo "DEVCONTAINER_PATH=$v"; done + sed -n 's/^image_tag: *//p' "$CONFIG_FILE" | while read -r v; do echo "IMAGE_TAG=$v"; done + sed -n 's/^registry: *//p' "$CONFIG_FILE" | while read -r v; do echo "REGISTRY=$v"; done +else + echo "CONFIG_EXISTS=0" +fi +CFGEOF + ) + + CONFIG_EXISTS=0 + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + CONFIG_EXISTS) CONFIG_EXISTS="${BASH_REMATCH[2]}" ;; + PROJECTS_DIR) BOOTSTRAP_PROJECTS_DIR="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_REPO) BOOTSTRAP_DEVC_REPO="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_PATH) BOOTSTRAP_DEVC_PATH="${BASH_REMATCH[2]}" ;; + IMAGE_TAG) BOOTSTRAP_IMAGE_TAG="${BASH_REMATCH[2]}" ;; + REGISTRY) BOOTSTRAP_REGISTRY="${BASH_REMATCH[2]}" ;; + esac + done <<< "$config_output" +} + +bootstrap_prompt_config() { + # Set defaults + BOOTSTRAP_PROJECTS_DIR="${BOOTSTRAP_PROJECTS_DIR:-~/Projects}" + BOOTSTRAP_DEVC_REPO="${BOOTSTRAP_DEVC_REPO:-vig-os/devcontainer}" + BOOTSTRAP_IMAGE_TAG="${BOOTSTRAP_IMAGE_TAG:-dev}" + BOOTSTRAP_REGISTRY="${BOOTSTRAP_REGISTRY:-ghcr.io/vig-os/devcontainer}" + + if [[ "$YES_MODE" == "0" ]]; then + log_info "No devc-remote config found on $SSH_HOST." + read -rp "Where should projects be cloned? [$BOOTSTRAP_PROJECTS_DIR]: " user_input + BOOTSTRAP_PROJECTS_DIR="${user_input:-$BOOTSTRAP_PROJECTS_DIR}" + fi + + # Derive devcontainer_path from projects_dir + BOOTSTRAP_DEVC_PATH="${BOOTSTRAP_PROJECTS_DIR}/devcontainer" +} + +bootstrap_write_config() { + # Write config file on remote + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$BOOTSTRAP_PROJECTS_DIR" "$BOOTSTRAP_DEVC_REPO" "$BOOTSTRAP_DEVC_PATH" "$BOOTSTRAP_IMAGE_TAG" "$BOOTSTRAP_REGISTRY" << 'WRITEEOF' +PROJECTS_DIR="$1" +DEVC_REPO="$2" +DEVC_PATH="$3" +IMAGE_TAG="$4" +REGISTRY="$5" +CONFIG_DIR="$HOME/.config/devc-remote" +CONFIG_FILE="$CONFIG_DIR/config.yaml" +mkdir -p "$CONFIG_DIR" +cat > "$CONFIG_FILE" << YAML +projects_dir: ${PROJECTS_DIR} +devcontainer_repo: ${DEVC_REPO} +devcontainer_path: ${DEVC_PATH} +image_tag: ${IMAGE_TAG} +registry: ${REGISTRY} +YAML +WRITEEOF + + log_success "Config written to ~/.config/devc-remote/config.yaml — edit to customize." +} + +bootstrap_forward_ghcr_auth() { + # Forward container registry credentials to remote + local local_auth="" + + # Check podman auth first, then docker + if [[ -f "${HOME}/.config/containers/auth.json" ]]; then + local_auth="${HOME}/.config/containers/auth.json" + elif [[ -f "${HOME}/.docker/config.json" ]]; then + local_auth="${HOME}/.docker/config.json" + elif [[ -n "${GHCR_TOKEN:-}" ]]; then + # Use token-based auth — create temp auth file + local tmp_auth + tmp_auth="$(mktemp)" + echo "{\"auths\":{\"ghcr.io\":{\"auth\":\"$(echo -n "token:${GHCR_TOKEN}" | base64)\"}}}" > "$tmp_auth" + local_auth="$tmp_auth" + fi + + if [[ -z "$local_auth" ]]; then + log_warning "GHCR auth: no local credentials found, skipping" + return 0 + fi + + # Ensure remote directories exist and copy auth file + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "mkdir -p ~/.config/containers ~/.docker" + scp -q "$local_auth" "$SSH_HOST:~/.config/containers/auth.json" + scp -q "$local_auth" "$SSH_HOST:~/.docker/config.json" + + # Clean up temp file if we created one + if [[ -n "${GHCR_TOKEN:-}" && -n "${tmp_auth:-}" ]]; then + rm -f "$tmp_auth" + fi + + log_success "GHCR auth forwarded to $SSH_HOST" +} + +bootstrap_clone_and_build() { + log_info "Building devcontainer image on $SSH_HOST..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$BOOTSTRAP_DEVC_REPO" "$BOOTSTRAP_DEVC_PATH" "$BOOTSTRAP_IMAGE_TAG" "$BOOTSTRAP_REGISTRY" << 'BUILDEOF' +DEVC_REPO="$1" +DEVC_PATH="$2" +IMAGE_TAG="$3" +REGISTRY="$4" + +# Expand ~ in DEVC_PATH +DEVC_PATH="${DEVC_PATH/#\~/$HOME}" + +if [ -d "$DEVC_PATH/.git" ]; then + echo "Repository exists, pulling latest..." + cd "$DEVC_PATH" && git pull +else + echo "Cloning $DEVC_REPO..." + # Expand ~ in parent dir + PARENT_DIR="$(dirname "$DEVC_PATH")" + mkdir -p "$PARENT_DIR" + cd "$PARENT_DIR" + git clone "https://github.com/${DEVC_REPO}.git" "$(basename "$DEVC_PATH")" + cd "$DEVC_PATH" +fi + +# Build the image +if [ -f "scripts/build.sh" ]; then + echo "Running scripts/build.sh..." + bash scripts/build.sh +else + echo "WARNING: scripts/build.sh not found in $DEVC_PATH" +fi +BUILDEOF + + log_success "Devcontainer image built on $SSH_HOST" +} + +bootstrap_remote() { + log_info "Bootstrap: checking remote config on $SSH_HOST..." + bootstrap_check_config + + if [[ "$CONFIG_EXISTS" == "1" ]]; then + log_info "Config: ~/.config/devc-remote/config.yaml (existing, not modified)" + else + bootstrap_prompt_config + bootstrap_write_config + fi + + bootstrap_forward_ghcr_auth + bootstrap_clone_and_build + + log_success "Bootstrap complete for $SSH_HOST" +} + # ═══════════════════════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════════════════════ @@ -489,6 +666,15 @@ for peer in (data.get('Peer') or {}).values(): main() { parse_args "$@" + # Bootstrap mode: one-time remote host setup + if [[ "$BOOTSTRAP_MODE" == "1" ]]; then + log_info "Checking SSH connectivity to $SSH_HOST..." + check_ssh + log_success "SSH connection OK" + bootstrap_remote + return + fi + detect_editor_cli case "$OPEN_MODE" in cursor|code) log_success "IDE: $EDITOR_CLI" ;; From 42d3e754cd8391872449895d3a9b2b0eed4a65ef Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 16:56:07 +0100 Subject: [PATCH 055/215] chore: add .worktrees/ to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d1d3fb76..84c85ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -223,5 +223,8 @@ __marimo__/ # Pre-commit cache .pre-commit-cache/ +# Worktrees +.worktrees/ + # Bats node_modules/ From 83dbb32c8a277e6fbafc67cbbca906d657161de0 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 19:01:16 +0100 Subject: [PATCH 056/215] test(remote): add failing tests for gh: target arg parsing Refs: #236 --- tests/bats/devc-remote.bats | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 09834d41..79dbb7db 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -109,6 +109,48 @@ setup() { assert_failure } +# ── parse_args: gh: target syntax ──────────────────────────────────────────── + +@test "parse_args recognizes gh:org/repo as second positional arg" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 + # Should get past parse_args (fail at check_ssh, not "Unexpected argument") + refute_output --partial "Unexpected argument" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "parse_args recognizes gh:org/repo:branch with branch extraction" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5:feature/my-branch 2>&1 + refute_output --partial "Unexpected argument" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "parse_args accepts host:path combined with gh:org/repo" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver:~/custom/path gh:vig-os/fd5 2>&1 + refute_output --partial "Unexpected argument" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "parse_args rejects gh: with missing repo" { + run "$DEVC_REMOTE" --open none myserver gh: 2>&1 + assert_failure + assert_output --partial "Invalid gh: target" +} + # ── detect_editor_cli ───────────────────────────────────────────────────────── @test "detect_editor_cli prefers cursor when both cursor and code exist" { From 00e26641bef9aec3e0d7073a68500a1bfc65a0dd Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 20:02:10 +0100 Subject: [PATCH 057/215] feat(remote): add gh:org/repo[:branch] arg parsing to parse_args Refs: #236 --- assets/workspace/scripts/devc-remote.sh | 24 ++++++++++++++++++++++++ scripts/devc-remote.sh | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 6727371d..3f2246ef 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -79,6 +79,9 @@ parse_args() { REMOTE_PATH="~" YES_MODE=0 OPEN_MODE="auto" + GH_REPO="" + GH_BRANCH="" + GH_MODE=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -104,6 +107,27 @@ parse_args() { echo "Use --help for usage information" exit 1 ;; + gh:*) + # gh:org/repo or gh:org/repo:branch + local gh_target="${1#gh:}" + if [[ -z "$gh_target" || "$gh_target" != */* ]]; then + log_error "Invalid gh: target. Use gh:org/repo or gh:org/repo:branch" + exit 1 + fi + # shellcheck disable=SC2034 + GH_MODE=1 + # Split on first colon after org/repo (branch may contain slashes) + if [[ "$gh_target" =~ ^([^:]+):(.+)$ ]]; then + # shellcheck disable=SC2034 + GH_REPO="${BASH_REMATCH[1]}" + # shellcheck disable=SC2034 + GH_BRANCH="${BASH_REMATCH[2]}" + else + # shellcheck disable=SC2034 + GH_REPO="$gh_target" + fi + shift + ;; *) if [[ -n "$SSH_HOST" ]]; then log_error "Unexpected argument: $1" diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 6727371d..3f2246ef 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -79,6 +79,9 @@ parse_args() { REMOTE_PATH="~" YES_MODE=0 OPEN_MODE="auto" + GH_REPO="" + GH_BRANCH="" + GH_MODE=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -104,6 +107,27 @@ parse_args() { echo "Use --help for usage information" exit 1 ;; + gh:*) + # gh:org/repo or gh:org/repo:branch + local gh_target="${1#gh:}" + if [[ -z "$gh_target" || "$gh_target" != */* ]]; then + log_error "Invalid gh: target. Use gh:org/repo or gh:org/repo:branch" + exit 1 + fi + # shellcheck disable=SC2034 + GH_MODE=1 + # Split on first colon after org/repo (branch may contain slashes) + if [[ "$gh_target" =~ ^([^:]+):(.+)$ ]]; then + # shellcheck disable=SC2034 + GH_REPO="${BASH_REMATCH[1]}" + # shellcheck disable=SC2034 + GH_BRANCH="${BASH_REMATCH[2]}" + else + # shellcheck disable=SC2034 + GH_REPO="$gh_target" + fi + shift + ;; *) if [[ -n "$SSH_HOST" ]]; then log_error "Unexpected argument: $1" From fd64108b95cdb0b82d00c1f42dd79201c807aa7b Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 20:15:06 +0100 Subject: [PATCH 058/215] test(remote): add failing tests for remote_clone_project function Refs: #236 --- tests/bats/devc-remote.bats | 93 +++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index 79dbb7db..54504fd0 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -151,6 +151,99 @@ setup() { assert_output --partial "Invalid gh: target" } +# ── remote_clone_project ───────────────────────────────────────────────────── + +@test "remote_clone_project clones repo on fresh target" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "CLONE_PATH=/home/user/Projects/fd5" + echo "CLONE_STATUS=cloned" +elif [ "\$count" = "2" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "3" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 + assert_success + assert_output --partial "Cloning vig-os/fd5" + rm -rf "$mock_bin" +} + +@test "remote_clone_project fetches existing repo" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "CLONE_PATH=/home/user/Projects/fd5" + echo "CLONE_STATUS=fetched" +elif [ "\$count" = "2" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "3" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 + assert_success + assert_output --partial "Fetching vig-os/fd5" + rm -rf "$mock_bin" +} + +@test "remote_clone_project checks out specified branch" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/ssh" << SSHEOF +#!/bin/sh +counter="${mock_bin}/ssh_counter" +count=\$(cat "\$counter" 2>/dev/null || echo 0) +echo \$((count + 1)) > "\$counter" +if [ "\$count" = "1" ]; then + echo "CLONE_PATH=/home/user/Projects/fd5" + echo "CLONE_STATUS=cloned" + echo "CLONE_BRANCH=feature/my-branch" +elif [ "\$count" = "2" ]; then + echo "RUNTIME=podman" + echo "COMPOSE_AVAILABLE=1" + echo "REPO_PATH_EXISTS=1" + echo "DEVCONTAINER_EXISTS=1" + echo "DISK_AVAILABLE_GB=5" + echo "OS_TYPE=linux" +elif [ "\$count" = "3" ]; then + echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' +fi +exit 0 +SSHEOF + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5:feature/my-branch 2>&1 + assert_success + assert_output --partial "Checked out feature/my-branch" + rm -rf "$mock_bin" +} + # ── detect_editor_cli ───────────────────────────────────────────────────────── @test "detect_editor_cli prefers cursor when both cursor and code exist" { From 5fb12e2a55a00394f040762cd08117a4d4c499e0 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 8 Mar 2026 20:28:21 +0100 Subject: [PATCH 059/215] feat(remote): implement remote_clone_project and wire into main pipeline Refs: #236 --- assets/workspace/scripts/devc-remote.sh | 74 +++++++++++++++++++++++++ scripts/devc-remote.sh | 74 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 3f2246ef..99c6f6fc 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -321,6 +321,78 @@ check_ssh() { fi } +remote_clone_project() { + [[ "$GH_MODE" == "1" ]] || return 0 + + log_info "Cloning $GH_REPO on $SSH_HOST..." + + local clone_output + # shellcheck disable=SC2029 + clone_output=$(ssh "$SSH_HOST" "bash -s" "$GH_REPO" "$GH_BRANCH" "$REMOTE_PATH" << 'CLONEEOF' +GH_REPO="$1" +GH_BRANCH="$2" +USER_PATH="$3" +REPO_NAME="${GH_REPO##*/}" + +# Resolve target directory +if [ "$USER_PATH" != "~" ] && [ -n "$USER_PATH" ]; then + TARGET_DIR="$USER_PATH" +else + # Read projects_dir from config, fallback to ~/Projects + PROJECTS_DIR="$HOME/Projects" + CONFIG_FILE="$HOME/.config/devc-remote/config.yaml" + if [ -f "$CONFIG_FILE" ]; then + CONFIGURED_DIR=$(sed -n 's/^projects_dir: *//p' "$CONFIG_FILE") + [ -n "$CONFIGURED_DIR" ] && PROJECTS_DIR="${CONFIGURED_DIR/#\~/$HOME}" + fi + TARGET_DIR="$PROJECTS_DIR/$REPO_NAME" +fi + +# Clone or fetch +CLONE_STATUS="fetched" +if [ ! -d "$TARGET_DIR/.git" ]; then + git clone "https://github.com/${GH_REPO}.git" "$TARGET_DIR" + CLONE_STATUS="cloned" +else + cd "$TARGET_DIR" && git fetch +fi + +# Checkout branch if specified +if [ -n "$GH_BRANCH" ]; then + cd "$TARGET_DIR" && git checkout "$GH_BRANCH" + echo "CLONE_BRANCH=$GH_BRANCH" +fi + +echo "CLONE_PATH=$TARGET_DIR" +echo "CLONE_STATUS=$CLONE_STATUS" +CLONEEOF + ) + + local clone_path="" clone_status="" clone_branch="" + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + CLONE_PATH) clone_path="${BASH_REMATCH[2]}" ;; + CLONE_STATUS) clone_status="${BASH_REMATCH[2]}" ;; + CLONE_BRANCH) clone_branch="${BASH_REMATCH[2]}" ;; + esac + done <<< "$clone_output" + + if [[ -n "$clone_path" ]]; then + REMOTE_PATH="$clone_path" + fi + + if [[ "$clone_status" == "cloned" ]]; then + log_success "Cloning $GH_REPO — cloned to $clone_path" + else + log_success "Fetching $GH_REPO — updated at $clone_path" + fi + + if [[ -n "$clone_branch" ]]; then + log_success "Checked out $clone_branch" + fi +} + remote_preflight() { local preflight_output # shellcheck disable=SC2029 @@ -524,6 +596,8 @@ main() { check_ssh log_success "SSH connection OK" + remote_clone_project + log_info "Running pre-flight checks on $SSH_HOST..." remote_preflight log_success "Pre-flight OK (runtime: $RUNTIME)" diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 3f2246ef..99c6f6fc 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -321,6 +321,78 @@ check_ssh() { fi } +remote_clone_project() { + [[ "$GH_MODE" == "1" ]] || return 0 + + log_info "Cloning $GH_REPO on $SSH_HOST..." + + local clone_output + # shellcheck disable=SC2029 + clone_output=$(ssh "$SSH_HOST" "bash -s" "$GH_REPO" "$GH_BRANCH" "$REMOTE_PATH" << 'CLONEEOF' +GH_REPO="$1" +GH_BRANCH="$2" +USER_PATH="$3" +REPO_NAME="${GH_REPO##*/}" + +# Resolve target directory +if [ "$USER_PATH" != "~" ] && [ -n "$USER_PATH" ]; then + TARGET_DIR="$USER_PATH" +else + # Read projects_dir from config, fallback to ~/Projects + PROJECTS_DIR="$HOME/Projects" + CONFIG_FILE="$HOME/.config/devc-remote/config.yaml" + if [ -f "$CONFIG_FILE" ]; then + CONFIGURED_DIR=$(sed -n 's/^projects_dir: *//p' "$CONFIG_FILE") + [ -n "$CONFIGURED_DIR" ] && PROJECTS_DIR="${CONFIGURED_DIR/#\~/$HOME}" + fi + TARGET_DIR="$PROJECTS_DIR/$REPO_NAME" +fi + +# Clone or fetch +CLONE_STATUS="fetched" +if [ ! -d "$TARGET_DIR/.git" ]; then + git clone "https://github.com/${GH_REPO}.git" "$TARGET_DIR" + CLONE_STATUS="cloned" +else + cd "$TARGET_DIR" && git fetch +fi + +# Checkout branch if specified +if [ -n "$GH_BRANCH" ]; then + cd "$TARGET_DIR" && git checkout "$GH_BRANCH" + echo "CLONE_BRANCH=$GH_BRANCH" +fi + +echo "CLONE_PATH=$TARGET_DIR" +echo "CLONE_STATUS=$CLONE_STATUS" +CLONEEOF + ) + + local clone_path="" clone_status="" clone_branch="" + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + CLONE_PATH) clone_path="${BASH_REMATCH[2]}" ;; + CLONE_STATUS) clone_status="${BASH_REMATCH[2]}" ;; + CLONE_BRANCH) clone_branch="${BASH_REMATCH[2]}" ;; + esac + done <<< "$clone_output" + + if [[ -n "$clone_path" ]]; then + REMOTE_PATH="$clone_path" + fi + + if [[ "$clone_status" == "cloned" ]]; then + log_success "Cloning $GH_REPO — cloned to $clone_path" + else + log_success "Fetching $GH_REPO — updated at $clone_path" + fi + + if [[ -n "$clone_branch" ]]; then + log_success "Checked out $clone_branch" + fi +} + remote_preflight() { local preflight_output # shellcheck disable=SC2029 @@ -524,6 +596,8 @@ main() { check_ssh log_success "SSH connection OK" + remote_clone_project + log_info "Running pre-flight checks on $SSH_HOST..." remote_preflight log_success "Pre-flight OK (runtime: $RUNTIME)" From 7702775c95a23664e00706f733df52e3b54b6b20 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 09:53:59 +0100 Subject: [PATCH 060/215] docs(remote): add gh: target syntax to help text and changelog Refs: #236 --- CHANGELOG.md | 5 +++++ assets/workspace/scripts/devc-remote.sh | 14 ++++++++++++-- scripts/devc-remote.sh | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f24bd7..d1f71d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`gh:org/repo[:branch]` target for devc-remote** ([#236](https://github.com/vig-os/devcontainer/issues/236)) + - Clone a GitHub repo on the remote host and start its devcontainer in one command + - Supports `gh:org/repo` (default branch) and `gh:org/repo:branch` (specific branch) + - Already-cloned repos are fetched, not re-cloned + - Clone location resolved from remote config `projects_dir` or overridden via `host:path` - **Opt-in Tailscale SSH support for devcontainer** ([#208](https://github.com/vig-os/devcontainer/issues/208)) - New `setup-tailscale.sh` script with `install` and `start` subcommands - Hooks into `post-create.sh` (install) and `post-start.sh` (start) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 99c6f6fc..d391b7d7 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -7,7 +7,7 @@ # compose lifecycle, and optional Tailscale auth key injection. # # USAGE: -# ./scripts/devc-remote.sh [options] [:] +# ./scripts/devc-remote.sh [options] [:] [gh:/[:]] # ./scripts/devc-remote.sh --help # # Options: @@ -19,6 +19,13 @@ # ssh - wait for Tailscale, print hostname (for SSH clients) # none - infra only, no IDE # +# GitHub repo target (gh:): +# Clone a GitHub repo on the remote host and start its devcontainer. +# gh:/ Clone to / (from config or ~/Projects) +# gh:/: Clone and checkout specified branch +# Combined with host:path to override clone location: +# : gh:/ Clone to instead of default +# # Tailscale key injection (opt-in): # When TS_CLIENT_ID and TS_CLIENT_SECRET are set in the local environment, # generates an ephemeral auth key via the Tailscale API and injects it @@ -29,8 +36,11 @@ # ./scripts/devc-remote.sh --open none myserver:/home/user/repo # ./scripts/devc-remote.sh --open ssh myserver # ./scripts/devc-remote.sh --yes --open code user@host:/opt/projects/myrepo +# ./scripts/devc-remote.sh myserver gh:vig-os/fd5 +# ./scripts/devc-remote.sh myserver gh:vig-os/fd5:feature/my-branch +# ./scripts/devc-remote.sh myserver:~/custom/path gh:vig-os/fd5 # -# Part of #70. See issues #152, #230, #231 for design. +# Part of #70. See issues #152, #230, #231, #236 for design. ############################################################################### set -euo pipefail diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 99c6f6fc..d391b7d7 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -7,7 +7,7 @@ # compose lifecycle, and optional Tailscale auth key injection. # # USAGE: -# ./scripts/devc-remote.sh [options] [:] +# ./scripts/devc-remote.sh [options] [:] [gh:/[:]] # ./scripts/devc-remote.sh --help # # Options: @@ -19,6 +19,13 @@ # ssh - wait for Tailscale, print hostname (for SSH clients) # none - infra only, no IDE # +# GitHub repo target (gh:): +# Clone a GitHub repo on the remote host and start its devcontainer. +# gh:/ Clone to / (from config or ~/Projects) +# gh:/: Clone and checkout specified branch +# Combined with host:path to override clone location: +# : gh:/ Clone to instead of default +# # Tailscale key injection (opt-in): # When TS_CLIENT_ID and TS_CLIENT_SECRET are set in the local environment, # generates an ephemeral auth key via the Tailscale API and injects it @@ -29,8 +36,11 @@ # ./scripts/devc-remote.sh --open none myserver:/home/user/repo # ./scripts/devc-remote.sh --open ssh myserver # ./scripts/devc-remote.sh --yes --open code user@host:/opt/projects/myrepo +# ./scripts/devc-remote.sh myserver gh:vig-os/fd5 +# ./scripts/devc-remote.sh myserver gh:vig-os/fd5:feature/my-branch +# ./scripts/devc-remote.sh myserver:~/custom/path gh:vig-os/fd5 # -# Part of #70. See issues #152, #230, #231 for design. +# Part of #70. See issues #152, #230, #231, #236 for design. ############################################################################### set -euo pipefail From 17ca79f32c79edcc1b4bd2e69d47b6ad1a7850e8 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 12:17:31 +0100 Subject: [PATCH 061/215] fix(remote): use sentinels for SSH arg passing in remote_clone_project SSH drops empty string arguments and the remote shell expands ~ before the script sees it. Use _NONE_ and _DEFAULT_ sentinels to preserve empty branch and default path values through the SSH boundary. Refs: #243 --- assets/workspace/scripts/devc-remote.sh | 11 +++++++++-- scripts/devc-remote.sh | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index f9cc35d6..25125c0c 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -404,14 +404,21 @@ remote_clone_project() { local clone_output # shellcheck disable=SC2029 - clone_output=$(ssh "$SSH_HOST" "bash -s" "$GH_REPO" "$GH_BRANCH" "$REMOTE_PATH" << 'CLONEEOF' + # Use sentinels for empty/default args — SSH drops empty strings and expands ~ + local _branch="${GH_BRANCH:-_NONE_}" + local _path="${REMOTE_PATH}" + [[ "$_path" == "~" ]] && _path="_DEFAULT_" + # shellcheck disable=SC2029 + clone_output=$(ssh "$SSH_HOST" "bash -s" "$GH_REPO" "$_branch" "$_path" << 'CLONEEOF' GH_REPO="$1" GH_BRANCH="$2" +[ "$GH_BRANCH" = "_NONE_" ] && GH_BRANCH="" USER_PATH="$3" +[ "$USER_PATH" = "_DEFAULT_" ] && USER_PATH="" REPO_NAME="${GH_REPO##*/}" # Resolve target directory -if [ "$USER_PATH" != "~" ] && [ -n "$USER_PATH" ]; then +if [ -n "$USER_PATH" ]; then TARGET_DIR="$USER_PATH" else # Read projects_dir from config, fallback to ~/Projects diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index f9cc35d6..25125c0c 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -404,14 +404,21 @@ remote_clone_project() { local clone_output # shellcheck disable=SC2029 - clone_output=$(ssh "$SSH_HOST" "bash -s" "$GH_REPO" "$GH_BRANCH" "$REMOTE_PATH" << 'CLONEEOF' + # Use sentinels for empty/default args — SSH drops empty strings and expands ~ + local _branch="${GH_BRANCH:-_NONE_}" + local _path="${REMOTE_PATH}" + [[ "$_path" == "~" ]] && _path="_DEFAULT_" + # shellcheck disable=SC2029 + clone_output=$(ssh "$SSH_HOST" "bash -s" "$GH_REPO" "$_branch" "$_path" << 'CLONEEOF' GH_REPO="$1" GH_BRANCH="$2" +[ "$GH_BRANCH" = "_NONE_" ] && GH_BRANCH="" USER_PATH="$3" +[ "$USER_PATH" = "_DEFAULT_" ] && USER_PATH="" REPO_NAME="${GH_REPO##*/}" # Resolve target directory -if [ "$USER_PATH" != "~" ] && [ -n "$USER_PATH" ]; then +if [ -n "$USER_PATH" ]; then TARGET_DIR="$USER_PATH" else # Read projects_dir from config, fallback to ~/Projects From 92802241285c5bab524c16f144e14260eda3eca6 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 14:10:24 +0100 Subject: [PATCH 062/215] feat(remote): forward GHCR auth on every deploy, not just bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move forward_ghcr_auth (renamed from bootstrap_forward_ghcr_auth) into the normal main() flow after check_ssh. This ensures the remote always has valid GHCR credentials without requiring a separate --bootstrap step. The call is idempotent — overwrites with current local creds. Refs: #243 --- assets/workspace/scripts/devc-remote.sh | 6 ++- scripts/devc-remote.sh | 6 ++- tests/bats/devc-remote.bats | 69 ++++++++++++++++++------- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 25125c0c..0f66189f 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -848,7 +848,7 @@ WRITEEOF log_success "Config written to ~/.config/devc-remote/config.yaml — edit to customize." } -bootstrap_forward_ghcr_auth() { +forward_ghcr_auth() { # Forward container registry credentials to remote local local_auth="" @@ -932,7 +932,7 @@ bootstrap_remote() { bootstrap_write_config fi - bootstrap_forward_ghcr_auth + forward_ghcr_auth bootstrap_clone_and_build log_success "Bootstrap complete for $SSH_HOST" @@ -966,6 +966,8 @@ main() { check_ssh log_success "SSH connection OK" + forward_ghcr_auth + remote_clone_project log_info "Running pre-flight checks on $SSH_HOST..." diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 25125c0c..0f66189f 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -848,7 +848,7 @@ WRITEEOF log_success "Config written to ~/.config/devc-remote/config.yaml — edit to customize." } -bootstrap_forward_ghcr_auth() { +forward_ghcr_auth() { # Forward container registry credentials to remote local local_auth="" @@ -932,7 +932,7 @@ bootstrap_remote() { bootstrap_write_config fi - bootstrap_forward_ghcr_auth + forward_ghcr_auth bootstrap_clone_and_build log_success "Bootstrap complete for $SSH_HOST" @@ -966,6 +966,8 @@ main() { check_ssh log_success "SSH connection OK" + forward_ghcr_auth + remote_clone_project log_info "Running pre-flight checks on $SSH_HOST..." diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index b516a992..c14a110a 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -158,25 +158,28 @@ setup() { mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=clone, 3=preflight, 4+=prepare/compose counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +if [ "\$count" = "2" ]; then echo "CLONE_PATH=/home/user/Projects/fd5" echo "CLONE_STATUS=cloned" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "3" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "3" ]; then +elif [ "\$count" = "6" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 assert_success assert_output --partial "Cloning vig-os/fd5" @@ -188,25 +191,28 @@ SSHEOF mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=clone, 3=preflight, 4+=prepare/compose counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +if [ "\$count" = "2" ]; then echo "CLONE_PATH=/home/user/Projects/fd5" echo "CLONE_STATUS=fetched" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "3" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "3" ]; then +elif [ "\$count" = "6" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 assert_success assert_output --partial "Fetching vig-os/fd5" @@ -218,26 +224,29 @@ SSHEOF mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=clone, 3=preflight, 4+=prepare/compose counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +if [ "\$count" = "2" ]; then echo "CLONE_PATH=/home/user/Projects/fd5" echo "CLONE_STATUS=cloned" echo "CLONE_BRANCH=feature/my-branch" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "3" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "3" ]; then +elif [ "\$count" = "6" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5:feature/my-branch 2>&1 assert_success assert_output --partial "Checked out feature/my-branch" @@ -408,22 +417,25 @@ SSHEOF mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=preflight, 3,4=prepare, 5=compose_ps counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +if [ "\$count" = "2" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "2" ]; then +elif [ "\$count" = "5" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" chmod +x "$mock_bin/python3" printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" @@ -456,22 +468,25 @@ SSHEOF mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=preflight, 3,4=prepare, 5=compose_ps counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +if [ "\$count" = "2" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "4" ]; then +elif [ "\$count" = "5" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" chmod +x "$mock_bin/python3" printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" @@ -491,6 +506,8 @@ SSHEOF mock_bin="$(mktemp -d)" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/ssh" chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" # Need cursor for detect_editor_cli printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" @@ -529,6 +546,8 @@ echo "OS_TYPE=linux" exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" # Will fail at remote_compose_up or open_editor; we verify we get past preflight @@ -550,6 +569,8 @@ echo "COMPOSE_AVAILABLE=0" exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none host 2>&1 @@ -565,22 +586,25 @@ SSHEOF mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=preflight, 3,4=prepare, 5=compose_ps counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +if [ "\$count" = "2" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "4" ]; then +elif [ "\$count" = "5" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" chmod +x "$mock_bin/python3" printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" @@ -601,19 +625,22 @@ SSHEOF counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=preflight, 3,4=prepare_remote, 5=compose_ps +if [ "\$count" = "2" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "4" ]; then +elif [ "\$count" = "5" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" printf '%s\n' '#!/bin/sh' 'echo "vscode-remote://test"' 'exit 0' > "$mock_bin/python3" chmod +x "$mock_bin/python3" printf '%s\n' '#!/bin/sh' '[ "$1" = "--folder-uri" ] && [ -n "$2" ] && exit 0' 'exit 1' > "$mock_bin/cursor" @@ -844,18 +871,18 @@ SSHEOF mock_bin="$(mktemp -d)" cat > "$mock_bin/ssh" << SSHEOF #!/bin/sh -# check_ssh=0, preflight=1, prepare_remote=2+3, compose_ps=4 +# 0=check_ssh, 1=forward_ghcr_auth(mkdir), 2=preflight, 3,4=prepare, 5=compose_ps counter="${mock_bin}/ssh_counter" count=\$(cat "\$counter" 2>/dev/null || echo 0) echo \$((count + 1)) > "\$counter" -if [ "\$count" = "1" ]; then +if [ "\$count" = "2" ]; then echo "RUNTIME=podman" echo "COMPOSE_AVAILABLE=1" echo "REPO_PATH_EXISTS=1" echo "DEVCONTAINER_EXISTS=1" echo "DISK_AVAILABLE_GB=5" echo "OS_TYPE=linux" -elif [ "\$count" = "4" ]; then +elif [ "\$count" = "5" ]; then echo '[{"Service":"devcontainer","State":"running","Health":"healthy"}]' else : @@ -863,6 +890,8 @@ fi exit 0 SSHEOF chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" chmod +x "$mock_bin/cursor" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none host 2>&1 From a6f140a0f6b0fbd0fc29ee360872e26864e6f108 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 15:21:11 +0100 Subject: [PATCH 063/215] feat(remote): add --force flag, unpushed commits guard, and just remote-devc recipe - check_unpushed_commits() blocks deploy when local commits aren't pushed - --force/-f auto-pushes before deploying (handles no-upstream branches too) - just remote-devc auto-detects org/repo:branch from local git state - BATS tests for all check_unpushed_commits scenarios - Fix existing gh: target tests to mock git (avoid real repo interference) Refs: #246 --- CHANGELOG.md | 6 + assets/workspace/.devcontainer/justfile.base | 31 +++ assets/workspace/scripts/devc-remote.sh | 46 ++++ justfile.base | 31 +++ scripts/devc-remote.sh | 46 ++++ tests/bats/devc-remote.bats | 228 +++++++++++++++++++ 6 files changed, 388 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aebc500..86aae927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Forwards GHCR auth (podman/docker credentials or `GHCR_TOKEN`) to remote - Clones devcontainer repo and builds image on remote - Re-run reads existing config without re-prompting, pulls latest and rebuilds +- **Seamless local-to-remote handoff with `just remote-devc`** ([#246](https://github.com/vig-os/devcontainer/issues/246)) + - `just remote-devc ` auto-detects repo + branch from local git state + - `--force` / `-f` flag auto-pushes unpushed commits before deploying + - Unpushed commits guard: blocks deploy unless pushed or `--force` used + - No-upstream branches auto-pushed with `git push -u origin ` when `--force` + - GHCR auth forwarded on every deploy (not just bootstrap) - **`gh:org/repo[:branch]` target for devc-remote** ([#236](https://github.com/vig-os/devcontainer/issues/236)) - Clone a GitHub repo on the remote host and start its devcontainer in one command - Supports `gh:org/repo` (default branch) and `gh:org/repo:branch` (specific branch) diff --git a/assets/workspace/.devcontainer/justfile.base b/assets/workspace/.devcontainer/justfile.base index b5517c21..4ed7b279 100644 --- a/assets/workspace/.devcontainer/justfile.base +++ b/assets/workspace/.devcontainer/justfile.base @@ -380,3 +380,34 @@ sidecar name *args: [group('devcontainer')] devc-remote *args: bash scripts/devc-remote.sh {{args}} + +# Deploy current project to a remote host (auto-detects repo + branch) +# Pushes unpushed commits, clones/fetches on remote, starts devcontainer +# Usage: just remote-devc [:] [extra-args...] +# Example: just remote-devc ksb-meatgrinder +# just remote-devc ksb-meatgrinder --open none +[group('devcontainer')] +remote-devc host *args: + #!/usr/bin/env bash + set -euo pipefail + SCRIPT_DIR="$(cd "{{source_directory()}}/scripts" && pwd)" + if [[ ! -f "$SCRIPT_DIR/devc-remote.sh" ]]; then + echo "[ERROR] devc-remote.sh not found at $SCRIPT_DIR" + exit 1 + fi + # Auto-detect org/repo from git remote + remote_url=$(git remote get-url origin 2>/dev/null) || { + echo "[ERROR] No git remote 'origin' found. Run from inside a git repo." + exit 1 + } + # Extract org/repo from SSH or HTTPS URL + # ssh: git@github.com:org/repo.git → org/repo + # https: https://github.com/org/repo.git → org/repo + gh_repo=$(echo "$remote_url" | sed -E 's#(git@|https://)([^:/]+)[:/]##; s/\.git$//') + # Auto-detect current branch + branch=$(git branch --show-current 2>/dev/null) + gh_target="gh:${gh_repo}" + if [[ -n "$branch" ]]; then + gh_target="gh:${gh_repo}:${branch}" + fi + bash "$SCRIPT_DIR/devc-remote.sh" --force "{{host}}" "$gh_target" {{args}} diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 0f66189f..850f964f 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -14,6 +14,7 @@ # Options: # --bootstrap One-time remote host setup (config, GHCR auth, image build) # --yes, -y Auto-accept prompts (use defaults without asking) +# --force, -f Auto-push unpushed commits before deploying (gh: targets) # --open How to connect after compose up: # auto - detect IDE from $TERM_PROGRAM or CLI availability (default) # cursor - open Cursor via devcontainer protocol @@ -92,6 +93,7 @@ parse_args() { YES_MODE=0 OPEN_MODE="auto" BOOTSTRAP_MODE=0 + FORCE_PUSH=0 GH_REPO="" GH_BRANCH="" GH_MODE=0 @@ -110,6 +112,11 @@ parse_args() { YES_MODE=1 shift ;; + --force|-f) + # shellcheck disable=SC2034 + FORCE_PUSH=1 + shift + ;; --open) shift OPEN_MODE="${1:-cursor}" @@ -171,6 +178,43 @@ parse_args() { fi } +check_unpushed_commits() { + # Only relevant when deploying a gh: target from a local repo + [[ "$GH_MODE" == "1" ]] || return 0 + + # Check if we're in a git repo + if ! git rev-parse --is-inside-work-tree &>/dev/null; then + return 0 + fi + + local branch upstream ahead + branch=$(git branch --show-current 2>/dev/null) + [[ -n "$branch" ]] || return 0 + + upstream=$(git rev-parse --abbrev-ref "@{upstream}" 2>/dev/null) || { + log_warning "Branch '$branch' has no upstream. Push with: git push -u origin $branch" + if [[ "$FORCE_PUSH" == "1" ]]; then + log_info "Pushing $branch to origin..." + git push -u origin "$branch" + log_success "Pushed $branch" + return 0 + fi + exit 1 + } + + ahead=$(git rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) + if [[ "$ahead" -gt 0 ]]; then + if [[ "$FORCE_PUSH" == "1" ]]; then + log_info "Pushing $ahead commit(s) on $branch to origin..." + git push + log_success "Pushed $ahead commit(s)" + else + log_error "$ahead unpushed commit(s) on $branch. Push first or use --force." + exit 1 + fi + fi +} + detect_editor_cli() { if [[ "$OPEN_MODE" == "none" || "$OPEN_MODE" == "ssh" ]]; then EDITOR_CLI="" @@ -955,6 +999,8 @@ main() { return fi + check_unpushed_commits + detect_editor_cli case "$OPEN_MODE" in cursor|code) log_success "IDE: $EDITOR_CLI" ;; diff --git a/justfile.base b/justfile.base index b5517c21..4ed7b279 100644 --- a/justfile.base +++ b/justfile.base @@ -380,3 +380,34 @@ sidecar name *args: [group('devcontainer')] devc-remote *args: bash scripts/devc-remote.sh {{args}} + +# Deploy current project to a remote host (auto-detects repo + branch) +# Pushes unpushed commits, clones/fetches on remote, starts devcontainer +# Usage: just remote-devc [:] [extra-args...] +# Example: just remote-devc ksb-meatgrinder +# just remote-devc ksb-meatgrinder --open none +[group('devcontainer')] +remote-devc host *args: + #!/usr/bin/env bash + set -euo pipefail + SCRIPT_DIR="$(cd "{{source_directory()}}/scripts" && pwd)" + if [[ ! -f "$SCRIPT_DIR/devc-remote.sh" ]]; then + echo "[ERROR] devc-remote.sh not found at $SCRIPT_DIR" + exit 1 + fi + # Auto-detect org/repo from git remote + remote_url=$(git remote get-url origin 2>/dev/null) || { + echo "[ERROR] No git remote 'origin' found. Run from inside a git repo." + exit 1 + } + # Extract org/repo from SSH or HTTPS URL + # ssh: git@github.com:org/repo.git → org/repo + # https: https://github.com/org/repo.git → org/repo + gh_repo=$(echo "$remote_url" | sed -E 's#(git@|https://)([^:/]+)[:/]##; s/\.git$//') + # Auto-detect current branch + branch=$(git branch --show-current 2>/dev/null) + gh_target="gh:${gh_repo}" + if [[ -n "$branch" ]]; then + gh_target="gh:${gh_repo}:${branch}" + fi + bash "$SCRIPT_DIR/devc-remote.sh" --force "{{host}}" "$gh_target" {{args}} diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 0f66189f..850f964f 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -14,6 +14,7 @@ # Options: # --bootstrap One-time remote host setup (config, GHCR auth, image build) # --yes, -y Auto-accept prompts (use defaults without asking) +# --force, -f Auto-push unpushed commits before deploying (gh: targets) # --open How to connect after compose up: # auto - detect IDE from $TERM_PROGRAM or CLI availability (default) # cursor - open Cursor via devcontainer protocol @@ -92,6 +93,7 @@ parse_args() { YES_MODE=0 OPEN_MODE="auto" BOOTSTRAP_MODE=0 + FORCE_PUSH=0 GH_REPO="" GH_BRANCH="" GH_MODE=0 @@ -110,6 +112,11 @@ parse_args() { YES_MODE=1 shift ;; + --force|-f) + # shellcheck disable=SC2034 + FORCE_PUSH=1 + shift + ;; --open) shift OPEN_MODE="${1:-cursor}" @@ -171,6 +178,43 @@ parse_args() { fi } +check_unpushed_commits() { + # Only relevant when deploying a gh: target from a local repo + [[ "$GH_MODE" == "1" ]] || return 0 + + # Check if we're in a git repo + if ! git rev-parse --is-inside-work-tree &>/dev/null; then + return 0 + fi + + local branch upstream ahead + branch=$(git branch --show-current 2>/dev/null) + [[ -n "$branch" ]] || return 0 + + upstream=$(git rev-parse --abbrev-ref "@{upstream}" 2>/dev/null) || { + log_warning "Branch '$branch' has no upstream. Push with: git push -u origin $branch" + if [[ "$FORCE_PUSH" == "1" ]]; then + log_info "Pushing $branch to origin..." + git push -u origin "$branch" + log_success "Pushed $branch" + return 0 + fi + exit 1 + } + + ahead=$(git rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) + if [[ "$ahead" -gt 0 ]]; then + if [[ "$FORCE_PUSH" == "1" ]]; then + log_info "Pushing $ahead commit(s) on $branch to origin..." + git push + log_success "Pushed $ahead commit(s)" + else + log_error "$ahead unpushed commit(s) on $branch. Push first or use --force." + exit 1 + fi + fi +} + detect_editor_cli() { if [[ "$OPEN_MODE" == "none" || "$OPEN_MODE" == "ssh" ]]; then EDITOR_CLI="" @@ -955,6 +999,8 @@ main() { return fi + check_unpushed_commits + detect_editor_cli case "$OPEN_MODE" in cursor|code) log_success "IDE: $EDITOR_CLI" ;; diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index c14a110a..af208710 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -116,6 +116,9 @@ setup() { mock_bin="$(mktemp -d)" printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" chmod +x "$mock_bin/ssh" + # Mock git so check_unpushed_commits passes (0 ahead) + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 # Should get past parse_args (fail at check_ssh, not "Unexpected argument") refute_output --partial "Unexpected argument" @@ -128,6 +131,9 @@ setup() { mock_bin="$(mktemp -d)" printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" chmod +x "$mock_bin/ssh" + # Mock git so check_unpushed_commits passes (0 ahead) + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5:feature/my-branch 2>&1 refute_output --partial "Unexpected argument" assert_output --partial "Cannot connect to" @@ -139,6 +145,9 @@ setup() { mock_bin="$(mktemp -d)" printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" chmod +x "$mock_bin/ssh" + # Mock git so check_unpushed_commits passes (0 ahead) + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver:~/custom/path gh:vig-os/fd5 2>&1 refute_output --partial "Unexpected argument" assert_output --partial "Cannot connect to" @@ -180,6 +189,9 @@ SSHEOF chmod +x "$mock_bin/ssh" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" chmod +x "$mock_bin/scp" + # Mock git so check_unpushed_commits passes (0 ahead) + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 assert_success assert_output --partial "Cloning vig-os/fd5" @@ -213,6 +225,9 @@ SSHEOF chmod +x "$mock_bin/ssh" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" chmod +x "$mock_bin/scp" + # Mock git so check_unpushed_commits passes (0 ahead) + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 assert_success assert_output --partial "Fetching vig-os/fd5" @@ -247,6 +262,9 @@ SSHEOF chmod +x "$mock_bin/ssh" printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" chmod +x "$mock_bin/scp" + # Mock git so check_unpushed_commits passes (0 ahead) + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5:feature/my-branch 2>&1 assert_success assert_output --partial "Checked out feature/my-branch" @@ -866,6 +884,216 @@ SSHEOF # ── remote_compose_up ──────────────────────────────────────────────────────── +# ── --force flag ────────────────────────────────────────────────────────── + +@test "--force flag is accepted" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --force --open cursor myserver 2>&1 + # Should fail at check_ssh, not argument parsing + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "-f flag is accepted as alias for --force" { + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/cursor" + chmod +x "$mock_bin/cursor" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" -f --open cursor myserver 2>&1 + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +# ── check_unpushed_commits ──────────────────────────────────────────────── + +@test "check_unpushed_commits defines function" { + run grep 'check_unpushed_commits()' "$DEVC_REMOTE" + assert_success +} + +@test "check_unpushed_commits is called in main" { + run grep 'check_unpushed_commits' "$DEVC_REMOTE" + assert_success + local count + count=$(grep -c 'check_unpushed_commits' "$DEVC_REMOTE") + [ "$count" -ge 2 ] +} + +@test "check_unpushed_commits skips when no gh: target" { + # Without gh: target, GH_MODE=0 → check_unpushed_commits returns 0 immediately + local mock_bin + mock_bin="$(mktemp -d)" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + # No gh: target — should skip unpushed check and fail at check_ssh + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver 2>&1 + refute_output --partial "unpushed" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +@test "check_unpushed_commits blocks when commits unpushed without --force" { + local mock_bin + mock_bin="$(mktemp -d)" + # Mock git to simulate unpushed commits + cat > "$mock_bin/git" << 'GITEOF' +#!/bin/sh +case "$1" in + rev-parse) + if [ "$2" = "--is-inside-work-tree" ]; then echo "true"; exit 0; fi + if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi + ;; + branch) + if [ "$2" = "--show-current" ]; then echo "main"; exit 0; fi + ;; + rev-list) + echo "3"; exit 0 + ;; +esac +exit 0 +GITEOF + chmod +x "$mock_bin/git" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 + assert_failure + assert_output --partial "unpushed commit(s)" + assert_output --partial "--force" + rm -rf "$mock_bin" +} + +@test "check_unpushed_commits pushes when --force and commits unpushed" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/git" << GITEOF +#!/bin/sh +case "\$1" in + rev-parse) + if [ "\$2" = "--is-inside-work-tree" ]; then echo "true"; exit 0; fi + if [ "\$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi + ;; + branch) + if [ "\$2" = "--show-current" ]; then echo "main"; exit 0; fi + ;; + rev-list) + echo "2"; exit 0 + ;; + push) + echo "PUSHED" >> "${mock_bin}/push_log" + exit 0 + ;; +esac +exit 0 +GITEOF + chmod +x "$mock_bin/git" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --force --open none myserver gh:vig-os/fd5 2>&1 + # Should push and then fail at check_ssh (not at unpushed check) + assert_output --partial "Pushing 2 commit(s)" + assert_output --partial "Cannot connect to" + # Verify git push was called + [ -f "$mock_bin/push_log" ] + rm -rf "$mock_bin" +} + +@test "check_unpushed_commits blocks when no upstream without --force" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/git" << 'GITEOF' +#!/bin/sh +case "$1" in + rev-parse) + if [ "$2" = "--is-inside-work-tree" ]; then echo "true"; exit 0; fi + if [ "$2" = "--abbrev-ref" ]; then exit 1; fi + ;; + branch) + if [ "$2" = "--show-current" ]; then echo "feature/test"; exit 0; fi + ;; +esac +exit 0 +GITEOF + chmod +x "$mock_bin/git" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 + assert_failure + assert_output --partial "has no upstream" + rm -rf "$mock_bin" +} + +@test "check_unpushed_commits pushes -u when no upstream with --force" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/git" << GITEOF +#!/bin/sh +case "\$1" in + rev-parse) + if [ "\$2" = "--is-inside-work-tree" ]; then echo "true"; exit 0; fi + if [ "\$2" = "--abbrev-ref" ]; then exit 1; fi + ;; + branch) + if [ "\$2" = "--show-current" ]; then echo "feature/test"; exit 0; fi + ;; + push) + echo "PUSHED: \$@" >> "${mock_bin}/push_log" + exit 0 + ;; +esac +exit 0 +GITEOF + chmod +x "$mock_bin/git" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --force --open none myserver gh:vig-os/fd5 2>&1 + # Should push -u and continue to check_ssh + assert_output --partial "Pushing feature/test" + assert_output --partial "Cannot connect to" + # Verify push was called with -u + [ -f "$mock_bin/push_log" ] + run grep -- "-u" "$mock_bin/push_log" + assert_success + rm -rf "$mock_bin" +} + +@test "check_unpushed_commits passes when all commits pushed" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/git" << 'GITEOF' +#!/bin/sh +case "$1" in + rev-parse) + if [ "$2" = "--is-inside-work-tree" ]; then echo "true"; exit 0; fi + if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi + ;; + branch) + if [ "$2" = "--show-current" ]; then echo "main"; exit 0; fi + ;; + rev-list) + echo "0"; exit 0 + ;; +esac +exit 0 +GITEOF + chmod +x "$mock_bin/git" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + PATH="$mock_bin:$PATH" run "$DEVC_REMOTE" --open none myserver gh:vig-os/fd5 2>&1 + # Should pass through to check_ssh + refute_output --partial "unpushed" + assert_output --partial "Cannot connect to" + rm -rf "$mock_bin" +} + +# ── remote_compose_up ──────────────────────────────────────────────────────── + @test "remote_compose_up skips when container running and healthy" { local mock_bin mock_bin="$(mktemp -d)" From c209f1d85857c2353ff45e3c87bb0fd03e8d11ea Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 16:33:19 +0100 Subject: [PATCH 064/215] fix(remote): use real TUN for Tailscale SSH instead of userspace networking userspace-networking mode cannot intercept incoming connections, so --ssh was advertised but non-functional. Now setup-tailscale.sh uses real TUN when /dev/net/tun is available and warns otherwise. inject_tailscale_key adds devices + cap_add to remote compose. Template example updated with required entries. Refs: #70 --- CHANGELOG.md | 5 +++ .../.devcontainer/docker-compose.local.yaml | 6 +++ .../.devcontainer/scripts/setup-tailscale.sh | 11 ++++- assets/workspace/scripts/devc-remote.sh | 42 +++++++++---------- scripts/devc-remote.sh | 42 +++++++++---------- 5 files changed, 61 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86aae927..f3ef0f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Tailscale SSH now works inside containers** ([#70](https://github.com/vig-os/devcontainer/issues/70)) + - Use real TUN device instead of `--tun=userspace-networking` (userspace mode cannot serve SSH) + - `setup-tailscale.sh` auto-detects `/dev/net/tun` and warns if missing + - `inject_tailscale_key` adds `devices` + `cap_add` to remote `docker-compose.local.yaml` + - Template example updated with required `devices` and `cap_add` entries - **CI Project Checks coverage includes devc_remote_uri tests** ([#70](https://github.com/vig-os/devcontainer/issues/70)) - Add `tests/test_devc_remote_uri.py` to test-project action pytest run - Add build_uri validation tests for empty devcontainer_path, ssh_host, container_workspace diff --git a/assets/workspace/.devcontainer/docker-compose.local.yaml b/assets/workspace/.devcontainer/docker-compose.local.yaml index be3ea648..7941f78b 100644 --- a/assets/workspace/.devcontainer/docker-compose.local.yaml +++ b/assets/workspace/.devcontainer/docker-compose.local.yaml @@ -25,9 +25,15 @@ # Optional: Tailscale SSH for direct mesh access (e.g. Cursor GUI workaround) # Generate an auth key at https://login.tailscale.com/admin/settings/keys # Use an ephemeral + reusable key so stale containers auto-expire. +# The device + cap_add entries are required for Tailscale SSH to work (real TUN). # # services: # devcontainer: +# devices: +# - /dev/net/tun:/dev/net/tun +# cap_add: +# - NET_ADMIN +# - NET_RAW # environment: # - TAILSCALE_AUTHKEY=tskey-auth-XXXX # - TAILSCALE_HOSTNAME=myproject-devc-mybox # optional override diff --git a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh index 94a86722..2dc51dbb 100755 --- a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh +++ b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh @@ -64,7 +64,16 @@ cmd_start() { echo "Tailscale: starting (hostname=$hostname)..." if ! pgrep -x tailscaled &>/dev/null; then - setsid tailscaled --tun=userspace-networking --state=/var/lib/tailscale/tailscaled.state &>/dev/null & + # Use real TUN if /dev/net/tun exists (required for Tailscale SSH to work). + # Falls back to userspace networking (outbound-only, no SSH server). + local tun_flag="" + if [ ! -c /dev/net/tun ]; then + echo "Tailscale: WARNING — /dev/net/tun not available. SSH into container will NOT work." >&2 + echo "Tailscale: Add 'devices: [\"/dev/net/tun:/dev/net/tun\"]' and 'cap_add: [NET_ADMIN, NET_RAW]' to compose." >&2 + tun_flag="--tun=userspace-networking" + fi + # shellcheck disable=SC2086 + setsid tailscaled $tun_flag --state=/var/lib/tailscale/tailscaled.state &>/dev/null & sleep 2 fi diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 850f964f..bef54452 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -332,43 +332,41 @@ inject_tailscale_key() { fi # Inject into remote docker-compose.local.yaml + # Includes devices + cap_add for real TUN (required for Tailscale SSH) # shellcheck disable=SC2029 ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$auth_key" << 'INJECT_EOF' REPO_PATH="$1" AUTH_KEY="$2" LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" -# Create if missing -if [ ! -f "$LOCAL_YAML" ]; then - cat > "$LOCAL_YAML" << 'YAML' -services: - devcontainer: - environment: - - TAILSCALE_AUTHKEY=PLACEHOLDER -YAML -fi - -# If file has 'services: {}' (empty), replace with proper structure -if grep -q 'services: {}' "$LOCAL_YAML"; then +# Full Tailscale block with TUN device + capabilities for SSH support +write_full_ts_yaml() { cat > "$LOCAL_YAML" << YAML services: devcontainer: + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + - NET_RAW environment: - TAILSCALE_AUTHKEY=${AUTH_KEY} YAML +} + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + write_full_ts_yaml +elif grep -q 'services: {}' "$LOCAL_YAML"; then + write_full_ts_yaml elif grep -q 'TAILSCALE_AUTHKEY' "$LOCAL_YAML"; then + # Update existing key, ensure devices/cap_add present sed -i "s|TAILSCALE_AUTHKEY=.*|TAILSCALE_AUTHKEY=${AUTH_KEY}|" "$LOCAL_YAML" -elif grep -q 'environment:' "$LOCAL_YAML"; then - sed -i "/environment:/a\\ - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" -elif grep -q 'devcontainer:' "$LOCAL_YAML"; then - sed -i "/devcontainer:/a\\ environment:\\n - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" + if ! grep -q '/dev/net/tun' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ devices:\\n - /dev/net/tun:/dev/net/tun\\n cap_add:\\n - NET_ADMIN\\n - NET_RAW" "$LOCAL_YAML" + fi else - cat > "$LOCAL_YAML" << YAML -services: - devcontainer: - environment: - - TAILSCALE_AUTHKEY=${AUTH_KEY} -YAML + write_full_ts_yaml fi INJECT_EOF diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 850f964f..bef54452 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -332,43 +332,41 @@ inject_tailscale_key() { fi # Inject into remote docker-compose.local.yaml + # Includes devices + cap_add for real TUN (required for Tailscale SSH) # shellcheck disable=SC2029 ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$auth_key" << 'INJECT_EOF' REPO_PATH="$1" AUTH_KEY="$2" LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" -# Create if missing -if [ ! -f "$LOCAL_YAML" ]; then - cat > "$LOCAL_YAML" << 'YAML' -services: - devcontainer: - environment: - - TAILSCALE_AUTHKEY=PLACEHOLDER -YAML -fi - -# If file has 'services: {}' (empty), replace with proper structure -if grep -q 'services: {}' "$LOCAL_YAML"; then +# Full Tailscale block with TUN device + capabilities for SSH support +write_full_ts_yaml() { cat > "$LOCAL_YAML" << YAML services: devcontainer: + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + - NET_RAW environment: - TAILSCALE_AUTHKEY=${AUTH_KEY} YAML +} + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + write_full_ts_yaml +elif grep -q 'services: {}' "$LOCAL_YAML"; then + write_full_ts_yaml elif grep -q 'TAILSCALE_AUTHKEY' "$LOCAL_YAML"; then + # Update existing key, ensure devices/cap_add present sed -i "s|TAILSCALE_AUTHKEY=.*|TAILSCALE_AUTHKEY=${AUTH_KEY}|" "$LOCAL_YAML" -elif grep -q 'environment:' "$LOCAL_YAML"; then - sed -i "/environment:/a\\ - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" -elif grep -q 'devcontainer:' "$LOCAL_YAML"; then - sed -i "/devcontainer:/a\\ environment:\\n - TAILSCALE_AUTHKEY=${AUTH_KEY}" "$LOCAL_YAML" + if ! grep -q '/dev/net/tun' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ devices:\\n - /dev/net/tun:/dev/net/tun\\n cap_add:\\n - NET_ADMIN\\n - NET_RAW" "$LOCAL_YAML" + fi else - cat > "$LOCAL_YAML" << YAML -services: - devcontainer: - environment: - - TAILSCALE_AUTHKEY=${AUTH_KEY} -YAML + write_full_ts_yaml fi INJECT_EOF From 501b0ef4ed10aa54e572604f9a87729217c19339 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 16:36:48 +0100 Subject: [PATCH 065/215] fix(remote): ensure TUN device config added to existing Tailscale compose files When TAILSCALE_AUTHKEY already existed in docker-compose.local.yaml, inject_tailscale_key returned early without adding the required devices + cap_add entries for real TUN networking. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 10 +++++++++- scripts/devc-remote.sh | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index bef54452..680ae750 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -273,9 +273,17 @@ inject_tailscale_key() { return 0 fi - # Check if key already set on remote + # Check if key already set on remote — still need to ensure TUN device config # shellcheck disable=SC2029 if ssh "$SSH_HOST" "grep -q 'TAILSCALE_AUTHKEY' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + # Ensure TUN device + capabilities are present even if key already set + # shellcheck disable=SC2029 + if ! ssh "$SSH_HOST" "grep -q '/dev/net/tun' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + log_info "Tailscale: adding TUN device config to existing compose..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "sed -i '/devcontainer:/a\\ devices:\\n - /dev/net/tun:/dev/net/tun\\n cap_add:\\n - NET_ADMIN\\n - NET_RAW' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml'" + log_success "Tailscale: TUN device config added" + fi log_info "Tailscale: auth key already configured on remote" return 0 fi diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index bef54452..680ae750 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -273,9 +273,17 @@ inject_tailscale_key() { return 0 fi - # Check if key already set on remote + # Check if key already set on remote — still need to ensure TUN device config # shellcheck disable=SC2029 if ssh "$SSH_HOST" "grep -q 'TAILSCALE_AUTHKEY' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + # Ensure TUN device + capabilities are present even if key already set + # shellcheck disable=SC2029 + if ! ssh "$SSH_HOST" "grep -q '/dev/net/tun' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + log_info "Tailscale: adding TUN device config to existing compose..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "sed -i '/devcontainer:/a\\ devices:\\n - /dev/net/tun:/dev/net/tun\\n cap_add:\\n - NET_ADMIN\\n - NET_RAW' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml'" + log_success "Tailscale: TUN device config added" + fi log_info "Tailscale: auth key already configured on remote" return 0 fi From 0b0bcefce8047863db6ab4700e977655614fe7cb Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 16:55:53 +0100 Subject: [PATCH 066/215] fix(remote): always regenerate Tailscale key and prefer podman-compose Expired ephemeral keys in docker-compose.local.yaml blocked redeploys because inject_tailscale_key skipped when any TAILSCALE_AUTHKEY existed. Now always regenerates via OAuth. Also prefers podman-compose (Python) over docker-compose bridge which drops devices/cap_add on podman <5. Refs: #70 --- CHANGELOG.md | 5 + .../.devcontainer/scripts/devc-remote.sh | 1057 +++++++++++++++++ assets/workspace/scripts/devc-remote.sh | 47 +- scripts/devc-remote.sh | 47 +- 4 files changed, 1114 insertions(+), 42 deletions(-) create mode 100755 assets/workspace/.devcontainer/scripts/devc-remote.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ef0f93..0caf5db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,8 +50,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Tailscale SSH now works inside containers** ([#70](https://github.com/vig-os/devcontainer/issues/70)) - Use real TUN device instead of `--tun=userspace-networking` (userspace mode cannot serve SSH) - `setup-tailscale.sh` auto-detects `/dev/net/tun` and warns if missing + - `inject_tailscale_key` always regenerates ephemeral auth key (expired keys no longer block deploys) - `inject_tailscale_key` adds `devices` + `cap_add` to remote `docker-compose.local.yaml` - Template example updated with required `devices` and `cap_add` entries +- **Compose tooling prefers `podman-compose` over docker-compose bridge** ([#70](https://github.com/vig-os/devcontainer/issues/70)) + - `podman-compose` (Python) correctly passes `devices`/`cap_add` to podman CLI + - `docker-compose` v5.1.0 as podman external provider drops these fields (known bug) + - Remote preflight now detects `podman-compose` > `podman compose` > `docker compose` - **CI Project Checks coverage includes devc_remote_uri tests** ([#70](https://github.com/vig-os/devcontainer/issues/70)) - Add `tests/test_devc_remote_uri.py` to test-project action pytest run - Add build_uri validation tests for empty devcontainer_path, ssh_host, container_workspace diff --git a/assets/workspace/.devcontainer/scripts/devc-remote.sh b/assets/workspace/.devcontainer/scripts/devc-remote.sh new file mode 100755 index 00000000..ad7d5270 --- /dev/null +++ b/assets/workspace/.devcontainer/scripts/devc-remote.sh @@ -0,0 +1,1057 @@ +#!/usr/bin/env bash +############################################################################### +# devc-remote.sh - Remote devcontainer orchestrator +# +# Starts a devcontainer on a remote host via SSH and optionally opens an IDE. +# Handles SSH connectivity, pre-flight checks, container state detection, +# compose lifecycle, and optional Tailscale auth key injection. +# +# USAGE: +# ./scripts/devc-remote.sh [options] [:] [gh:/[:]] +# ./scripts/devc-remote.sh --bootstrap [--yes] +# ./scripts/devc-remote.sh --help +# +# Options: +# --bootstrap One-time remote host setup (config, GHCR auth, image build) +# --yes, -y Auto-accept prompts (use defaults without asking) +# --force, -f Auto-push unpushed commits before deploying (gh: targets) +# --open How to connect after compose up: +# auto - detect IDE from $TERM_PROGRAM or CLI availability (default) +# cursor - open Cursor via devcontainer protocol +# code - open VS Code via devcontainer protocol +# ssh - wait for Tailscale, print hostname (for SSH clients) +# none - infra only, no IDE +# +# GitHub repo target (gh:): +# Clone a GitHub repo on the remote host and start its devcontainer. +# gh:/ Clone to / (from config or ~/Projects) +# gh:/: Clone and checkout specified branch +# Combined with host:path to override clone location: +# : gh:/ Clone to instead of default +# +# Tailscale key injection (opt-in): +# When TS_CLIENT_ID and TS_CLIENT_SECRET are set in the local environment, +# generates an ephemeral auth key via the Tailscale API and injects it +# into the remote docker-compose.local.yaml before compose up. +# +# Examples: +# ./scripts/devc-remote.sh myserver +# ./scripts/devc-remote.sh --open none myserver:/home/user/repo +# ./scripts/devc-remote.sh --open ssh myserver +# ./scripts/devc-remote.sh --yes --open code user@host:/opt/projects/myrepo +# ./scripts/devc-remote.sh myserver gh:vig-os/fd5 +# ./scripts/devc-remote.sh myserver gh:vig-os/fd5:feature/my-branch +# ./scripts/devc-remote.sh myserver:~/custom/path gh:vig-os/fd5 +# +# Part of #70. See issues #152, #230, #231, #236 for design. +############################################################################### + +set -euo pipefail + +# ═══════════════════════════════════════════════════════════════════════════════ +# CONFIGURATION +# ═══════════════════════════════════════════════════════════════════════════════ + +# shellcheck disable=SC2034 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ═══════════════════════════════════════════════════════════════════════════════ +# LOGGING (matches init.sh patterns) +# ═══════════════════════════════════════════════════════════════════════════════ + +log_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +log_error() { + echo -e "${RED}✗${NC} $1" +} + +show_help() { + sed -n '/^###############################################################################$/,/^###############################################################################$/p' "$0" | sed '1d;$d' + exit 0 +} + +parse_args() { + SSH_HOST="" + REMOTE_PATH="~" + YES_MODE=0 + OPEN_MODE="auto" + BOOTSTRAP_MODE=0 + FORCE_PUSH=0 + GH_REPO="" + GH_BRANCH="" + GH_MODE=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help + ;; + --bootstrap) + BOOTSTRAP_MODE=1 + shift + ;; + --yes|-y) + # shellcheck disable=SC2034 + YES_MODE=1 + shift + ;; + --force|-f) + # shellcheck disable=SC2034 + FORCE_PUSH=1 + shift + ;; + --open) + shift + OPEN_MODE="${1:-cursor}" + if [[ "$OPEN_MODE" != "auto" && "$OPEN_MODE" != "cursor" && "$OPEN_MODE" != "code" && "$OPEN_MODE" != "ssh" && "$OPEN_MODE" != "none" ]]; then + log_error "--open must be auto, cursor, code, ssh, or none" + exit 1 + fi + shift + ;; + -*) + log_error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + gh:*) + # gh:org/repo or gh:org/repo:branch + local gh_target="${1#gh:}" + if [[ -z "$gh_target" || "$gh_target" != */* ]]; then + log_error "Invalid gh: target. Use gh:org/repo or gh:org/repo:branch" + exit 1 + fi + # shellcheck disable=SC2034 + GH_MODE=1 + # Split on first colon after org/repo (branch may contain slashes) + if [[ "$gh_target" =~ ^([^:]+):(.+)$ ]]; then + # shellcheck disable=SC2034 + GH_REPO="${BASH_REMATCH[1]}" + # shellcheck disable=SC2034 + GH_BRANCH="${BASH_REMATCH[2]}" + else + # shellcheck disable=SC2034 + GH_REPO="$gh_target" + fi + shift + ;; + *) + if [[ -n "$SSH_HOST" ]]; then + log_error "Unexpected argument: $1" + exit 1 + fi + # Parse SSH-style format: user@host:path or host:path + if [[ "$1" =~ ^([^:]+):(.+)$ ]]; then + SSH_HOST="${BASH_REMATCH[1]}" + REMOTE_PATH="${BASH_REMATCH[2]}" + else + SSH_HOST="$1" + # Default to ~ (expanded by remote shell) if no path specified + REMOTE_PATH="~" + fi + shift + ;; + esac + done + + if [[ -z "$SSH_HOST" ]]; then + log_error "Missing required argument: [:]" + echo "Use --help for usage information" + exit 1 + fi +} + +check_unpushed_commits() { + # Only relevant when deploying a gh: target from a local repo + [[ "$GH_MODE" == "1" ]] || return 0 + + # Check if we're in a git repo + if ! git rev-parse --is-inside-work-tree &>/dev/null; then + return 0 + fi + + local branch upstream ahead + branch=$(git branch --show-current 2>/dev/null) + [[ -n "$branch" ]] || return 0 + + upstream=$(git rev-parse --abbrev-ref "@{upstream}" 2>/dev/null) || { + log_warning "Branch '$branch' has no upstream. Push with: git push -u origin $branch" + if [[ "$FORCE_PUSH" == "1" ]]; then + log_info "Pushing $branch to origin..." + git push -u origin "$branch" + log_success "Pushed $branch" + return 0 + fi + exit 1 + } + + ahead=$(git rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) + if [[ "$ahead" -gt 0 ]]; then + if [[ "$FORCE_PUSH" == "1" ]]; then + log_info "Pushing $ahead commit(s) on $branch to origin..." + git push + log_success "Pushed $ahead commit(s)" + else + log_error "$ahead unpushed commit(s) on $branch. Push first or use --force." + exit 1 + fi + fi +} + +detect_editor_cli() { + if [[ "$OPEN_MODE" == "none" || "$OPEN_MODE" == "ssh" ]]; then + EDITOR_CLI="" + return + fi + + # Auto-detect: check TERM_PROGRAM, then fall back to CLI availability + if [[ "$OPEN_MODE" == "auto" ]]; then + case "${TERM_PROGRAM:-}" in + cursor|Cursor) + OPEN_MODE="cursor" ;; + vscode|VSCode) + OPEN_MODE="code" ;; + WezTerm|iTerm*|Apple_Terminal|tmux) + # Terminal app — no devcontainer protocol, default to ssh + OPEN_MODE="ssh" ;; + esac + fi + + # Still auto? Fall back to CLI availability + if [[ "$OPEN_MODE" == "auto" ]]; then + if command -v cursor &>/dev/null; then + OPEN_MODE="cursor" + elif command -v code &>/dev/null; then + OPEN_MODE="code" + else + OPEN_MODE="ssh" + log_info "No IDE CLI found, falling back to --open ssh" + fi + fi + + if [[ "$OPEN_MODE" == "cursor" ]]; then + if command -v cursor &>/dev/null; then + EDITOR_CLI="cursor" + else + log_error "cursor CLI not found. Install Cursor and enable the shell command, or use --open code|ssh|none." + exit 1 + fi + elif [[ "$OPEN_MODE" == "code" ]]; then + if command -v code &>/dev/null; then + EDITOR_CLI="code" + else + log_error "code CLI not found. Install VS Code and enable the shell command, or use --open cursor|ssh|none." + exit 1 + fi + fi +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# TAILSCALE KEY INJECTION (opt-in via TS_CLIENT_ID + TS_CLIENT_SECRET) +# ═══════════════════════════════════════════════════════════════════════════════ + +inject_tailscale_key() { + # Skip if no OAuth credentials + if [[ -z "${TS_CLIENT_ID:-}" || -z "${TS_CLIENT_SECRET:-}" ]]; then + return 0 + fi + + # Always regenerate — ephemeral keys may have expired since last deploy. + + # Verify local prerequisites + if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then + log_warning "Tailscale: curl and jq required for key generation, skipping" + return 0 + fi + + log_info "Tailscale: generating ephemeral auth key..." + + # Get OAuth access token + local token_response token + token_response=$(curl -s -f \ + -d "client_id=$TS_CLIENT_ID" \ + -d "client_secret=$TS_CLIENT_SECRET" \ + "https://api.tailscale.com/api/v2/oauth/token" 2>&1) || { + log_warning "Tailscale: failed to get OAuth token, skipping" + return 0 + } + token=$(echo "$token_response" | jq -r '.access_token // empty') + if [[ -z "$token" ]]; then + log_warning "Tailscale: empty access token, skipping" + return 0 + fi + + # Create ephemeral, non-reusable auth key + local key_response auth_key + key_response=$(curl -s -f -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d '{ + "capabilities": { + "devices": { + "create": { + "reusable": false, + "ephemeral": true, + "tags": ["tag:devc"] + } + } + } + }' \ + "https://api.tailscale.com/api/v2/tailnet/-/keys" 2>&1) || { + log_warning "Tailscale: failed to create auth key, skipping" + return 0 + } + auth_key=$(echo "$key_response" | jq -r '.key // empty') + if [[ -z "$auth_key" ]]; then + local err_msg + err_msg=$(echo "$key_response" | jq -r '.message // empty') + log_warning "Tailscale: API error: ${err_msg:-unknown}, skipping" + return 0 + fi + + # Inject into remote docker-compose.local.yaml + # Includes devices + cap_add for real TUN (required for Tailscale SSH) + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$auth_key" << 'INJECT_EOF' +REPO_PATH="$1" +AUTH_KEY="$2" +LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" + +# Full Tailscale block with TUN device + capabilities for SSH support +write_full_ts_yaml() { + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + - NET_RAW + environment: + - TAILSCALE_AUTHKEY=${AUTH_KEY} +YAML +} + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + write_full_ts_yaml +elif grep -q 'services: {}' "$LOCAL_YAML"; then + write_full_ts_yaml +elif grep -q 'TAILSCALE_AUTHKEY' "$LOCAL_YAML"; then + # Update existing key, ensure devices/cap_add present + sed -i "s|TAILSCALE_AUTHKEY=.*|TAILSCALE_AUTHKEY=${AUTH_KEY}|" "$LOCAL_YAML" + if ! grep -q '/dev/net/tun' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ devices:\\n - /dev/net/tun:/dev/net/tun\\n cap_add:\\n - NET_ADMIN\\n - NET_RAW" "$LOCAL_YAML" + fi +else + write_full_ts_yaml +fi +INJECT_EOF + + log_success "Tailscale: ephemeral auth key injected into remote compose" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# CLAUDE CODE AUTH INJECTION (opt-in via CLAUDE_CODE_OAUTH_TOKEN) +# ═══════════════════════════════════════════════════════════════════════════════ + +inject_claude_auth() { + # Skip if no OAuth token in local environment + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + return 0 + fi + + # Check if token already set on remote + # shellcheck disable=SC2029 + if ssh "$SSH_HOST" "grep -q 'CLAUDE_CODE_OAUTH_TOKEN' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then + log_info "Claude: OAuth token already configured on remote" + return 0 + fi + + log_info "Claude: injecting OAuth token into remote compose..." + + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" "$CLAUDE_CODE_OAUTH_TOKEN" << 'INJECT_EOF' +REPO_PATH="$1" +TOKEN="$2" +LOCAL_YAML="$REPO_PATH/.devcontainer/docker-compose.local.yaml" + +# Create if missing +if [ ! -f "$LOCAL_YAML" ]; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +elif grep -q 'services: {}' "$LOCAL_YAML"; then + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +elif grep -q 'CLAUDE_CODE_OAUTH_TOKEN' "$LOCAL_YAML"; then + sed -i "s|CLAUDE_CODE_OAUTH_TOKEN=.*|CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}|" "$LOCAL_YAML" +elif grep -q 'environment:' "$LOCAL_YAML"; then + sed -i "/environment:/a\\ - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}" "$LOCAL_YAML" +elif grep -q 'devcontainer:' "$LOCAL_YAML"; then + sed -i "/devcontainer:/a\\ environment:\\n - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN}" "$LOCAL_YAML" +else + cat > "$LOCAL_YAML" << YAML +services: + devcontainer: + environment: + - CLAUDE_CODE_OAUTH_TOKEN=${TOKEN} +YAML +fi +INJECT_EOF + + log_success "Claude: OAuth token injected into remote compose" +} + +check_ssh() { + if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" true 2>/dev/null; then + log_error "Cannot connect to $SSH_HOST. Check your SSH config and network." + exit 1 + fi +} + +remote_clone_project() { + [[ "$GH_MODE" == "1" ]] || return 0 + + log_info "Cloning $GH_REPO on $SSH_HOST..." + + local clone_output + # shellcheck disable=SC2029 + # Use sentinels for empty/default args — SSH drops empty strings and expands ~ + local _branch="${GH_BRANCH:-_NONE_}" + local _path="${REMOTE_PATH}" + [[ "$_path" == "~" ]] && _path="_DEFAULT_" + # shellcheck disable=SC2029 + clone_output=$(ssh "$SSH_HOST" "bash -s" "$GH_REPO" "$_branch" "$_path" << 'CLONEEOF' +GH_REPO="$1" +GH_BRANCH="$2" +[ "$GH_BRANCH" = "_NONE_" ] && GH_BRANCH="" +USER_PATH="$3" +[ "$USER_PATH" = "_DEFAULT_" ] && USER_PATH="" +REPO_NAME="${GH_REPO##*/}" + +# Resolve target directory +if [ -n "$USER_PATH" ]; then + TARGET_DIR="$USER_PATH" +else + # Read projects_dir from config, fallback to ~/Projects + PROJECTS_DIR="$HOME/Projects" + CONFIG_FILE="$HOME/.config/devc-remote/config.yaml" + if [ -f "$CONFIG_FILE" ]; then + CONFIGURED_DIR=$(sed -n 's/^projects_dir: *//p' "$CONFIG_FILE") + [ -n "$CONFIGURED_DIR" ] && PROJECTS_DIR="${CONFIGURED_DIR/#\~/$HOME}" + fi + TARGET_DIR="$PROJECTS_DIR/$REPO_NAME" +fi + +# Clone or fetch +CLONE_STATUS="fetched" +if [ ! -d "$TARGET_DIR/.git" ]; then + git clone "https://github.com/${GH_REPO}.git" "$TARGET_DIR" + CLONE_STATUS="cloned" +else + cd "$TARGET_DIR" && git fetch +fi + +# Checkout branch if specified +if [ -n "$GH_BRANCH" ]; then + cd "$TARGET_DIR" && git checkout "$GH_BRANCH" + echo "CLONE_BRANCH=$GH_BRANCH" +fi + +echo "CLONE_PATH=$TARGET_DIR" +echo "CLONE_STATUS=$CLONE_STATUS" +CLONEEOF + ) + + local clone_path="" clone_status="" clone_branch="" + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + CLONE_PATH) clone_path="${BASH_REMATCH[2]}" ;; + CLONE_STATUS) clone_status="${BASH_REMATCH[2]}" ;; + CLONE_BRANCH) clone_branch="${BASH_REMATCH[2]}" ;; + esac + done <<< "$clone_output" + + if [[ -n "$clone_path" ]]; then + REMOTE_PATH="$clone_path" + fi + + if [[ "$clone_status" == "cloned" ]]; then + log_success "Cloning $GH_REPO — cloned to $clone_path" + else + log_success "Fetching $GH_REPO — updated at $clone_path" + fi + + if [[ -n "$clone_branch" ]]; then + log_success "Checked out $clone_branch" + fi +} + +remote_preflight() { + local preflight_output + # shellcheck disable=SC2029 + preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' +REPO_PATH="${1:-$HOME}" +if command -v podman &>/dev/null; then + echo "RUNTIME=podman" +elif command -v docker &>/dev/null; then + echo "RUNTIME=docker" +else + echo "RUNTIME=" +fi +# Detect compose tool — prefer podman-compose (Python, passes devices/cap_add) +# over podman compose (docker-compose bridge, drops some fields on podman <5) +if command -v podman-compose &>/dev/null; then + echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=podman-compose" +elif command -v podman &>/dev/null && podman compose version &>/dev/null; then + echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=podman-compose-plugin" +elif command -v docker &>/dev/null && docker compose version &>/dev/null; then + echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=docker-compose-plugin" +else + echo "COMPOSE_AVAILABLE=0" + echo "COMPOSE_TOOL=" +fi +if [ -d "$REPO_PATH" ]; then + echo "REPO_PATH_EXISTS=1" +else + echo "REPO_PATH_EXISTS=0" +fi +if [ -d "$REPO_PATH/.devcontainer" ]; then + echo "DEVCONTAINER_EXISTS=1" +else + echo "DEVCONTAINER_EXISTS=0" +fi +AVAIL_GB=$(df -BG "$REPO_PATH" 2>/dev/null | awk 'NR==2 {gsub(/G/,""); print $4}') +echo "DISK_AVAILABLE_GB=${AVAIL_GB:-0}" +if [ "$(uname -s)" = "Darwin" ]; then + echo "OS_TYPE=macos" +else + echo "OS_TYPE=linux" +fi +# Detect container socket path +if [ -S /var/run/docker.sock ]; then + echo "SOCKET_PATH=/var/run/docker.sock" +elif [ -S "/run/user/$(id -u)/podman/podman.sock" ]; then + echo "SOCKET_PATH=/run/user/$(id -u)/podman/podman.sock" +else + echo "SOCKET_PATH=" +fi +REMOTEEOF + ) + + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + RUNTIME) RUNTIME="${BASH_REMATCH[2]}" ;; + COMPOSE_AVAILABLE) COMPOSE_AVAILABLE="${BASH_REMATCH[2]}" ;; + COMPOSE_TOOL) COMPOSE_TOOL="${BASH_REMATCH[2]}" ;; + REPO_PATH_EXISTS) REPO_PATH_EXISTS="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; + DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; + OS_TYPE) OS_TYPE="${BASH_REMATCH[2]}" ;; + SOCKET_PATH) SOCKET_PATH="${BASH_REMATCH[2]}" ;; + esac + done <<< "$preflight_output" + + if [[ -z "${RUNTIME:-}" ]]; then + log_error "No container runtime found on $SSH_HOST. Install podman or docker." + exit 1 + fi + # Set compose command based on detected tool + case "${COMPOSE_TOOL:-}" in + podman-compose) COMPOSE_CMD="podman-compose" ;; + podman-compose-plugin) COMPOSE_CMD="podman compose" ;; + docker-compose-plugin) COMPOSE_CMD="docker compose" ;; + *) + if [[ "$RUNTIME" == "podman" ]]; then + COMPOSE_CMD="podman compose" + else + COMPOSE_CMD="docker compose" + fi + ;; + esac + if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then + log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." + exit 1 + fi + if [[ "${REPO_PATH_EXISTS:-0}" != "1" ]]; then + log_error "Repository not found at $REMOTE_PATH on $SSH_HOST." + exit 1 + fi + if [[ "${DEVCONTAINER_EXISTS:-0}" != "1" ]]; then + log_error "No .devcontainer/ found in $REMOTE_PATH. Is this a devcontainer-enabled project?" + exit 1 + fi + if [[ "${DISK_AVAILABLE_GB:-0}" -lt 2 ]] 2>/dev/null; then + log_warning "Low disk space on $SSH_HOST (${DISK_AVAILABLE_GB:-0}GB). At least 2GB recommended." + fi + if [[ "${OS_TYPE:-}" == "macos" ]]; then + log_warning "Remote host is macOS. Devcontainer support may be limited." + fi +} + +prepare_remote() { + local devc_dir="$REMOTE_PATH/.devcontainer" + + # Write container socket path to .env for compose interpolation + if [[ -n "${SOCKET_PATH:-}" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "echo 'CONTAINER_SOCKET_PATH=$SOCKET_PATH' > $devc_dir/.env" + log_info "Container socket: $SOCKET_PATH" + fi + + # Create stub docker-compose.local.yaml if missing + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "test -f $devc_dir/docker-compose.local.yaml || echo -e '---\nservices: {}' > $devc_dir/docker-compose.local.yaml" +} + +read_compose_files() { + # Read dockerComposeFile array from devcontainer.json on remote host + local raw + # shellcheck disable=SC2029 + # shellcheck disable=SC2029 + raw=$(ssh "$SSH_HOST" \ + "python3 -c \" +import json, os, sys +path = os.path.expanduser('${REMOTE_PATH}/.devcontainer/devcontainer.json') +with open(path) as f: + data = json.load(f) +files = data.get('dockerComposeFile', ['docker-compose.yml']) +if isinstance(files, str): + files = [files] +for f in files: + print(f) +\" 2>/dev/null" || echo "") + if [[ -z "$raw" ]]; then + echo "docker-compose.yml" + return + fi + echo "$raw" +} + +compose_cmd_with_files() { + # Build compose command with -f flags for each compose file + local cmd="$COMPOSE_CMD" + local file + while IFS= read -r file; do + [[ -n "$file" ]] && cmd="$cmd -f $file" + done < <(read_compose_files) + echo "$cmd" +} + +remote_compose_up() { + local ps_output state health compose_full + compose_full=$(compose_cmd_with_files) + local devc_dir="$REMOTE_PATH/.devcontainer" + + # shellcheck disable=SC2029 + ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + # shellcheck disable=SC2034 + health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + + if [[ "$state" == "running" ]]; then + log_success "Devcontainer already running on $SSH_HOST" + CONTAINER_FRESH=0 + else + log_info "Starting devcontainer on $SSH_HOST..." + # shellcheck disable=SC2029 + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then + log_error "Failed to start devcontainer on $SSH_HOST." + log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" + exit 1 + fi + sleep 2 + CONTAINER_FRESH=1 + fi +} + +run_container_lifecycle() { + local compose_full devc_dir workspace_folder scripts_dir + compose_full=$(compose_cmd_with_files) + devc_dir="$REMOTE_PATH/.devcontainer" + workspace_folder=$(read_workspace_folder) + scripts_dir="$workspace_folder/.devcontainer/scripts" + + local has_scripts + # shellcheck disable=SC2029 + has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") + + if [[ "$has_scripts" != "1" ]]; then + log_info "No lifecycle scripts found at $scripts_dir — skipping" + return 0 + fi + + # post-create: one-time setup (git, precommit, tailscale install, deps) + if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then + log_info "Running post-create lifecycle (first start)..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + /bin/bash $scripts_dir/post-create.sh" 2>&1 || { + log_warning "post-create.sh failed (non-fatal, container still running)" + } + fi + + # post-start: every-start setup (socket perms, deps sync, tailscale start) + local has_post_start + # shellcheck disable=SC2029 + has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") + + if [[ "$has_post_start" == "1" ]]; then + log_info "Running post-start lifecycle..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + /bin/bash $scripts_dir/post-start.sh" 2>&1 || { + log_warning "post-start.sh failed (non-fatal, container still running)" + } + fi +} + +read_workspace_folder() { + # Read workspaceFolder from devcontainer.json on remote host + local folder + # shellcheck disable=SC2029 + folder=$(ssh "$SSH_HOST" \ + "grep -o '\"workspaceFolder\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \ + ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" \ + | sed 's/.*: *"//;s/"//' || echo "/workspace") + echo "${folder:-/workspace}" +} + +open_editor() { + local container_workspace uri + container_workspace=$(read_workspace_folder) + + # Build URI using Python helper + uri=$(python3 "$SCRIPT_DIR/devc_remote_uri.py" \ + "$REMOTE_PATH" \ + "$SSH_HOST" \ + "$container_workspace") + + "$EDITOR_CLI" --folder-uri "$uri" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# TAILSCALE WAIT + SSH OUTPUT +# ═══════════════════════════════════════════════════════════════════════════════ + +wait_for_tailscale() { + if ! command -v tailscale &>/dev/null; then + log_error "tailscale CLI not found locally. Install Tailscale to use --open ssh." + exit 1 + fi + + # Derive expected hostname pattern from devcontainer.json name field + local devc_name + # shellcheck disable=SC2029 + devc_name=$(ssh "$SSH_HOST" \ + "python3 -c \"import json,sys; print(json.load(sys.stdin).get('name',''))\" \ + < ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" || true) + devc_name="${devc_name:-devc}" + + log_info "Tailscale: waiting for container to join tailnet (pattern: *${devc_name}*)..." + + local ip hostname + for _ in $(seq 1 30); do + # Query local tailscale for peers matching the devc hostname pattern + local ts_status + ts_status=$(tailscale status --json 2>/dev/null || true) + if [[ -n "$ts_status" ]]; then + # Find an online peer whose hostname contains the devc name + local match + match=$(echo "$ts_status" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for peer in (data.get('Peer') or {}).values(): + if peer.get('Online') and '${devc_name}' in peer.get('HostName', ''): + ips = peer.get('TailscaleIPs', []) + print(peer['HostName'] + ' ' + (ips[0] if ips else '')) + break +" 2>/dev/null || true) + + if [[ -n "$match" ]]; then + hostname="${match%% *}" + ip="${match#* }" + log_success "Tailscale: container online as ${hostname} (${ip})" + # Output connection info to stdout (for scripting) + echo "" + echo "Connect via:" + echo " ssh root@${hostname}" + echo " ssh root@${ip}" + echo "" + echo "Cursor: cursor --remote ssh-remote+root@${hostname} $(read_workspace_folder)" + echo "VS Code: code --remote ssh-remote+root@${hostname} $(read_workspace_folder)" + return 0 + fi + fi + sleep 2 + done + + log_warning "Tailscale: container did not appear on tailnet within 60s" + log_warning "Check that TAILSCALE_AUTHKEY is set and Tailscale ACLs allow SSH" + return 1 +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# BOOTSTRAP (one-time remote host setup) +# ═══════════════════════════════════════════════════════════════════════════════ + +bootstrap_check_config() { + # Check if config exists on remote, read values if so + local config_output + # shellcheck disable=SC2029 + config_output=$(ssh "$SSH_HOST" "bash -s" << 'CFGEOF' +CONFIG_DIR="$HOME/.config/devc-remote" +CONFIG_FILE="$CONFIG_DIR/config.yaml" +if [ -f "$CONFIG_FILE" ]; then + echo "CONFIG_EXISTS=1" + # Parse simple flat YAML (key: value) using sed + sed -n 's/^projects_dir: *//p' "$CONFIG_FILE" | while read -r v; do echo "PROJECTS_DIR=$v"; done + sed -n 's/^devcontainer_repo: *//p' "$CONFIG_FILE" | while read -r v; do echo "DEVCONTAINER_REPO=$v"; done + sed -n 's/^devcontainer_path: *//p' "$CONFIG_FILE" | while read -r v; do echo "DEVCONTAINER_PATH=$v"; done + sed -n 's/^image_tag: *//p' "$CONFIG_FILE" | while read -r v; do echo "IMAGE_TAG=$v"; done + sed -n 's/^registry: *//p' "$CONFIG_FILE" | while read -r v; do echo "REGISTRY=$v"; done +else + echo "CONFIG_EXISTS=0" +fi +CFGEOF + ) + + CONFIG_EXISTS=0 + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z_]+)=(.*)$ ]] || continue + case "${BASH_REMATCH[1]}" in + CONFIG_EXISTS) CONFIG_EXISTS="${BASH_REMATCH[2]}" ;; + PROJECTS_DIR) BOOTSTRAP_PROJECTS_DIR="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_REPO) BOOTSTRAP_DEVC_REPO="${BASH_REMATCH[2]}" ;; + DEVCONTAINER_PATH) BOOTSTRAP_DEVC_PATH="${BASH_REMATCH[2]}" ;; + IMAGE_TAG) BOOTSTRAP_IMAGE_TAG="${BASH_REMATCH[2]}" ;; + REGISTRY) BOOTSTRAP_REGISTRY="${BASH_REMATCH[2]}" ;; + esac + done <<< "$config_output" +} + +bootstrap_prompt_config() { + # Set defaults + BOOTSTRAP_PROJECTS_DIR="${BOOTSTRAP_PROJECTS_DIR:-~/Projects}" + BOOTSTRAP_DEVC_REPO="${BOOTSTRAP_DEVC_REPO:-vig-os/devcontainer}" + BOOTSTRAP_IMAGE_TAG="${BOOTSTRAP_IMAGE_TAG:-dev}" + BOOTSTRAP_REGISTRY="${BOOTSTRAP_REGISTRY:-ghcr.io/vig-os/devcontainer}" + + if [[ "$YES_MODE" == "0" ]]; then + log_info "No devc-remote config found on $SSH_HOST." + read -rp "Where should projects be cloned? [$BOOTSTRAP_PROJECTS_DIR]: " user_input + BOOTSTRAP_PROJECTS_DIR="${user_input:-$BOOTSTRAP_PROJECTS_DIR}" + fi + + # Derive devcontainer_path from projects_dir + BOOTSTRAP_DEVC_PATH="${BOOTSTRAP_PROJECTS_DIR}/devcontainer" +} + +bootstrap_write_config() { + # Write config file on remote + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$BOOTSTRAP_PROJECTS_DIR" "$BOOTSTRAP_DEVC_REPO" "$BOOTSTRAP_DEVC_PATH" "$BOOTSTRAP_IMAGE_TAG" "$BOOTSTRAP_REGISTRY" << 'WRITEEOF' +PROJECTS_DIR="$1" +DEVC_REPO="$2" +DEVC_PATH="$3" +IMAGE_TAG="$4" +REGISTRY="$5" +CONFIG_DIR="$HOME/.config/devc-remote" +CONFIG_FILE="$CONFIG_DIR/config.yaml" +mkdir -p "$CONFIG_DIR" +cat > "$CONFIG_FILE" << YAML +projects_dir: ${PROJECTS_DIR} +devcontainer_repo: ${DEVC_REPO} +devcontainer_path: ${DEVC_PATH} +image_tag: ${IMAGE_TAG} +registry: ${REGISTRY} +YAML +WRITEEOF + + log_success "Config written to ~/.config/devc-remote/config.yaml — edit to customize." +} + +forward_ghcr_auth() { + # Forward container registry credentials to remote + local local_auth="" + + # Check podman auth first, then docker + if [[ -f "${HOME}/.config/containers/auth.json" ]]; then + local_auth="${HOME}/.config/containers/auth.json" + elif [[ -f "${HOME}/.docker/config.json" ]]; then + local_auth="${HOME}/.docker/config.json" + elif [[ -n "${GHCR_TOKEN:-}" ]]; then + # Use token-based auth — create temp auth file + local tmp_auth + tmp_auth="$(mktemp)" + echo "{\"auths\":{\"ghcr.io\":{\"auth\":\"$(echo -n "token:${GHCR_TOKEN}" | base64)\"}}}" > "$tmp_auth" + local_auth="$tmp_auth" + fi + + if [[ -z "$local_auth" ]]; then + log_warning "GHCR auth: no local credentials found, skipping" + return 0 + fi + + # Ensure remote directories exist and copy auth file + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "mkdir -p ~/.config/containers ~/.docker" + scp -q "$local_auth" "$SSH_HOST:~/.config/containers/auth.json" + scp -q "$local_auth" "$SSH_HOST:~/.docker/config.json" + + # Clean up temp file if we created one + if [[ -n "${GHCR_TOKEN:-}" && -n "${tmp_auth:-}" ]]; then + rm -f "$tmp_auth" + fi + + log_success "GHCR auth forwarded to $SSH_HOST" +} + +bootstrap_clone_and_build() { + log_info "Building devcontainer image on $SSH_HOST..." + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "bash -s" "$BOOTSTRAP_DEVC_REPO" "$BOOTSTRAP_DEVC_PATH" "$BOOTSTRAP_IMAGE_TAG" "$BOOTSTRAP_REGISTRY" << 'BUILDEOF' +DEVC_REPO="$1" +DEVC_PATH="$2" +IMAGE_TAG="$3" +REGISTRY="$4" + +# Expand ~ in DEVC_PATH +DEVC_PATH="${DEVC_PATH/#\~/$HOME}" + +if [ -d "$DEVC_PATH/.git" ]; then + echo "Repository exists, pulling latest..." + cd "$DEVC_PATH" && git pull +else + echo "Cloning $DEVC_REPO..." + # Expand ~ in parent dir + PARENT_DIR="$(dirname "$DEVC_PATH")" + mkdir -p "$PARENT_DIR" + cd "$PARENT_DIR" + git clone "https://github.com/${DEVC_REPO}.git" "$(basename "$DEVC_PATH")" + cd "$DEVC_PATH" +fi + +# Build the image +if [ -f "scripts/build.sh" ]; then + echo "Running scripts/build.sh..." + bash scripts/build.sh +else + echo "WARNING: scripts/build.sh not found in $DEVC_PATH" +fi +BUILDEOF + + log_success "Devcontainer image built on $SSH_HOST" +} + +bootstrap_remote() { + log_info "Bootstrap: checking remote config on $SSH_HOST..." + bootstrap_check_config + + if [[ "$CONFIG_EXISTS" == "1" ]]; then + log_info "Config: ~/.config/devc-remote/config.yaml (existing, not modified)" + else + bootstrap_prompt_config + bootstrap_write_config + fi + + forward_ghcr_auth + bootstrap_clone_and_build + + log_success "Bootstrap complete for $SSH_HOST" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════════════════════ + +main() { + parse_args "$@" + CONTAINER_FRESH=0 + + # Bootstrap mode: one-time remote host setup + if [[ "$BOOTSTRAP_MODE" == "1" ]]; then + log_info "Checking SSH connectivity to $SSH_HOST..." + check_ssh + log_success "SSH connection OK" + bootstrap_remote + return + fi + + check_unpushed_commits + + detect_editor_cli + case "$OPEN_MODE" in + cursor|code) log_success "IDE: $EDITOR_CLI" ;; + ssh) log_info "Mode: SSH (wait for Tailscale, print connection info)" ;; + none) log_info "Mode: infra only (no IDE)" ;; + esac + + log_info "Checking SSH connectivity to $SSH_HOST..." + check_ssh + log_success "SSH connection OK" + + forward_ghcr_auth + + remote_clone_project + + log_info "Running pre-flight checks on $SSH_HOST..." + remote_preflight + log_success "Pre-flight OK (runtime: $RUNTIME)" + + prepare_remote + + inject_tailscale_key + inject_claude_auth + + remote_compose_up + + run_container_lifecycle + + case "$OPEN_MODE" in + cursor|code) + open_editor + log_success "Done — opened $EDITOR_CLI for $SSH_HOST:$REMOTE_PATH" + ;; + ssh) + wait_for_tailscale + ;; + none) + log_success "Done — devcontainer running on $SSH_HOST:$REMOTE_PATH" + ;; + esac +} + +main "$@" diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 680ae750..ad7d5270 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -273,20 +273,7 @@ inject_tailscale_key() { return 0 fi - # Check if key already set on remote — still need to ensure TUN device config - # shellcheck disable=SC2029 - if ssh "$SSH_HOST" "grep -q 'TAILSCALE_AUTHKEY' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then - # Ensure TUN device + capabilities are present even if key already set - # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "grep -q '/dev/net/tun' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then - log_info "Tailscale: adding TUN device config to existing compose..." - # shellcheck disable=SC2029 - ssh "$SSH_HOST" "sed -i '/devcontainer:/a\\ devices:\\n - /dev/net/tun:/dev/net/tun\\n cap_add:\\n - NET_ADMIN\\n - NET_RAW' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml'" - log_success "Tailscale: TUN device config added" - fi - log_info "Tailscale: auth key already configured on remote" - return 0 - fi + # Always regenerate — ephemeral keys may have expired since last deploy. # Verify local prerequisites if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then @@ -538,11 +525,20 @@ elif command -v docker &>/dev/null; then else echo "RUNTIME=" fi -if (command -v podman &>/dev/null && podman compose version &>/dev/null) || \ - (command -v docker &>/dev/null && docker compose version &>/dev/null); then +# Detect compose tool — prefer podman-compose (Python, passes devices/cap_add) +# over podman compose (docker-compose bridge, drops some fields on podman <5) +if command -v podman-compose &>/dev/null; then + echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=podman-compose" +elif command -v podman &>/dev/null && podman compose version &>/dev/null; then echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=podman-compose-plugin" +elif command -v docker &>/dev/null && docker compose version &>/dev/null; then + echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=docker-compose-plugin" else echo "COMPOSE_AVAILABLE=0" + echo "COMPOSE_TOOL=" fi if [ -d "$REPO_PATH" ]; then echo "REPO_PATH_EXISTS=1" @@ -577,6 +573,7 @@ REMOTEEOF case "${BASH_REMATCH[1]}" in RUNTIME) RUNTIME="${BASH_REMATCH[2]}" ;; COMPOSE_AVAILABLE) COMPOSE_AVAILABLE="${BASH_REMATCH[2]}" ;; + COMPOSE_TOOL) COMPOSE_TOOL="${BASH_REMATCH[2]}" ;; REPO_PATH_EXISTS) REPO_PATH_EXISTS="${BASH_REMATCH[2]}" ;; DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; @@ -589,11 +586,19 @@ REMOTEEOF log_error "No container runtime found on $SSH_HOST. Install podman or docker." exit 1 fi - if [[ "$RUNTIME" == "podman" ]]; then - COMPOSE_CMD="podman compose" - else - COMPOSE_CMD="docker compose" - fi + # Set compose command based on detected tool + case "${COMPOSE_TOOL:-}" in + podman-compose) COMPOSE_CMD="podman-compose" ;; + podman-compose-plugin) COMPOSE_CMD="podman compose" ;; + docker-compose-plugin) COMPOSE_CMD="docker compose" ;; + *) + if [[ "$RUNTIME" == "podman" ]]; then + COMPOSE_CMD="podman compose" + else + COMPOSE_CMD="docker compose" + fi + ;; + esac if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." exit 1 diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 680ae750..ad7d5270 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -273,20 +273,7 @@ inject_tailscale_key() { return 0 fi - # Check if key already set on remote — still need to ensure TUN device config - # shellcheck disable=SC2029 - if ssh "$SSH_HOST" "grep -q 'TAILSCALE_AUTHKEY' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then - # Ensure TUN device + capabilities are present even if key already set - # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "grep -q '/dev/net/tun' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml' 2>/dev/null"; then - log_info "Tailscale: adding TUN device config to existing compose..." - # shellcheck disable=SC2029 - ssh "$SSH_HOST" "sed -i '/devcontainer:/a\\ devices:\\n - /dev/net/tun:/dev/net/tun\\n cap_add:\\n - NET_ADMIN\\n - NET_RAW' '$REMOTE_PATH/.devcontainer/docker-compose.local.yaml'" - log_success "Tailscale: TUN device config added" - fi - log_info "Tailscale: auth key already configured on remote" - return 0 - fi + # Always regenerate — ephemeral keys may have expired since last deploy. # Verify local prerequisites if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then @@ -538,11 +525,20 @@ elif command -v docker &>/dev/null; then else echo "RUNTIME=" fi -if (command -v podman &>/dev/null && podman compose version &>/dev/null) || \ - (command -v docker &>/dev/null && docker compose version &>/dev/null); then +# Detect compose tool — prefer podman-compose (Python, passes devices/cap_add) +# over podman compose (docker-compose bridge, drops some fields on podman <5) +if command -v podman-compose &>/dev/null; then + echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=podman-compose" +elif command -v podman &>/dev/null && podman compose version &>/dev/null; then echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=podman-compose-plugin" +elif command -v docker &>/dev/null && docker compose version &>/dev/null; then + echo "COMPOSE_AVAILABLE=1" + echo "COMPOSE_TOOL=docker-compose-plugin" else echo "COMPOSE_AVAILABLE=0" + echo "COMPOSE_TOOL=" fi if [ -d "$REPO_PATH" ]; then echo "REPO_PATH_EXISTS=1" @@ -577,6 +573,7 @@ REMOTEEOF case "${BASH_REMATCH[1]}" in RUNTIME) RUNTIME="${BASH_REMATCH[2]}" ;; COMPOSE_AVAILABLE) COMPOSE_AVAILABLE="${BASH_REMATCH[2]}" ;; + COMPOSE_TOOL) COMPOSE_TOOL="${BASH_REMATCH[2]}" ;; REPO_PATH_EXISTS) REPO_PATH_EXISTS="${BASH_REMATCH[2]}" ;; DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; @@ -589,11 +586,19 @@ REMOTEEOF log_error "No container runtime found on $SSH_HOST. Install podman or docker." exit 1 fi - if [[ "$RUNTIME" == "podman" ]]; then - COMPOSE_CMD="podman compose" - else - COMPOSE_CMD="docker compose" - fi + # Set compose command based on detected tool + case "${COMPOSE_TOOL:-}" in + podman-compose) COMPOSE_CMD="podman-compose" ;; + podman-compose-plugin) COMPOSE_CMD="podman compose" ;; + docker-compose-plugin) COMPOSE_CMD="docker compose" ;; + *) + if [[ "$RUNTIME" == "podman" ]]; then + COMPOSE_CMD="podman compose" + else + COMPOSE_CMD="docker compose" + fi + ;; + esac if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." exit 1 From 15120fb7fe2fba027950ac87b54f585e007f7c83 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 16:59:25 +0100 Subject: [PATCH 067/215] fix(remote): ensure ~/.local/bin in PATH for SSH compose commands Non-login SSH shells miss ~/.local/bin where podman-compose is typically installed via pip. Add PATH prefix to preflight and all remote compose invocations. Refs: #70 --- .../.devcontainer/scripts/devc-remote.sh | 17 +++++++++++------ assets/workspace/scripts/devc-remote.sh | 17 +++++++++++------ scripts/devc-remote.sh | 17 +++++++++++------ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/assets/workspace/.devcontainer/scripts/devc-remote.sh b/assets/workspace/.devcontainer/scripts/devc-remote.sh index ad7d5270..e407b9ec 100755 --- a/assets/workspace/.devcontainer/scripts/devc-remote.sh +++ b/assets/workspace/.devcontainer/scripts/devc-remote.sh @@ -518,6 +518,8 @@ remote_preflight() { # shellcheck disable=SC2029 preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' REPO_PATH="${1:-$HOME}" +# Ensure common user bin dirs are in PATH (SSH non-login shells may miss them) +export PATH="$HOME/.local/bin:$PATH" if command -v podman &>/dev/null; then echo "RUNTIME=podman" elif command -v docker &>/dev/null; then @@ -587,6 +589,9 @@ REMOTEEOF exit 1 fi # Set compose command based on detected tool + # REMOTE_ENV_PREFIX ensures ~/.local/bin is in PATH for non-login SSH shells + # shellcheck disable=SC2016 + REMOTE_ENV_PREFIX='export PATH="$HOME/.local/bin:$PATH"' case "${COMPOSE_TOOL:-}" in podman-compose) COMPOSE_CMD="podman-compose" ;; podman-compose-plugin) COMPOSE_CMD="podman compose" ;; @@ -674,7 +679,7 @@ remote_compose_up() { local devc_dir="$REMOTE_PATH/.devcontainer" # shellcheck disable=SC2029 - ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + ps_output=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) @@ -685,7 +690,7 @@ remote_compose_up() { else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then + if ! ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full up -d"; then log_error "Failed to start devcontainer on $SSH_HOST." log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" exit 1 @@ -704,7 +709,7 @@ run_container_lifecycle() { local has_scripts # shellcheck disable=SC2029 - has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + has_scripts=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_scripts" != "1" ]]; then @@ -716,7 +721,7 @@ run_container_lifecycle() { if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then log_info "Running post-create lifecycle (first start)..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-create.sh" 2>&1 || { log_warning "post-create.sh failed (non-fatal, container still running)" } @@ -725,13 +730,13 @@ run_container_lifecycle() { # post-start: every-start setup (socket perms, deps sync, tailscale start) local has_post_start # shellcheck disable=SC2029 - has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + has_post_start=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_post_start" == "1" ]]; then log_info "Running post-start lifecycle..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-start.sh" 2>&1 || { log_warning "post-start.sh failed (non-fatal, container still running)" } diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index ad7d5270..e407b9ec 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -518,6 +518,8 @@ remote_preflight() { # shellcheck disable=SC2029 preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' REPO_PATH="${1:-$HOME}" +# Ensure common user bin dirs are in PATH (SSH non-login shells may miss them) +export PATH="$HOME/.local/bin:$PATH" if command -v podman &>/dev/null; then echo "RUNTIME=podman" elif command -v docker &>/dev/null; then @@ -587,6 +589,9 @@ REMOTEEOF exit 1 fi # Set compose command based on detected tool + # REMOTE_ENV_PREFIX ensures ~/.local/bin is in PATH for non-login SSH shells + # shellcheck disable=SC2016 + REMOTE_ENV_PREFIX='export PATH="$HOME/.local/bin:$PATH"' case "${COMPOSE_TOOL:-}" in podman-compose) COMPOSE_CMD="podman-compose" ;; podman-compose-plugin) COMPOSE_CMD="podman compose" ;; @@ -674,7 +679,7 @@ remote_compose_up() { local devc_dir="$REMOTE_PATH/.devcontainer" # shellcheck disable=SC2029 - ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + ps_output=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) @@ -685,7 +690,7 @@ remote_compose_up() { else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then + if ! ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full up -d"; then log_error "Failed to start devcontainer on $SSH_HOST." log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" exit 1 @@ -704,7 +709,7 @@ run_container_lifecycle() { local has_scripts # shellcheck disable=SC2029 - has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + has_scripts=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_scripts" != "1" ]]; then @@ -716,7 +721,7 @@ run_container_lifecycle() { if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then log_info "Running post-create lifecycle (first start)..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-create.sh" 2>&1 || { log_warning "post-create.sh failed (non-fatal, container still running)" } @@ -725,13 +730,13 @@ run_container_lifecycle() { # post-start: every-start setup (socket perms, deps sync, tailscale start) local has_post_start # shellcheck disable=SC2029 - has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + has_post_start=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_post_start" == "1" ]]; then log_info "Running post-start lifecycle..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-start.sh" 2>&1 || { log_warning "post-start.sh failed (non-fatal, container still running)" } diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index ad7d5270..e407b9ec 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -518,6 +518,8 @@ remote_preflight() { # shellcheck disable=SC2029 preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' REPO_PATH="${1:-$HOME}" +# Ensure common user bin dirs are in PATH (SSH non-login shells may miss them) +export PATH="$HOME/.local/bin:$PATH" if command -v podman &>/dev/null; then echo "RUNTIME=podman" elif command -v docker &>/dev/null; then @@ -587,6 +589,9 @@ REMOTEEOF exit 1 fi # Set compose command based on detected tool + # REMOTE_ENV_PREFIX ensures ~/.local/bin is in PATH for non-login SSH shells + # shellcheck disable=SC2016 + REMOTE_ENV_PREFIX='export PATH="$HOME/.local/bin:$PATH"' case "${COMPOSE_TOOL:-}" in podman-compose) COMPOSE_CMD="podman-compose" ;; podman-compose-plugin) COMPOSE_CMD="podman compose" ;; @@ -674,7 +679,7 @@ remote_compose_up() { local devc_dir="$REMOTE_PATH/.devcontainer" # shellcheck disable=SC2029 - ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + ps_output=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) @@ -685,7 +690,7 @@ remote_compose_up() { else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then + if ! ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full up -d"; then log_error "Failed to start devcontainer on $SSH_HOST." log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" exit 1 @@ -704,7 +709,7 @@ run_container_lifecycle() { local has_scripts # shellcheck disable=SC2029 - has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + has_scripts=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_scripts" != "1" ]]; then @@ -716,7 +721,7 @@ run_container_lifecycle() { if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then log_info "Running post-create lifecycle (first start)..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-create.sh" 2>&1 || { log_warning "post-create.sh failed (non-fatal, container still running)" } @@ -725,13 +730,13 @@ run_container_lifecycle() { # post-start: every-start setup (socket perms, deps sync, tailscale start) local has_post_start # shellcheck disable=SC2029 - has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + has_post_start=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_post_start" == "1" ]]; then log_info "Running post-start lifecycle..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-start.sh" 2>&1 || { log_warning "post-start.sh failed (non-fatal, container still running)" } From ea3af494c1d11202712e6203e387e48e238f9094 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 17:24:51 +0100 Subject: [PATCH 068/215] refactor(remote): revert unnecessary podman-compose preference podman compose (docker-compose bridge) correctly passes devices/cap_add when compose files are specified via -f flags, which compose_cmd_with_files already does. The earlier failure was caused by a stale local Tailscale client, not by the compose tooling. Remove COMPOSE_TOOL detection, REMOTE_ENV_PREFIX, and ~/.local/bin PATH injection. Refs: #70 --- CHANGELOG.md | 4 -- assets/workspace/scripts/devc-remote.sh | 49 +++++++------------------ scripts/devc-remote.sh | 49 +++++++------------------ 3 files changed, 26 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0caf5db4..e92eadff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,10 +53,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `inject_tailscale_key` always regenerates ephemeral auth key (expired keys no longer block deploys) - `inject_tailscale_key` adds `devices` + `cap_add` to remote `docker-compose.local.yaml` - Template example updated with required `devices` and `cap_add` entries -- **Compose tooling prefers `podman-compose` over docker-compose bridge** ([#70](https://github.com/vig-os/devcontainer/issues/70)) - - `podman-compose` (Python) correctly passes `devices`/`cap_add` to podman CLI - - `docker-compose` v5.1.0 as podman external provider drops these fields (known bug) - - Remote preflight now detects `podman-compose` > `podman compose` > `docker compose` - **CI Project Checks coverage includes devc_remote_uri tests** ([#70](https://github.com/vig-os/devcontainer/issues/70)) - Add `tests/test_devc_remote_uri.py` to test-project action pytest run - Add build_uri validation tests for empty devcontainer_path, ssh_host, container_workspace diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index e407b9ec..b4b3bb02 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -518,8 +518,6 @@ remote_preflight() { # shellcheck disable=SC2029 preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' REPO_PATH="${1:-$HOME}" -# Ensure common user bin dirs are in PATH (SSH non-login shells may miss them) -export PATH="$HOME/.local/bin:$PATH" if command -v podman &>/dev/null; then echo "RUNTIME=podman" elif command -v docker &>/dev/null; then @@ -527,20 +525,11 @@ elif command -v docker &>/dev/null; then else echo "RUNTIME=" fi -# Detect compose tool — prefer podman-compose (Python, passes devices/cap_add) -# over podman compose (docker-compose bridge, drops some fields on podman <5) -if command -v podman-compose &>/dev/null; then +if (command -v podman &>/dev/null && podman compose version &>/dev/null) || \ + (command -v docker &>/dev/null && docker compose version &>/dev/null); then echo "COMPOSE_AVAILABLE=1" - echo "COMPOSE_TOOL=podman-compose" -elif command -v podman &>/dev/null && podman compose version &>/dev/null; then - echo "COMPOSE_AVAILABLE=1" - echo "COMPOSE_TOOL=podman-compose-plugin" -elif command -v docker &>/dev/null && docker compose version &>/dev/null; then - echo "COMPOSE_AVAILABLE=1" - echo "COMPOSE_TOOL=docker-compose-plugin" else echo "COMPOSE_AVAILABLE=0" - echo "COMPOSE_TOOL=" fi if [ -d "$REPO_PATH" ]; then echo "REPO_PATH_EXISTS=1" @@ -575,7 +564,6 @@ REMOTEEOF case "${BASH_REMATCH[1]}" in RUNTIME) RUNTIME="${BASH_REMATCH[2]}" ;; COMPOSE_AVAILABLE) COMPOSE_AVAILABLE="${BASH_REMATCH[2]}" ;; - COMPOSE_TOOL) COMPOSE_TOOL="${BASH_REMATCH[2]}" ;; REPO_PATH_EXISTS) REPO_PATH_EXISTS="${BASH_REMATCH[2]}" ;; DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; @@ -588,22 +576,11 @@ REMOTEEOF log_error "No container runtime found on $SSH_HOST. Install podman or docker." exit 1 fi - # Set compose command based on detected tool - # REMOTE_ENV_PREFIX ensures ~/.local/bin is in PATH for non-login SSH shells - # shellcheck disable=SC2016 - REMOTE_ENV_PREFIX='export PATH="$HOME/.local/bin:$PATH"' - case "${COMPOSE_TOOL:-}" in - podman-compose) COMPOSE_CMD="podman-compose" ;; - podman-compose-plugin) COMPOSE_CMD="podman compose" ;; - docker-compose-plugin) COMPOSE_CMD="docker compose" ;; - *) - if [[ "$RUNTIME" == "podman" ]]; then - COMPOSE_CMD="podman compose" - else - COMPOSE_CMD="docker compose" - fi - ;; - esac + if [[ "$RUNTIME" == "podman" ]]; then + COMPOSE_CMD="podman compose" + else + COMPOSE_CMD="docker compose" + fi if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." exit 1 @@ -679,7 +656,7 @@ remote_compose_up() { local devc_dir="$REMOTE_PATH/.devcontainer" # shellcheck disable=SC2029 - ps_output=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) @@ -690,7 +667,7 @@ remote_compose_up() { else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full up -d"; then + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then log_error "Failed to start devcontainer on $SSH_HOST." log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" exit 1 @@ -709,7 +686,7 @@ run_container_lifecycle() { local has_scripts # shellcheck disable=SC2029 - has_scripts=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_scripts" != "1" ]]; then @@ -721,7 +698,7 @@ run_container_lifecycle() { if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then log_info "Running post-create lifecycle (first start)..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-create.sh" 2>&1 || { log_warning "post-create.sh failed (non-fatal, container still running)" } @@ -730,13 +707,13 @@ run_container_lifecycle() { # post-start: every-start setup (socket perms, deps sync, tailscale start) local has_post_start # shellcheck disable=SC2029 - has_post_start=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_post_start" == "1" ]]; then log_info "Running post-start lifecycle..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-start.sh" 2>&1 || { log_warning "post-start.sh failed (non-fatal, container still running)" } diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index e407b9ec..b4b3bb02 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -518,8 +518,6 @@ remote_preflight() { # shellcheck disable=SC2029 preflight_output=$(ssh "$SSH_HOST" "bash -s" "$REMOTE_PATH" << 'REMOTEEOF' REPO_PATH="${1:-$HOME}" -# Ensure common user bin dirs are in PATH (SSH non-login shells may miss them) -export PATH="$HOME/.local/bin:$PATH" if command -v podman &>/dev/null; then echo "RUNTIME=podman" elif command -v docker &>/dev/null; then @@ -527,20 +525,11 @@ elif command -v docker &>/dev/null; then else echo "RUNTIME=" fi -# Detect compose tool — prefer podman-compose (Python, passes devices/cap_add) -# over podman compose (docker-compose bridge, drops some fields on podman <5) -if command -v podman-compose &>/dev/null; then +if (command -v podman &>/dev/null && podman compose version &>/dev/null) || \ + (command -v docker &>/dev/null && docker compose version &>/dev/null); then echo "COMPOSE_AVAILABLE=1" - echo "COMPOSE_TOOL=podman-compose" -elif command -v podman &>/dev/null && podman compose version &>/dev/null; then - echo "COMPOSE_AVAILABLE=1" - echo "COMPOSE_TOOL=podman-compose-plugin" -elif command -v docker &>/dev/null && docker compose version &>/dev/null; then - echo "COMPOSE_AVAILABLE=1" - echo "COMPOSE_TOOL=docker-compose-plugin" else echo "COMPOSE_AVAILABLE=0" - echo "COMPOSE_TOOL=" fi if [ -d "$REPO_PATH" ]; then echo "REPO_PATH_EXISTS=1" @@ -575,7 +564,6 @@ REMOTEEOF case "${BASH_REMATCH[1]}" in RUNTIME) RUNTIME="${BASH_REMATCH[2]}" ;; COMPOSE_AVAILABLE) COMPOSE_AVAILABLE="${BASH_REMATCH[2]}" ;; - COMPOSE_TOOL) COMPOSE_TOOL="${BASH_REMATCH[2]}" ;; REPO_PATH_EXISTS) REPO_PATH_EXISTS="${BASH_REMATCH[2]}" ;; DEVCONTAINER_EXISTS) DEVCONTAINER_EXISTS="${BASH_REMATCH[2]}" ;; DISK_AVAILABLE_GB) DISK_AVAILABLE_GB="${BASH_REMATCH[2]}" ;; @@ -588,22 +576,11 @@ REMOTEEOF log_error "No container runtime found on $SSH_HOST. Install podman or docker." exit 1 fi - # Set compose command based on detected tool - # REMOTE_ENV_PREFIX ensures ~/.local/bin is in PATH for non-login SSH shells - # shellcheck disable=SC2016 - REMOTE_ENV_PREFIX='export PATH="$HOME/.local/bin:$PATH"' - case "${COMPOSE_TOOL:-}" in - podman-compose) COMPOSE_CMD="podman-compose" ;; - podman-compose-plugin) COMPOSE_CMD="podman compose" ;; - docker-compose-plugin) COMPOSE_CMD="docker compose" ;; - *) - if [[ "$RUNTIME" == "podman" ]]; then - COMPOSE_CMD="podman compose" - else - COMPOSE_CMD="docker compose" - fi - ;; - esac + if [[ "$RUNTIME" == "podman" ]]; then + COMPOSE_CMD="podman compose" + else + COMPOSE_CMD="docker compose" + fi if [[ "${COMPOSE_AVAILABLE:-0}" != "1" ]]; then log_error "Compose not available on $SSH_HOST. Install docker-compose or podman-compose." exit 1 @@ -679,7 +656,7 @@ remote_compose_up() { local devc_dir="$REMOTE_PATH/.devcontainer" # shellcheck disable=SC2029 - ps_output=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) + ps_output=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps --format json 2>/dev/null" || true) state=$(echo "$ps_output" | grep -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || true) # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) @@ -690,7 +667,7 @@ remote_compose_up() { else log_info "Starting devcontainer on $SSH_HOST..." # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full up -d"; then + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then log_error "Failed to start devcontainer on $SSH_HOST." log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" exit 1 @@ -709,7 +686,7 @@ run_container_lifecycle() { local has_scripts # shellcheck disable=SC2029 - has_scripts=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + has_scripts=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-create.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_scripts" != "1" ]]; then @@ -721,7 +698,7 @@ run_container_lifecycle() { if [[ "${CONTAINER_FRESH:-0}" == "1" ]]; then log_info "Running post-create lifecycle (first start)..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-create.sh" 2>&1 || { log_warning "post-create.sh failed (non-fatal, container still running)" } @@ -730,13 +707,13 @@ run_container_lifecycle() { # post-start: every-start setup (socket perms, deps sync, tailscale start) local has_post_start # shellcheck disable=SC2029 - has_post_start=$(ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + has_post_start=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ test -f $scripts_dir/post-start.sh && echo 1 || echo 0" 2>/dev/null || echo "0") if [[ "$has_post_start" == "1" ]]; then log_info "Running post-start lifecycle..." # shellcheck disable=SC2029 - ssh "$SSH_HOST" "${REMOTE_ENV_PREFIX}; cd $devc_dir && $compose_full exec -T devcontainer \ + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer \ /bin/bash $scripts_dir/post-start.sh" 2>&1 || { log_warning "post-start.sh failed (non-fatal, container still running)" } From 49c7a4e72c9a7f45e9469296621b170788a7b513 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Mon, 9 Mar 2026 17:59:51 +0100 Subject: [PATCH 069/215] feat(remote): pre-flight check for stale local Tailscale check_local_tailscale() verifies BackendState=Running and Self.Online before spending time on remote setup. Fails fast with actionable error messages. Also adds health check to WezTerm LEADER+s and LEADER+d via shared check_tailscale_health() helper with toast notifications. Refs: #70 --- CHANGELOG.md | 1 + assets/workspace/scripts/devc-remote.sh | 28 +++++++- scripts/devc-remote.sh | 28 +++++++- tests/bats/devc-remote.bats | 92 ++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e92eadff..1f946fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Tailscale SSH now works inside containers** ([#70](https://github.com/vig-os/devcontainer/issues/70)) - Use real TUN device instead of `--tun=userspace-networking` (userspace mode cannot serve SSH) - `setup-tailscale.sh` auto-detects `/dev/net/tun` and warns if missing + - Pre-flight `check_local_tailscale` fails fast when local Tailscale daemon is stopped or offline - `inject_tailscale_key` always regenerates ephemeral auth key (expired keys no longer block deploys) - `inject_tailscale_key` adds `devices` + `cap_add` to remote `docker-compose.local.yaml` - Template example updated with required `devices` and `cap_add` entries diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index b4b3bb02..36022031 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -748,12 +748,34 @@ open_editor() { # TAILSCALE WAIT + SSH OUTPUT # ═══════════════════════════════════════════════════════════════════════════════ -wait_for_tailscale() { +check_local_tailscale() { if ! command -v tailscale &>/dev/null; then log_error "tailscale CLI not found locally. Install Tailscale to use --open ssh." exit 1 fi + local ts_status backend_state self_online + ts_status=$(tailscale status --json 2>/dev/null) || { + log_error "Tailscale: cannot query local daemon. Is Tailscale running?" + exit 1 + } + backend_state=$(echo "$ts_status" | python3 -c "import json,sys; print(json.load(sys.stdin).get('BackendState',''))" 2>/dev/null) + self_online=$(echo "$ts_status" | python3 -c "import json,sys; print(json.load(sys.stdin).get('Self',{}).get('Online',False))" 2>/dev/null) + + if [[ "$backend_state" != "Running" ]]; then + log_error "Tailscale: local daemon state is '$backend_state' (expected 'Running'). Start Tailscale first." + exit 1 + fi + if [[ "$self_online" != "True" ]]; then + log_error "Tailscale: local node is offline. Reconnect with: tailscale up" + exit 1 + fi + log_success "Tailscale: local client healthy (state=$backend_state)" +} + +wait_for_tailscale() { + check_local_tailscale + # Derive expected hostname pattern from devcontainer.json name field local devc_name # shellcheck disable=SC2029 @@ -995,6 +1017,10 @@ main() { check_unpushed_commits detect_editor_cli + # Fail fast: verify local Tailscale before spending time on remote setup + if [[ "$OPEN_MODE" == "ssh" ]]; then + check_local_tailscale + fi case "$OPEN_MODE" in cursor|code) log_success "IDE: $EDITOR_CLI" ;; ssh) log_info "Mode: SSH (wait for Tailscale, print connection info)" ;; diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index b4b3bb02..36022031 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -748,12 +748,34 @@ open_editor() { # TAILSCALE WAIT + SSH OUTPUT # ═══════════════════════════════════════════════════════════════════════════════ -wait_for_tailscale() { +check_local_tailscale() { if ! command -v tailscale &>/dev/null; then log_error "tailscale CLI not found locally. Install Tailscale to use --open ssh." exit 1 fi + local ts_status backend_state self_online + ts_status=$(tailscale status --json 2>/dev/null) || { + log_error "Tailscale: cannot query local daemon. Is Tailscale running?" + exit 1 + } + backend_state=$(echo "$ts_status" | python3 -c "import json,sys; print(json.load(sys.stdin).get('BackendState',''))" 2>/dev/null) + self_online=$(echo "$ts_status" | python3 -c "import json,sys; print(json.load(sys.stdin).get('Self',{}).get('Online',False))" 2>/dev/null) + + if [[ "$backend_state" != "Running" ]]; then + log_error "Tailscale: local daemon state is '$backend_state' (expected 'Running'). Start Tailscale first." + exit 1 + fi + if [[ "$self_online" != "True" ]]; then + log_error "Tailscale: local node is offline. Reconnect with: tailscale up" + exit 1 + fi + log_success "Tailscale: local client healthy (state=$backend_state)" +} + +wait_for_tailscale() { + check_local_tailscale + # Derive expected hostname pattern from devcontainer.json name field local devc_name # shellcheck disable=SC2029 @@ -995,6 +1017,10 @@ main() { check_unpushed_commits detect_editor_cli + # Fail fast: verify local Tailscale before spending time on remote setup + if [[ "$OPEN_MODE" == "ssh" ]]; then + check_local_tailscale + fi case "$OPEN_MODE" in cursor|code) log_success "IDE: $EDITOR_CLI" ;; ssh) log_info "Mode: SSH (wait for Tailscale, print connection info)" ;; diff --git a/tests/bats/devc-remote.bats b/tests/bats/devc-remote.bats index af208710..300447a9 100644 --- a/tests/bats/devc-remote.bats +++ b/tests/bats/devc-remote.bats @@ -314,8 +314,8 @@ SSHEOF refute_output --partial "cursor CLI not found" refute_output --partial "code CLI not found" assert_output --partial "No IDE CLI found, falling back to --open ssh" - # Fails at check_ssh, not editor detection - assert_output --partial "Cannot connect to" + # Fails at check_local_tailscale (no tailscale in PATH), not editor detection + assert_output --partial "tailscale CLI not found" rm -rf "$mock_bin" } @@ -1126,3 +1126,91 @@ SSHEOF refute_output --partial "compose up" rm -rf "$mock_bin" } + +# ── check_local_tailscale ─────────────────────────────────────────────────── + +@test "check_local_tailscale defines function" { + run grep -q 'check_local_tailscale()' "$DEVC_REMOTE" + assert_success +} + +@test "check_local_tailscale is called in main for --open ssh" { + run grep -A5 'if \[\[ "\$OPEN_MODE" == "ssh" \]\]' "$DEVC_REMOTE" + assert_success + assert_output --partial "check_local_tailscale" +} + +@test "check_local_tailscale fails when tailscale not found" { + local mock_bin + mock_bin="$(mktemp -d)" + # No tailscale binary in mock_bin, and we override PATH to exclude real one + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" + PATH="$mock_bin:/usr/bin:/bin" run "$DEVC_REMOTE" --open ssh host gh:org/repo 2>&1 + assert_failure + assert_output --partial "tailscale CLI not found" + rm -rf "$mock_bin" +} + +@test "check_local_tailscale fails when backend state is not Running" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/tailscale" << 'TSEOF' +#!/bin/sh +echo '{"BackendState":"Stopped","Self":{"Online":false}}' +TSEOF + chmod +x "$mock_bin/tailscale" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" + PATH="$mock_bin:/usr/bin:/bin" run "$DEVC_REMOTE" --open ssh host gh:org/repo 2>&1 + assert_failure + assert_output --partial "local daemon state is 'Stopped'" + rm -rf "$mock_bin" +} + +@test "check_local_tailscale fails when self is offline" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/tailscale" << 'TSEOF' +#!/bin/sh +echo '{"BackendState":"Running","Self":{"Online":false}}' +TSEOF + chmod +x "$mock_bin/tailscale" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" + PATH="$mock_bin:/usr/bin:/bin" run "$DEVC_REMOTE" --open ssh host gh:org/repo 2>&1 + assert_failure + assert_output --partial "local node is offline" + rm -rf "$mock_bin" +} + +@test "check_local_tailscale passes when healthy" { + local mock_bin + mock_bin="$(mktemp -d)" + cat > "$mock_bin/tailscale" << 'TSEOF' +#!/bin/sh +echo '{"BackendState":"Running","Self":{"Online":true},"Peer":{}}' +TSEOF + chmod +x "$mock_bin/tailscale" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/ssh" + chmod +x "$mock_bin/ssh" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$mock_bin/scp" + chmod +x "$mock_bin/scp" + printf '%s\n' '#!/bin/sh' 'case "$1" in rev-parse) if [ "$2" = "--is-inside-work-tree" ]; then echo true; exit 0; fi; if [ "$2" = "--abbrev-ref" ]; then echo "origin/main"; exit 0; fi;; branch) echo "main"; exit 0;; rev-list) echo 0; exit 0;; esac; exit 0' > "$mock_bin/git" + chmod +x "$mock_bin/git" + PATH="$mock_bin:/usr/bin:/bin" run "$DEVC_REMOTE" --open ssh host gh:org/repo 2>&1 + assert_output --partial "local client healthy" + rm -rf "$mock_bin" +} From d44cf316b62a5524d5c929d684a4d93b0646275f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 12 Mar 2026 18:20:08 +0100 Subject: [PATCH 070/215] docs: regenerate README and CONTRIBUTE with remote-devc recipe Refs: #70 --- CONTRIBUTE.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 6a727aa9..5ed6aaae 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -173,6 +173,7 @@ Available recipes: down # Stop and remove containers logs *args # Tail container logs open # Open Cursor/VS Code attached to the running container + remote-devc host *args # just remote-devc ksb-meatgrinder --open none restart *args # Restart service(s) shell # Open bash in running devcontainer status # Show container status diff --git a/README.md b/README.md index 89861936..807d3943 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Available recipes: down # Stop and remove containers logs *args # Tail container logs open # Open Cursor/VS Code attached to the running container + remote-devc host *args # just remote-devc ksb-meatgrinder --open none restart *args # Restart service(s) shell # Open bash in running devcontainer status # Show container status From 41b6423ce4683cd2515828308530d8b9c7812c04 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Thu, 12 Mar 2026 18:20:59 +0100 Subject: [PATCH 071/215] fix(tests): bump expected gh version to 2.88 Refs: #70 --- CHANGELOG.md | 5 +++++ tests/test_image.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f946fb5..abb84908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Commented example in `docker-compose.local.yaml` for quick setup - Documentation in `.devcontainer/README.md` with quick-start and ACL instructions +### Changed + +- **Updated expected `gh` CLI version to 2.88** ([#70](https://github.com/vig-os/devcontainer/issues/70)) + - Updated expected version prefix from `2.87.` to `2.88.` to match GitHub CLI 2.88.1 (released 2026-03-12) + ### Fixed - **Tailscale SSH now works inside containers** ([#70](https://github.com/vig-os/devcontainer/issues/70)) diff --git a/tests/test_image.py b/tests/test_image.py index 23047a08..636d1cdc 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -19,7 +19,7 @@ EXPECTED_VERSIONS = { "git": "2.", # Major version check (from apt package) "curl": "8.", # Major version check (from apt package) - "gh": "2.87.", # Minor version check (GitHub CLI (manually installed from latest release) + "gh": "2.88.", # Minor version check (GitHub CLI (manually installed from latest release) "uv": "0.10.", # Minor version check (manually installed from latest release) "python": "3.12", # Python (from base image) "pre_commit": "4.5.", # Minor version check (installed via uv pip) From ac161b66e18b5221a5a938f6b9bffc573c8325bb Mon Sep 17 00:00:00 2001 From: "commit-action-bot[bot]" <248498966+commit-action-bot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:13:29 +0000 Subject: [PATCH 072/215] chore: prepare release 0.3.1 Strip empty Unreleased section from release branch. Release date TBD (set during finalization). --- CHANGELOG.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5878d473..3f9760f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased - -### Added - -### Changed - -### Deprecated - -### Removed - -### Fixed - -### Security - ## [0.3.1] - TBD ### Added From 1cef54986f4506f6702a44a68440b87a9ce16468 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 15:39:54 +0100 Subject: [PATCH 073/215] fix(ci): sync workspace manifest during release finalization Refs: #343 --- .github/workflows/release.yml | 7 +++++++ assets/workspace/.devcontainer/CHANGELOG.md | 6 +----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28220b3e..541ab291 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -518,6 +518,13 @@ jobs: uv run python docs/generate.py echo "✓ Regenerated docs from finalized CHANGELOG.md" + - name: Sync workspace manifest for finalized release + if: needs.validate.outputs.release_kind == 'final' + run: | + set -euo pipefail + uv run python scripts/sync_manifest.py sync assets/workspace/ + echo "✓ Synced workspace manifest after changelog finalization" + - name: Collect finalization files if: needs.validate.outputs.release_kind == 'final' id: finalize-files diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 22eee4e8..3f9760f5 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.3.1] - TBD ### Added @@ -59,10 +59,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add concurrency control to `assets/smoke-test/.github/workflows/repository-dispatch.yml` to prevent overlapping dispatch races - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released -### Deprecated - -### Removed - ### Fixed - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) From 4591cac1e6cebe4930e20828344809183bc26c3b Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 15:46:07 +0100 Subject: [PATCH 074/215] fix(ci): sync manifest mirror in prepare-release changelog commits Refs: #343 --- .github/workflows/prepare-release.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 5f72ed91..b58c66ea 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -198,6 +198,12 @@ jobs: uv run prepare-changelog prepare "$VERSION" CHANGELOG.md echo "✓ CHANGELOG prepared (Unreleased → [${VERSION}] - TBD + fresh Unreleased)" + - name: Sync workspace manifest after changelog prepare + run: | + set -euo pipefail + uv run python scripts/sync_manifest.py sync assets/workspace/ + echo "✓ Synced workspace manifest after changelog prepare" + - name: Extract CHANGELOG content for PR body id: changelog env: @@ -222,7 +228,7 @@ jobs: Move Unreleased content to [${{ needs.validate.outputs.version }}] - TBD and create fresh empty Unreleased section for continued development. - FILE_PATHS: CHANGELOG.md + FILE_PATHS: CHANGELOG.md assets/workspace/.devcontainer/CHANGELOG.md - name: Create release branch from dev id: create-branch @@ -255,6 +261,12 @@ jobs: " echo "✓ Stripped empty Unreleased section from CHANGELOG" + - name: Sync workspace manifest after release changelog strip + run: | + set -euo pipefail + uv run python scripts/sync_manifest.py sync assets/workspace/ + echo "✓ Synced workspace manifest after release changelog strip" + - name: Commit stripped CHANGELOG to release branch via API uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 env: @@ -266,7 +278,7 @@ jobs: Strip empty Unreleased section from release branch. Release date TBD (set during finalization). - FILE_PATHS: CHANGELOG.md + FILE_PATHS: CHANGELOG.md assets/workspace/.devcontainer/CHANGELOG.md - name: Create draft PR to main id: pr @@ -348,6 +360,7 @@ jobs: echo "i dev CHANGELOG already matches pre-prepare state" else cp /tmp/changelog.pre-prepare.md CHANGELOG.md + uv run python scripts/sync_manifest.py sync assets/workspace/ CHANGELOG_ROLLBACK_NEEDED=true echo "✓ Prepared CHANGELOG rollback content for dev" fi @@ -366,7 +379,7 @@ jobs: chore: rollback failed prepare-release ${{ needs.validate.outputs.version }} Restore CHANGELOG.md on dev to pre-prepare state after prepare-release failed. - FILE_PATHS: CHANGELOG.md + FILE_PATHS: CHANGELOG.md assets/workspace/.devcontainer/CHANGELOG.md - name: Rollback summary if: ${{ failure() }} From 3e496ec3b8727e78929243cb6dfcfed0578e1ab2 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 16:46:34 +0100 Subject: [PATCH 075/215] fix(ci): harden release workflow retries for network-sensitive steps Refs: #347 --- .github/workflows/release.yml | 102 ++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 541ab291..7050f5d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -991,7 +991,22 @@ jobs: done fi - - name: Generate SBOM + - name: Generate SBOM (attempt 1) + id: sbom_generate + continue-on-error: true + uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + with: + image: ghcr.io/vig-os/devcontainer:${{ needs.validate.outputs.publish_version }} + artifact-name: sbom-${{ needs.validate.outputs.publish_version }}.spdx.json + output-file: /tmp/sbom.spdx.json + format: spdx-json + + - name: Wait before retrying SBOM generation + if: steps.sbom_generate.outcome == 'failure' + run: sleep 15 + + - name: Generate SBOM (retry) + if: steps.sbom_generate.outcome == 'failure' uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 with: image: ghcr.io/vig-os/devcontainer:${{ needs.validate.outputs.publish_version }} @@ -1018,7 +1033,20 @@ jobs: echo "Signing image: $REPO@$DIGEST" # Keyless signing using GitHub Actions OIDC identity - cosign sign --yes "$REPO@$DIGEST" + RETRIES=3 + for i in $(seq 1 $RETRIES); do + if cosign sign --yes "$REPO@$DIGEST"; then + break + fi + + if [ $i -lt $RETRIES ]; then + echo "Cosign sign failed, retrying ($i/$RETRIES)..." + sleep 15 + else + echo "ERROR: Failed to sign image after $RETRIES attempts" + exit 1 + fi + done echo "✓ Image signed with cosign (keyless)" - name: Capture image digest for attestation @@ -1031,15 +1059,44 @@ jobs: echo "digest=$DIGEST" >> $GITHUB_OUTPUT echo "Image digest: $DIGEST" - - name: Attest build provenance + - name: Attest build provenance (attempt 1) + id: attest_provenance + continue-on-error: true uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ghcr.io/vig-os/devcontainer subject-digest: ${{ steps.digest.outputs.digest }} push-to-registry: true - - name: Attest SBOM - uses: actions/attest-sbom@07e74fc4e78d1aad915e867f9a094073a9f71527 # v4.0.0 + - name: Wait before retrying build provenance attestation + if: steps.attest_provenance.outcome == 'failure' + run: sleep 30 + + - name: Attest build provenance (retry) + if: steps.attest_provenance.outcome == 'failure' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ghcr.io/vig-os/devcontainer + subject-digest: ${{ steps.digest.outputs.digest }} + push-to-registry: true + + - name: Attest SBOM (attempt 1) + id: attest_sbom + continue-on-error: true + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-name: ghcr.io/vig-os/devcontainer + subject-digest: ${{ steps.digest.outputs.digest }} + sbom-path: /tmp/sbom.spdx.json + push-to-registry: true + + - name: Wait before retrying SBOM attestation + if: steps.attest_sbom.outcome == 'failure' + run: sleep 30 + + - name: Attest SBOM (retry) + if: steps.attest_sbom.outcome == 'failure' + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-name: ghcr.io/vig-os/devcontainer subject-digest: ${{ steps.digest.outputs.digest }} @@ -1128,17 +1185,30 @@ jobs: CORRELATION_ID: ${{ github.repository }}:${{ github.run_id }}:${{ needs.validate.outputs.publish_version }} run: | set -euo pipefail - gh api repos/vig-os/devcontainer-smoke-test/dispatches \ - -f event_type=smoke-test-trigger \ - -f "client_payload[tag]=$RELEASE_TAG" \ - -f "client_payload[release_kind]=$RELEASE_KIND" \ - -f "client_payload[event_type]=smoke-test-trigger" \ - -f "client_payload[source_repo]=$SOURCE_REPO" \ - -f "client_payload[source_workflow]=$SOURCE_WORKFLOW" \ - -f "client_payload[source_run_id]=$SOURCE_RUN_ID" \ - -f "client_payload[source_run_url]=$SOURCE_RUN_URL" \ - -f "client_payload[source_sha]=$SOURCE_SHA" \ - -f "client_payload[correlation_id]=$CORRELATION_ID" + RETRIES=3 + for i in $(seq 1 $RETRIES); do + if gh api repos/vig-os/devcontainer-smoke-test/dispatches \ + -f event_type=smoke-test-trigger \ + -f "client_payload[tag]=$RELEASE_TAG" \ + -f "client_payload[release_kind]=$RELEASE_KIND" \ + -f "client_payload[event_type]=smoke-test-trigger" \ + -f "client_payload[source_repo]=$SOURCE_REPO" \ + -f "client_payload[source_workflow]=$SOURCE_WORKFLOW" \ + -f "client_payload[source_run_id]=$SOURCE_RUN_ID" \ + -f "client_payload[source_run_url]=$SOURCE_RUN_URL" \ + -f "client_payload[source_sha]=$SOURCE_SHA" \ + -f "client_payload[correlation_id]=$CORRELATION_ID"; then + break + fi + + if [ $i -lt $RETRIES ]; then + echo "Dispatch failed, retrying ($i/$RETRIES)..." + sleep 10 + else + echo "ERROR: Failed to trigger smoke-test dispatch after $RETRIES attempts" + exit 1 + fi + done echo "✓ Triggered smoke-test dispatch for release tag: $RELEASE_TAG" - name: Summary From b2e9b378c917f63522227d0eafc602017e9a83b1 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 17:02:32 +0100 Subject: [PATCH 076/215] fix(ci): add artifact-metadata permission for release attestations Refs: #348 --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7050f5d5..9e54dd01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -754,6 +754,7 @@ jobs: packages: write # push images to GHCR id-token: write # keyless cosign signing via OIDC attestations: write # build provenance attestations + artifact-metadata: write # persist attestation storage records steps: - name: Generate GitHub App Token From e4ebc23b7ce4a003f69c1a59016d9361e0aeafd0 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 17:07:18 +0100 Subject: [PATCH 077/215] docs(ci): add changelog entry for attestation metadata permission Refs: #348 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9760f5..6346f2a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs - Release PR body is refreshed from finalized `CHANGELOG.md` +- **Release attestation warnings reduced by granting artifact metadata permission** ([#348](https://github.com/vig-os/devcontainer/issues/348)) + - Add `artifact-metadata: write` to the release publish job so attestation steps can persist metadata storage records + - Keep `actions/attest`-based SBOM attestation path and remove missing-permission warnings from publish runs ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 3f9760f5..6346f2a0 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -64,6 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs - Release PR body is refreshed from finalized `CHANGELOG.md` +- **Release attestation warnings reduced by granting artifact metadata permission** ([#348](https://github.com/vig-os/devcontainer/issues/348)) + - Add `artifact-metadata: write` to the release publish job so attestation steps can persist metadata storage records + - Keep `actions/attest`-based SBOM attestation path and remove missing-permission warnings from publish runs ### Security From aa041686ef963a56628b631fef4f7dc6b5ecc645 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 21:34:07 +0100 Subject: [PATCH 078/215] fix(ci): normalize smoke-test workspace ownership after installer Repair repository ownership in repository_dispatch deploy runs when Docker leaves bind-mounted files non-writable, preventing changelog copy failures and downstream deploy automation breakage. Refs: #352 --- CHANGELOG.md | 3 +++ .../smoke-test/.github/workflows/repository-dispatch.yml | 7 +++++++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6346f2a0..c7fba6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release attestation warnings reduced by granting artifact metadata permission** ([#348](https://github.com/vig-os/devcontainer/issues/348)) - Add `artifact-metadata: write` to the release publish job so attestation steps can persist metadata storage records - Keep `actions/attest`-based SBOM attestation path and remove missing-permission warnings from publish runs +- **Smoke-test dispatch deploy now repairs workspace ownership before changelog copy** ([#352](https://github.com/vig-os/devcontainer/issues/352)) + - Add a write probe and conditional `sudo chown -R` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` after installer execution + - Prevent `Permission denied` failures when copying `.devcontainer/CHANGELOG.md` to repository root in GitHub-hosted runner jobs ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index a3f4be75..a6adafa5 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -182,6 +182,13 @@ jobs: curl -sSf "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/install.sh" \ | bash -s -- --version "${TAG}" --smoke-test --force --docker . + # Docker-based initialization can leave bind-mounted files owned by root. + # Normalize ownership so subsequent host-side workflow steps can write. + if ! touch ".workflow-write-probe" >/dev/null 2>&1; then + sudo chown -R "$(id -u):$(id -g)" . + fi + rm -f ".workflow-write-probe" + if [ ! -f ".devcontainer/CHANGELOG.md" ]; then echo "ERROR: expected .devcontainer/CHANGELOG.md after install" exit 1 diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 6346f2a0..c7fba6ae 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -67,6 +67,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release attestation warnings reduced by granting artifact metadata permission** ([#348](https://github.com/vig-os/devcontainer/issues/348)) - Add `artifact-metadata: write` to the release publish job so attestation steps can persist metadata storage records - Keep `actions/attest`-based SBOM attestation path and remove missing-permission warnings from publish runs +- **Smoke-test dispatch deploy now repairs workspace ownership before changelog copy** ([#352](https://github.com/vig-os/devcontainer/issues/352)) + - Add a write probe and conditional `sudo chown -R` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` after installer execution + - Prevent `Permission denied` failures when copying `.devcontainer/CHANGELOG.md` to repository root in GitHub-hosted runner jobs ### Security From aff43dff0b7cf254d450d9b8aeb8447076276ba9 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 21:44:03 +0100 Subject: [PATCH 079/215] fix(ci): probe changelog path writability before ownership repair Use target-specific writability checks for .devcontainer/CHANGELOG.md and CHANGELOG.md so ownership normalization triggers for the actual copy failure mode noted in PR review. Refs: #352 --- .../.github/workflows/repository-dispatch.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index a6adafa5..dcd2c5fb 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -183,11 +183,21 @@ jobs: | bash -s -- --version "${TAG}" --smoke-test --force --docker . # Docker-based initialization can leave bind-mounted files owned by root. - # Normalize ownership so subsequent host-side workflow steps can write. - if ! touch ".workflow-write-probe" >/dev/null 2>&1; then + # Probe writability of actual copy source/destination paths before repair. + NEEDS_CHOWN=false + if [ ! -w ".devcontainer/CHANGELOG.md" ]; then + NEEDS_CHOWN=true + fi + if [ -e "CHANGELOG.md" ]; then + if [ ! -w "CHANGELOG.md" ]; then + NEEDS_CHOWN=true + fi + elif [ ! -w "." ]; then + NEEDS_CHOWN=true + fi + if [ "${NEEDS_CHOWN}" = "true" ]; then sudo chown -R "$(id -u):$(id -g)" . fi - rm -f ".workflow-write-probe" if [ ! -f ".devcontainer/CHANGELOG.md" ]; then echo "ERROR: expected .devcontainer/CHANGELOG.md after install" From 44c72054835b3c9631a1d0b1b7c703e21d3e58f5 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 17 Mar 2026 22:34:25 +0100 Subject: [PATCH 080/215] fix(ci): validate changelog source readability in deploy probe Use source readability (-r) for .devcontainer/CHANGELOG.md in the dispatch deploy ownership guard so the probe matches cp preconditions more accurately. Refs: #352 --- assets/smoke-test/.github/workflows/repository-dispatch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index dcd2c5fb..c2d37a89 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -185,7 +185,7 @@ jobs: # Docker-based initialization can leave bind-mounted files owned by root. # Probe writability of actual copy source/destination paths before repair. NEEDS_CHOWN=false - if [ ! -w ".devcontainer/CHANGELOG.md" ]; then + if [ ! -r ".devcontainer/CHANGELOG.md" ]; then NEEDS_CHOWN=true fi if [ -e "CHANGELOG.md" ]; then From 2683cb0aa1b7e4e08cf6d7fc84984b9ef8d1ca8b Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 10:07:26 +0100 Subject: [PATCH 081/215] fix(ci): use status-aware smoke-test release lookup Refs: #355 --- .../.github/workflows/repository-dispatch.yml | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index c2d37a89..805f146c 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -316,8 +316,7 @@ jobs: EXPECTED_PRERELEASE=false fi - EXISTING_RELEASE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>/dev/null || true) - if [ -n "$EXISTING_RELEASE" ]; then + if EXISTING_RELEASE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>/dev/null); then EXISTING_PRERELEASE=$(echo "$EXISTING_RELEASE" | jq -r '.prerelease') if [ "$EXISTING_PRERELEASE" != "$EXPECTED_PRERELEASE" ]; then echo "ERROR: Existing release '$TAG' prerelease=$EXISTING_PRERELEASE (expected $EXPECTED_PRERELEASE)" @@ -328,19 +327,23 @@ jobs: --title "$TAG" \ --notes-file /tmp/smoke-test-release-notes.md echo "Release '$TAG' already exists with expected prerelease value." + else + if [ "$EXPECTED_PRERELEASE" = "true" ]; then + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file /tmp/smoke-test-release-notes.md \ + --prerelease + else + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file /tmp/smoke-test-release-notes.md + fi + echo "Created release '$TAG' with prerelease=$EXPECTED_PRERELEASE." exit 0 fi - if [ "$EXPECTED_PRERELEASE" = "true" ]; then - gh release create "$TAG" \ - --title "$TAG" \ - --notes-file /tmp/smoke-test-release-notes.md \ - --prerelease - else - gh release create "$TAG" \ - --title "$TAG" \ - --notes-file /tmp/smoke-test-release-notes.md - fi + echo "Updated existing release '$TAG' with expected prerelease value." + exit 0 - name: Summary env: From 8ff22052e6334272c0cc19f661503c32c68ed409 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 10:08:26 +0100 Subject: [PATCH 082/215] docs(ci): add changelog entry for release lookup fix Refs: #355 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7fba6ae..96c9cca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch deploy now repairs workspace ownership before changelog copy** ([#352](https://github.com/vig-os/devcontainer/issues/352)) - Add a write probe and conditional `sudo chown -R` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` after installer execution - Prevent `Permission denied` failures when copying `.devcontainer/CHANGELOG.md` to repository root in GitHub-hosted runner jobs +- **Smoke-test release lookup no longer treats missing tags as existing releases** ([#355](https://github.com/vig-os/devcontainer/issues/355)) + - Change `assets/smoke-test/.github/workflows/repository-dispatch.yml` to branch on `gh api` exit status when querying `releases/tags/` + - Ensure missing release tags follow the create path instead of failing with `prerelease=null` mismatch ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index c7fba6ae..96c9cca0 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -70,6 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch deploy now repairs workspace ownership before changelog copy** ([#352](https://github.com/vig-os/devcontainer/issues/352)) - Add a write probe and conditional `sudo chown -R` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` after installer execution - Prevent `Permission denied` failures when copying `.devcontainer/CHANGELOG.md` to repository root in GitHub-hosted runner jobs +- **Smoke-test release lookup no longer treats missing tags as existing releases** ([#355](https://github.com/vig-os/devcontainer/issues/355)) + - Change `assets/smoke-test/.github/workflows/repository-dispatch.yml` to branch on `gh api` exit status when querying `releases/tags/` + - Ensure missing release tags follow the create path instead of failing with `prerelease=null` mismatch ### Security From 3e5b769f154fce16e6a1bad14a75e06a902462a5 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 10:32:27 +0100 Subject: [PATCH 083/215] fix(ci): fail fast on non-404 release lookup errors Refs: #355 --- .../.github/workflows/repository-dispatch.yml | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 805f146c..0d3bbf22 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -316,8 +316,9 @@ jobs: EXPECTED_PRERELEASE=false fi - if EXISTING_RELEASE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>/dev/null); then - EXISTING_PRERELEASE=$(echo "$EXISTING_RELEASE" | jq -r '.prerelease') + RELEASE_LOOKUP_OUTPUT="" + if RELEASE_LOOKUP_OUTPUT=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>&1); then + EXISTING_PRERELEASE=$(echo "$RELEASE_LOOKUP_OUTPUT" | jq -r '.prerelease') if [ "$EXISTING_PRERELEASE" != "$EXPECTED_PRERELEASE" ]; then echo "ERROR: Existing release '$TAG' prerelease=$EXISTING_PRERELEASE (expected $EXPECTED_PRERELEASE)" exit 1 @@ -326,25 +327,27 @@ jobs: gh release edit "$TAG" \ --title "$TAG" \ --notes-file /tmp/smoke-test-release-notes.md - echo "Release '$TAG' already exists with expected prerelease value." + echo "Updated existing release '$TAG' with expected prerelease=$EXPECTED_PRERELEASE." else - if [ "$EXPECTED_PRERELEASE" = "true" ]; then - gh release create "$TAG" \ - --title "$TAG" \ - --notes-file /tmp/smoke-test-release-notes.md \ - --prerelease + if printf '%s' "$RELEASE_LOOKUP_OUTPUT" | grep -Eqi "404|not found"; then + if [ "$EXPECTED_PRERELEASE" = "true" ]; then + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file /tmp/smoke-test-release-notes.md \ + --prerelease + else + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file /tmp/smoke-test-release-notes.md + fi + echo "Created release '$TAG' with prerelease=$EXPECTED_PRERELEASE." else - gh release create "$TAG" \ - --title "$TAG" \ - --notes-file /tmp/smoke-test-release-notes.md + echo "ERROR: Failed to query release '$TAG'; refusing to create release." + echo "$RELEASE_LOOKUP_OUTPUT" + exit 1 fi - echo "Created release '$TAG' with prerelease=$EXPECTED_PRERELEASE." - exit 0 fi - echo "Updated existing release '$TAG' with expected prerelease value." - exit 0 - - name: Summary env: TAG: ${{ needs.validate.outputs.tag }} From 998f6f77ededbc09542426ef0133ed4515c02ecf Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 10:41:18 +0100 Subject: [PATCH 084/215] fix(ci): detect missing release via HTTP status Refs: #355 --- .../.github/workflows/repository-dispatch.yml | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 0d3bbf22..15fbb0b3 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -317,8 +317,23 @@ jobs: fi RELEASE_LOOKUP_OUTPUT="" - if RELEASE_LOOKUP_OUTPUT=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>&1); then - EXISTING_PRERELEASE=$(echo "$RELEASE_LOOKUP_OUTPUT" | jq -r '.prerelease') + set +e + RELEASE_LOOKUP_OUTPUT=$(gh api -i "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>&1) + RELEASE_LOOKUP_EXIT=$? + set -e + + RELEASE_LOOKUP_STATUS="$( + printf '%s\n' "$RELEASE_LOOKUP_OUTPUT" | awk ' + toupper($1) ~ /^HTTP\// { + print $2 + exit + } + ' + )" + + if [ "$RELEASE_LOOKUP_EXIT" -eq 0 ] && [ "$RELEASE_LOOKUP_STATUS" = "200" ]; then + EXISTING_RELEASE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}") + EXISTING_PRERELEASE=$(echo "$EXISTING_RELEASE" | jq -r '.prerelease') if [ "$EXISTING_PRERELEASE" != "$EXPECTED_PRERELEASE" ]; then echo "ERROR: Existing release '$TAG' prerelease=$EXISTING_PRERELEASE (expected $EXPECTED_PRERELEASE)" exit 1 @@ -329,7 +344,7 @@ jobs: --notes-file /tmp/smoke-test-release-notes.md echo "Updated existing release '$TAG' with expected prerelease=$EXPECTED_PRERELEASE." else - if printf '%s' "$RELEASE_LOOKUP_OUTPUT" | grep -Eqi "404|not found"; then + if [ "$RELEASE_LOOKUP_STATUS" = "404" ]; then if [ "$EXPECTED_PRERELEASE" = "true" ]; then gh release create "$TAG" \ --title "$TAG" \ @@ -342,7 +357,7 @@ jobs: fi echo "Created release '$TAG' with prerelease=$EXPECTED_PRERELEASE." else - echo "ERROR: Failed to query release '$TAG'; refusing to create release." + echo "ERROR: Failed to query release '$TAG' (status='${RELEASE_LOOKUP_STATUS:-unknown}', exit='${RELEASE_LOOKUP_EXIT}'); refusing to create release." echo "$RELEASE_LOOKUP_OUTPUT" exit 1 fi From 466523b1e20ea95b398c092e5c0ac69dec64054e Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 14:50:54 +0100 Subject: [PATCH 085/215] test(vigutils): add red tests for retry cli Refs: #357 --- packages/vig-utils/tests/test_retry.py | 134 +++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/vig-utils/tests/test_retry.py diff --git a/packages/vig-utils/tests/test_retry.py b/packages/vig-utils/tests/test_retry.py new file mode 100644 index 00000000..0af2abcc --- /dev/null +++ b/packages/vig-utils/tests/test_retry.py @@ -0,0 +1,134 @@ +"""Tests for the retry CLI.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest +from vig_utils import retry + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def test_retry_happy_path_succeeds_first_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"count": 0} + + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + calls["count"] += 1 + return subprocess.CompletedProcess(args=["true"], returncode=0) + + monkeypatch.setattr(subprocess, "run", fake_run) + rc = retry.retry_command(["true"], retries=3, backoff=1, max_backoff=2) + assert rc == 0 + assert calls["count"] == 1 + + +def test_retry_retries_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + attempts = {"count": 0} + sleep_calls: list[int] = [] + + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + attempts["count"] += 1 + if attempts["count"] < 3: + return subprocess.CompletedProcess(args=["cmd"], returncode=42) + return subprocess.CompletedProcess(args=["cmd"], returncode=0) + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(retry.time, "sleep", sleep_calls.append) + + rc = retry.retry_command(["cmd"], retries=4, backoff=2, max_backoff=10) + + assert rc == 0 + assert attempts["count"] == 3 + assert sleep_calls == [2, 4] + + +def test_retry_exhausts_attempts_and_returns_last_exit_code( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + sleep_calls: list[int] = [] + + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=["cmd"], returncode=17) + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(retry.time, "sleep", sleep_calls.append) + + rc = retry.retry_command(["cmd"], retries=3, backoff=1, max_backoff=5) + captured = capsys.readouterr() + + assert rc == 17 + assert sleep_calls == [1, 2] + assert "Command failed after 3/3 attempts (exit: 17)" in captured.err + + +@pytest.mark.parametrize( + "argv", + [ + ["--retries", "3", "--backoff", "1", "--max-backoff", "1", "--"], + ["--retries", "0", "--backoff", "1", "--max-backoff", "1", "--", "true"], + ["--retries", "3", "--backoff", "nope", "--max-backoff", "1", "--", "true"], + ], +) +def test_retry_input_validation_returns_exit_2( + monkeypatch: pytest.MonkeyPatch, argv: list[str] +) -> None: + monkeypatch.setattr(sys, "argv", ["retry", *argv]) + rc = retry.main() + assert rc == 2 + + +def test_retry_caps_backoff_with_max_backoff(monkeypatch: pytest.MonkeyPatch) -> None: + sleep_calls: list[int] = [] + + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=["cmd"], returncode=1) + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(retry.time, "sleep", sleep_calls.append) + + rc = retry.retry_command(["cmd"], retries=4, backoff=5, max_backoff=6) + + assert rc == 1 + assert sleep_calls == [5, 6, 6] + + +def test_retry_idempotent_for_successful_commands( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=["true"], returncode=0) + + monkeypatch.setattr(subprocess, "run", fake_run) + + rc1 = retry.retry_command(["true"], retries=2, backoff=1, max_backoff=1) + rc2 = retry.retry_command(["true"], retries=2, backoff=1, max_backoff=1) + + assert rc1 == rc2 == 0 + + +def test_retry_cli_module_invocation_succeeds() -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "vig_utils.retry", + "--retries", + "2", + "--backoff", + "1", + "--max-backoff", + "1", + "--", + "true", + ], + text=True, + capture_output=True, + check=False, + cwd=REPO_ROOT, + ) + assert result.returncode == 0, result.stderr From 13a3550e1227065ea19344a47c887c4aa61eee57 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 14:52:35 +0100 Subject: [PATCH 086/215] feat(vigutils): add bounded retry cli command Refs: #357 --- packages/vig-utils/pyproject.toml | 1 + packages/vig-utils/src/vig_utils/retry.py | 118 ++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 packages/vig-utils/src/vig_utils/retry.py diff --git a/packages/vig-utils/pyproject.toml b/packages/vig-utils/pyproject.toml index b6efb4c9..4a80fd13 100644 --- a/packages/vig-utils/pyproject.toml +++ b/packages/vig-utils/pyproject.toml @@ -27,6 +27,7 @@ resolve-branch = "vig_utils.resolve_branch:main" derive-branch-summary = "vig_utils.derive_branch_summary:main" check-skill-names = "vig_utils.check_skill_names:main" setup-labels = "vig_utils.setup_labels:main" +retry = "vig_utils.retry:main" vig-utils = "vig_utils.utils:main" [tool.hatch.build.targets.wheel] diff --git a/packages/vig-utils/src/vig_utils/retry.py b/packages/vig-utils/src/vig_utils/retry.py new file mode 100644 index 00000000..b0370ec9 --- /dev/null +++ b/packages/vig-utils/src/vig_utils/retry.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Retry CLI for transient command failures with bounded exponential backoff. + +Use `uv run retry -- ...` on bare runners that execute within the repository's +Python environment, or `retry -- ...` in devcontainer jobs where the command is +already available on PATH. +""" + +from __future__ import annotations + +import subprocess +import sys +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def _parse_positive_int(value: str, option_name: str) -> int: + if not value.isdigit() or int(value) <= 0: + raise ValueError(f"retry: {option_name} must be a positive integer") + return int(value) + + +def parse_cli(argv: Sequence[str]) -> tuple[int, int, int, list[str]]: + """Parse retry options and command from argv (without program name).""" + retries = 3 + backoff = 5 + max_backoff = 60 + command_start = None + i = 0 + + while i < len(argv): + arg = argv[i] + if arg == "--": + command_start = i + 1 + break + if arg == "--retries": + if i + 1 >= len(argv): + raise ValueError("retry: missing value for '--retries'") + retries = _parse_positive_int(argv[i + 1], "--retries") + i += 2 + continue + if arg == "--backoff": + if i + 1 >= len(argv): + raise ValueError("retry: missing value for '--backoff'") + backoff = _parse_positive_int(argv[i + 1], "--backoff") + i += 2 + continue + if arg == "--max-backoff": + if i + 1 >= len(argv): + raise ValueError("retry: missing value for '--max-backoff'") + max_backoff = _parse_positive_int(argv[i + 1], "--max-backoff") + i += 2 + continue + raise ValueError(f"retry: unknown option '{arg}'") + + if command_start is None or command_start >= len(argv): + raise ValueError("retry: missing command after '--'") + + return retries, backoff, max_backoff, list(argv[command_start:]) + + +def retry_command( + command: list[str], + *, + retries: int, + backoff: int, + max_backoff: int, +) -> int: + """Run command with bounded exponential retry.""" + exit_code = 0 + for attempt in range(1, retries + 1): + result = subprocess.run( + command, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + check=False, + ) + if result.returncode == 0: + return 0 + exit_code = result.returncode + + if attempt == retries: + print( + f"Command failed after {retries}/{retries} attempts (exit: {exit_code})", + file=sys.stderr, + ) + return exit_code + + wait_seconds = min(backoff * (1 << (attempt - 1)), max_backoff) + print( + f"Attempt {attempt}/{retries} failed (exit: {exit_code}); retrying in {wait_seconds}s...", + file=sys.stderr, + ) + time.sleep(wait_seconds) + return exit_code + + +def main() -> int: + try: + retries, backoff, max_backoff, command = parse_cli(sys.argv[1:]) + except ValueError as error: + print(str(error), file=sys.stderr) + return 2 + + return retry_command( + command, + retries=retries, + backoff=backoff, + max_backoff=max_backoff, + ) + + +if __name__ == "__main__": + sys.exit(main()) From 9b484ab08eaf5b042220ccbb2982673f1302f33e Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 14:52:51 +0100 Subject: [PATCH 087/215] fix(ci): use retry cli for setup and image pull steps Refs: #357 --- .github/actions/setup-env/action.yml | 16 ++++++++++++---- .github/actions/test-image/action.yml | 17 ++--------------- .github/workflows/ci.yml | 4 +++- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index 50c05ee2..44d604c4 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -162,8 +162,10 @@ runs: BIN_FILE="hadolint-${ARCH}" SHA_FILE="${BIN_FILE}.sha256" - curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" - curl -fsSL "${BASE_URL}/${SHA_FILE}" -o "${SHA_FILE}" + uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" + uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + curl -fsSL "${BASE_URL}/${SHA_FILE}" -o "${SHA_FILE}" EXPECTED_SHA="$(awk '{print $1}' "${SHA_FILE}")" echo "${EXPECTED_SHA} ${BIN_FILE}" | sha256sum -c - @@ -189,11 +191,17 @@ runs: ;; esac - TAPLO_VERSION="$(curl -fsSL https://api.github.com/repos/tamasfe/taplo/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')" + TAPLO_VERSION="$(uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + curl -fsSL https://api.github.com/repos/tamasfe/taplo/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')" + if [ -z "${TAPLO_VERSION:-}" ]; then + echo "ERROR: Failed to resolve Taplo version from GitHub releases API" + exit 1 + fi BASE_URL="https://github.com/tamasfe/taplo/releases/download/${TAPLO_VERSION}" BIN_FILE="taplo-linux-${ARCH}.gz" - curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" + uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" gunzip "${BIN_FILE}" sudo install -m 0755 "taplo-linux-${ARCH}" /usr/local/bin/taplo rm -f "taplo-linux-${ARCH}" diff --git a/.github/actions/test-image/action.yml b/.github/actions/test-image/action.yml index 69667a54..d8ebcae4 100644 --- a/.github/actions/test-image/action.yml +++ b/.github/actions/test-image/action.yml @@ -125,21 +125,8 @@ runs: echo "Pulling image: $IMAGE_TAG" # Retry logic for podman pull (network flakiness) - RETRIES=3 - for i in $(seq 1 $RETRIES); do - if podman pull "$IMAGE_TAG"; then - echo "Image pulled successfully" - break - else - if [ $i -lt $RETRIES ]; then - echo "Pull failed, retrying ($i/$RETRIES)..." - sleep 3 - else - echo "Pull failed after $RETRIES attempts" - exit 1 - fi - fi - done + uv run retry --retries 3 --backoff 3 --max-backoff 3 -- podman pull "$IMAGE_TAG" + echo "Image pulled successfully" - name: Verify image is available if: inputs.image-source == 'local' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cafca84a..9201bbf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,9 @@ jobs: sync-dependencies: 'true' - name: Install safety - run: uv pip install safety==3.7.0 + run: | + set -euo pipefail + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- uv pip install safety==3.7.0 - name: Run Bandit (Python security linting) id: bandit From 2ff89ec47256540d6c888a356ef82adbc06d032b Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 14:53:46 +0100 Subject: [PATCH 088/215] fix(ci): harden release and sync workflows with retry Refs: #357 --- .github/workflows/prepare-release.yml | 27 ++-- .github/workflows/release.yml | 142 ++++++------------ .github/workflows/sync-issues.yml | 6 +- .github/workflows/sync-main-to-dev.yml | 47 ++++-- .../.github/workflows/sync-issues.yml | 6 +- .../.github/workflows/sync-main-to-dev.yml | 47 ++++-- 6 files changed, 143 insertions(+), 132 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index b58c66ea..a1d8bfe5 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -185,7 +185,8 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} run: | set -euo pipefail - PREPARE_START_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') + PREPARE_START_SHA=$(uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') echo "prepare_start_sha=$PREPARE_START_SHA" >> "$GITHUB_OUTPUT" echo "✓ Captured pre-prepare dev SHA: $PREPARE_START_SHA" @@ -237,9 +238,11 @@ jobs: RELEASE_BRANCH: ${{ needs.validate.outputs.release_branch }} run: | set -euo pipefail - DEV_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') + DEV_SHA=$(uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') echo "Creating branch $RELEASE_BRANCH from dev at $DEV_SHA..." - gh api "repos/${{ github.repository }}/git/refs" \ + uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/refs" \ -f ref="refs/heads/$RELEASE_BRANCH" \ -f sha="$DEV_SHA" echo "dev_sha=$DEV_SHA" >> "$GITHUB_OUTPUT" @@ -299,7 +302,8 @@ jobs: $CHANGELOG_CONTENT " - PR_URL=$(gh pr create \ + PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh pr create \ --base main \ --head "$RELEASE_BRANCH" \ --title "chore: release $VERSION" \ @@ -335,8 +339,10 @@ jobs: exit 0 fi - if gh api "repos/${{ github.repository }}/git/ref/heads/$RELEASE_BRANCH" >/dev/null 2>&1; then - gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/$RELEASE_BRANCH" + if uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/$RELEASE_BRANCH" >/dev/null 2>&1; then + uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/$RELEASE_BRANCH" BRANCH_DELETED=true echo "✓ Deleted partially created release branch: $RELEASE_BRANCH" else @@ -344,7 +350,8 @@ jobs: fi if [ -n "${POST_FREEZE_DEV_SHA:-}" ]; then - CURRENT_DEV_SHA="$(gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha')" + CURRENT_DEV_SHA="$(uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha')" if [ "$CURRENT_DEV_SHA" != "$POST_FREEZE_DEV_SHA" ]; then echo "w dev advanced after freeze commit; skipping CHANGELOG rollback to avoid clobbering concurrent updates" echo "branch_deleted=$BRANCH_DELETED" >> "$GITHUB_OUTPUT" @@ -353,8 +360,10 @@ jobs: fi fi - gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=$PREPARE_START_SHA" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.pre-prepare.md - gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=dev" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.current-dev.md + uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=$PREPARE_START_SHA" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.pre-prepare.md + uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=dev" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.current-dev.md if cmp -s /tmp/changelog.pre-prepare.md /tmp/changelog.current-dev.md; then echo "i dev CHANGELOG already matches pre-prepare state" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e54dd01..8ad6ecdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,7 +117,8 @@ jobs: env: VERSION: ${{ steps.vars.outputs.version }} run: | - git fetch origin "release/$VERSION" || { + set -euo pipefail + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" || { echo "ERROR: Release branch not found: release/$VERSION" echo "Did you run: just prepare-release $VERSION" exit 1 @@ -333,7 +334,7 @@ jobs: RELEASE_BRANCH="release/$VERSION" # Find PR from release branch to main - PR_JSON=$(gh pr list \ + PR_JSON=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr list \ --head "$RELEASE_BRANCH" \ --base main \ --json number,isDraft,reviewDecision,statusCheckRollup \ @@ -575,7 +576,7 @@ jobs: run: | set -euo pipefail echo "Triggering sync-issues workflow..." - gh workflow run sync-issues.yml \ + uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh workflow run sync-issues.yml \ -f "target-branch=release/$VERSION" echo "✓ sync-issues workflow triggered" @@ -630,7 +631,7 @@ jobs: VERSION: ${{ needs.validate.outputs.version }} run: | set -euo pipefail - git fetch origin "release/$VERSION" + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" git reset --hard "origin/release/$VERSION" echo "✓ Synced with remote release branch" @@ -660,7 +661,8 @@ jobs: $CHANGELOG_CONTENT EOF - gh pr edit "$PR_NUMBER" --body-file /tmp/release-pr-body.md + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr edit "$PR_NUMBER" --body-file /tmp/release-pr-body.md echo "✓ Refreshed PR #$PR_NUMBER body from finalized CHANGELOG.md" - name: Output finalize SHA @@ -674,7 +676,8 @@ jobs: if [ "$RELEASE_KIND" = "final" ]; then FINALIZE_SHA=$(git rev-parse HEAD) else - FINALIZE_SHA=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/release/$VERSION" --jq '.object.sha') + FINALIZE_SHA=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/release/$VERSION" --jq '.object.sha') fi echo "finalize_sha=$FINALIZE_SHA" >> $GITHUB_OUTPUT echo "Release kind: $RELEASE_KIND — SHA: $FINALIZE_SHA" @@ -801,7 +804,14 @@ jobs: fi fi - git push origin "$PUBLISH_VERSION" + if ! uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then + if git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then + echo "Tag already present on origin after push attempt: $PUBLISH_VERSION" + else + echo "ERROR: Failed to push tag $PUBLISH_VERSION" + exit 1 + fi + fi echo "✓ Tag pushed: $PUBLISH_VERSION" - name: Generate final release notes from CHANGELOG @@ -865,22 +875,8 @@ jobs: IMAGE_TAG="$REPO:$PUBLISH_VERSION-$arch" docker tag "$SOURCE_IMAGE_TAG" "$IMAGE_TAG" echo "Pushing $arch image: $IMAGE_TAG" - - RETRIES=3 - for i in $(seq 1 $RETRIES); do - if docker push "$IMAGE_TAG"; then - echo "✓ Pushed: $IMAGE_TAG" - break - else - if [ $i -lt $RETRIES ]; then - echo "Push failed, retrying ($i/$RETRIES)..." - sleep 3 - else - echo "ERROR: Failed to push after $RETRIES attempts" - exit 1 - fi - fi - done + uv run retry --retries 3 --backoff 3 --max-backoff 3 -- docker push "$IMAGE_TAG" + echo "✓ Pushed: $IMAGE_TAG" done - name: Create multi-arch manifest @@ -902,45 +898,20 @@ jobs: done echo "Creating version manifest: $REPO:$PUBLISH_VERSION" - RETRIES=3 - for i in $(seq 1 $RETRIES); do - if docker buildx imagetools create \ + uv run retry --retries 3 --backoff 3 --max-backoff 3 -- \ + docker buildx imagetools create \ --tag "$REPO:$PUBLISH_VERSION" \ - "${ARCH_IMAGES[@]}"; then - echo "✓ Created version manifest: $REPO:$PUBLISH_VERSION" - break - else - if [ $i -lt $RETRIES ]; then - echo "Manifest creation failed, retrying ($i/$RETRIES)..." - sleep 3 - else - echo "ERROR: Failed to create version manifest" - exit 1 - fi - fi - done + "${ARCH_IMAGES[@]}" + echo "✓ Created version manifest: $REPO:$PUBLISH_VERSION" # Update latest manifest only for final releases building both architectures. if [ "$RELEASE_KIND" = "final" ] && [ ${#ARCH_ARRAY[@]} -eq 2 ]; then echo "Creating/updating latest manifest: $REPO:latest" - - RETRIES=3 - for i in $(seq 1 $RETRIES); do - if docker buildx imagetools create \ + uv run retry --retries 3 --backoff 3 --max-backoff 3 -- \ + docker buildx imagetools create \ --tag "$REPO:latest" \ - "${ARCH_IMAGES[@]}"; then - echo "✓ Created/updated latest manifest: $REPO:latest" - break - else - if [ $i -lt $RETRIES ]; then - echo "Latest manifest creation failed, retrying ($i/$RETRIES)..." - sleep 3 - else - echo "ERROR: Failed to create latest manifest" - exit 1 - fi - fi - done + "${ARCH_IMAGES[@]}" + echo "✓ Created/updated latest manifest: $REPO:latest" else echo "Skipping 'latest' manifest (single architecture or limited build)" fi @@ -1030,24 +1001,13 @@ jobs: REPO="ghcr.io/vig-os/devcontainer" # Get the digest for the multi-arch manifest - DIGEST=$(docker buildx imagetools inspect "$REPO:$PUBLISH_VERSION" --format '{{json .Manifest.Digest}}' | tr -d '"') + DIGEST=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + docker buildx imagetools inspect "$REPO:$PUBLISH_VERSION" --format '{{json .Manifest.Digest}}' | tr -d '"') echo "Signing image: $REPO@$DIGEST" # Keyless signing using GitHub Actions OIDC identity - RETRIES=3 - for i in $(seq 1 $RETRIES); do - if cosign sign --yes "$REPO@$DIGEST"; then - break - fi - - if [ $i -lt $RETRIES ]; then - echo "Cosign sign failed, retrying ($i/$RETRIES)..." - sleep 15 - else - echo "ERROR: Failed to sign image after $RETRIES attempts" - exit 1 - fi - done + uv run retry --retries 3 --backoff 15 --max-backoff 15 -- \ + cosign sign --yes "$REPO@$DIGEST" echo "✓ Image signed with cosign (keyless)" - name: Capture image digest for attestation @@ -1055,8 +1015,10 @@ jobs: env: PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} run: | + set -euo pipefail REPO="ghcr.io/vig-os/devcontainer" - DIGEST=$(docker buildx imagetools inspect "$REPO:$PUBLISH_VERSION" --format '{{json .Manifest.Digest}}' | tr -d '"') + DIGEST=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + docker buildx imagetools inspect "$REPO:$PUBLISH_VERSION" --format '{{json .Manifest.Digest}}' | tr -d '"') echo "digest=$DIGEST" >> $GITHUB_OUTPUT echo "Image digest: $DIGEST" @@ -1111,15 +1073,21 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - if gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" exit 1 fi - gh release create "$PUBLISH_VERSION" \ + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$PUBLISH_VERSION" \ --title "$PUBLISH_VERSION" \ --notes-file /tmp/github-release-notes.md \ - --verify-tag + --verify-tag || { + if gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + echo "GitHub Release already present after create attempt: $PUBLISH_VERSION" + else + exit 1 + fi + } echo "✓ GitHub Release published: $PUBLISH_VERSION" @@ -1186,9 +1154,8 @@ jobs: CORRELATION_ID: ${{ github.repository }}:${{ github.run_id }}:${{ needs.validate.outputs.publish_version }} run: | set -euo pipefail - RETRIES=3 - for i in $(seq 1 $RETRIES); do - if gh api repos/vig-os/devcontainer-smoke-test/dispatches \ + uv run retry --retries 3 --backoff 10 --max-backoff 10 -- \ + gh api repos/vig-os/devcontainer-smoke-test/dispatches \ -f event_type=smoke-test-trigger \ -f "client_payload[tag]=$RELEASE_TAG" \ -f "client_payload[release_kind]=$RELEASE_KIND" \ @@ -1198,18 +1165,7 @@ jobs: -f "client_payload[source_run_id]=$SOURCE_RUN_ID" \ -f "client_payload[source_run_url]=$SOURCE_RUN_URL" \ -f "client_payload[source_sha]=$SOURCE_SHA" \ - -f "client_payload[correlation_id]=$CORRELATION_ID"; then - break - fi - - if [ $i -lt $RETRIES ]; then - echo "Dispatch failed, retrying ($i/$RETRIES)..." - sleep 10 - else - echo "ERROR: Failed to trigger smoke-test dispatch after $RETRIES attempts" - exit 1 - fi - done + -f "client_payload[correlation_id]=$CORRELATION_ID" echo "✓ Triggered smoke-test dispatch for release tag: $RELEASE_TAG" - name: Summary @@ -1286,7 +1242,7 @@ jobs: run: | set -euo pipefail echo "Rolling back release branch to pre-finalization state..." - gh api "repos/${{ github.repository }}/git/refs/heads/release/$VERSION" \ + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh api "repos/${{ github.repository }}/git/refs/heads/release/$VERSION" \ -X PATCH \ -f sha="$PRE_SHA" \ -F force=true @@ -1301,9 +1257,9 @@ jobs: run: | set -euo pipefail TAG="$PUBLISH_VERSION" - if gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then + if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then echo "Deleting remote tag: $TAG" - gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" -X DELETE + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" -X DELETE echo "✓ Tag deleted" else echo "Tag does not exist on remote (not created)" diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 60e2ec6e..43c60710 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -83,10 +83,12 @@ jobs: sleep 2 # Try to delete cache using GitHub API CACHE_KEY="sync-issues-state-${{ github.repository }}" - CACHE_ID=$(gh api repos/${{ github.repository }}/actions/caches --jq ".actions_caches[] | select(.key == \"$CACHE_KEY\") | .id" | head -1) + CACHE_ID=$(uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + gh api repos/${{ github.repository }}/actions/caches --jq ".actions_caches[] | select(.key == \"$CACHE_KEY\") | .id" | head -1) if [ -n "$CACHE_ID" ]; then echo "Found cache ID: $CACHE_ID, attempting deletion..." - gh api repos/${{ github.repository }}/actions/caches/$CACHE_ID -X DELETE && echo "Cache deleted successfully" || echo "Cache deletion failed (may be locked or already deleted)" + uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + gh api repos/${{ github.repository }}/actions/caches/$CACHE_ID -X DELETE && echo "Cache deleted successfully" || echo "Cache deletion failed (may be locked or already deleted)" else echo "No cache found with key: $CACHE_KEY (this is OK for first run)" fi diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index ac3ecc92..7f883201 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -53,7 +53,7 @@ jobs: id: check run: | set -euo pipefail - git fetch origin main dev + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev if ! git show-ref --verify --quiet refs/remotes/origin/main; then echo "Error: remote branch 'origin/main' not found after fetch." exit 1 @@ -103,7 +103,7 @@ jobs: id: recheck run: | set -euo pipefail - git fetch origin main dev + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev BEHIND=$(git rev-list --count origin/main ^origin/dev) if [ "${BEHIND}" = "0" ]; then echo "up_to_date=true" >> "$GITHUB_OUTPUT" @@ -128,7 +128,8 @@ jobs: GH_TOKEN: ${{ steps.release-app-token.outputs.token }} run: | set -euo pipefail - OPEN=$(gh pr list --base dev --state open --limit 200 \ + OPEN=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr list --base dev --state open --limit 200 \ --json headRefName \ --jq '[.[] | select(.headRefName | startswith("chore/sync-main-to-dev-"))] | length') echo "count=${OPEN}" >> "$GITHUB_OUTPUT" @@ -142,13 +143,16 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} run: | set -euo pipefail - REFS=$(gh api --paginate "repos/${{ github.repository }}/git/matching-refs/heads/chore/sync-main-to-dev-" \ + REFS=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api --paginate "repos/${{ github.repository }}/git/matching-refs/heads/chore/sync-main-to-dev-" \ --jq '.[].ref' | sed 's|refs/heads/||') || true for branch in ${REFS}; do - HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" gh pr list --base dev --head "${branch}" \ + HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr list --base dev --head "${branch}" \ --state open --json number --jq 'length') if [ "${HAS_PR}" = "0" ]; then - gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${branch}" 2>/dev/null || true + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${branch}" 2>/dev/null || true echo "Deleted stale sync branch: ${branch}" fi done @@ -158,7 +162,7 @@ jobs: id: merge-check run: | set -euo pipefail - git fetch origin main + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main if git merge --no-commit --no-ff origin/main 2>/dev/null; then echo "conflict=false" >> "$GITHUB_OUTPUT" else @@ -173,9 +177,17 @@ jobs: run: | set -euo pipefail MAIN_SHA=$(git rev-parse origin/main) - gh api "repos/${{ github.repository }}/git/refs" \ + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api "repos/${{ github.repository }}/git/refs" \ -f ref="refs/heads/${SYNC_BRANCH}" \ - -f sha="${MAIN_SHA}" + -f sha="${MAIN_SHA}" || { + if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/${SYNC_BRANCH}" >/dev/null 2>&1; then + echo "Sync branch already exists: ${SYNC_BRANCH}" + else + exit 1 + fi + } echo "Sync branch ${SYNC_BRANCH} created from main at ${MAIN_SHA}" - name: Create PR @@ -211,14 +223,22 @@ jobs: BODY="Syncs \`dev\` with \`main\` (sync-main-to-dev workflow)." fi - PR_URL=$(gh pr create --base dev --head "${SYNC_BRANCH}" \ - --title "${TITLE}" --body "${BODY}") + EXISTING_PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr list --base dev --head "${SYNC_BRANCH}" --state open --json url --jq '.[0].url // empty') + if [ -n "${EXISTING_PR_URL}" ]; then + PR_URL="${EXISTING_PR_URL}" + else + PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr create --base dev --head "${SYNC_BRANCH}" \ + --title "${TITLE}" --body "${BODY}") + fi echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" echo "Created PR: ${PR_URL}" if [ "${CONFLICT}" = "true" ]; then gh label create "merge-conflict" --color "B60205" --force 2>/dev/null || true - gh pr edit "${SYNC_BRANCH}" --add-label "merge-conflict" || \ + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr edit "${SYNC_BRANCH}" --add-label "merge-conflict" || \ echo "Warning: failed to add merge-conflict label." fi @@ -231,5 +251,6 @@ jobs: PR_URL: ${{ steps.create-pr.outputs.pr_url }} run: | set -euo pipefail - gh pr merge "${PR_URL}" --auto --merge || \ + uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index 60e2ec6e..43c60710 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -83,10 +83,12 @@ jobs: sleep 2 # Try to delete cache using GitHub API CACHE_KEY="sync-issues-state-${{ github.repository }}" - CACHE_ID=$(gh api repos/${{ github.repository }}/actions/caches --jq ".actions_caches[] | select(.key == \"$CACHE_KEY\") | .id" | head -1) + CACHE_ID=$(uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + gh api repos/${{ github.repository }}/actions/caches --jq ".actions_caches[] | select(.key == \"$CACHE_KEY\") | .id" | head -1) if [ -n "$CACHE_ID" ]; then echo "Found cache ID: $CACHE_ID, attempting deletion..." - gh api repos/${{ github.repository }}/actions/caches/$CACHE_ID -X DELETE && echo "Cache deleted successfully" || echo "Cache deletion failed (may be locked or already deleted)" + uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + gh api repos/${{ github.repository }}/actions/caches/$CACHE_ID -X DELETE && echo "Cache deleted successfully" || echo "Cache deletion failed (may be locked or already deleted)" else echo "No cache found with key: $CACHE_KEY (this is OK for first run)" fi diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index ac3ecc92..7f883201 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -53,7 +53,7 @@ jobs: id: check run: | set -euo pipefail - git fetch origin main dev + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev if ! git show-ref --verify --quiet refs/remotes/origin/main; then echo "Error: remote branch 'origin/main' not found after fetch." exit 1 @@ -103,7 +103,7 @@ jobs: id: recheck run: | set -euo pipefail - git fetch origin main dev + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev BEHIND=$(git rev-list --count origin/main ^origin/dev) if [ "${BEHIND}" = "0" ]; then echo "up_to_date=true" >> "$GITHUB_OUTPUT" @@ -128,7 +128,8 @@ jobs: GH_TOKEN: ${{ steps.release-app-token.outputs.token }} run: | set -euo pipefail - OPEN=$(gh pr list --base dev --state open --limit 200 \ + OPEN=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr list --base dev --state open --limit 200 \ --json headRefName \ --jq '[.[] | select(.headRefName | startswith("chore/sync-main-to-dev-"))] | length') echo "count=${OPEN}" >> "$GITHUB_OUTPUT" @@ -142,13 +143,16 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} run: | set -euo pipefail - REFS=$(gh api --paginate "repos/${{ github.repository }}/git/matching-refs/heads/chore/sync-main-to-dev-" \ + REFS=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api --paginate "repos/${{ github.repository }}/git/matching-refs/heads/chore/sync-main-to-dev-" \ --jq '.[].ref' | sed 's|refs/heads/||') || true for branch in ${REFS}; do - HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" gh pr list --base dev --head "${branch}" \ + HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr list --base dev --head "${branch}" \ --state open --json number --jq 'length') if [ "${HAS_PR}" = "0" ]; then - gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${branch}" 2>/dev/null || true + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${branch}" 2>/dev/null || true echo "Deleted stale sync branch: ${branch}" fi done @@ -158,7 +162,7 @@ jobs: id: merge-check run: | set -euo pipefail - git fetch origin main + uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main if git merge --no-commit --no-ff origin/main 2>/dev/null; then echo "conflict=false" >> "$GITHUB_OUTPUT" else @@ -173,9 +177,17 @@ jobs: run: | set -euo pipefail MAIN_SHA=$(git rev-parse origin/main) - gh api "repos/${{ github.repository }}/git/refs" \ + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api "repos/${{ github.repository }}/git/refs" \ -f ref="refs/heads/${SYNC_BRANCH}" \ - -f sha="${MAIN_SHA}" + -f sha="${MAIN_SHA}" || { + if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/${SYNC_BRANCH}" >/dev/null 2>&1; then + echo "Sync branch already exists: ${SYNC_BRANCH}" + else + exit 1 + fi + } echo "Sync branch ${SYNC_BRANCH} created from main at ${MAIN_SHA}" - name: Create PR @@ -211,14 +223,22 @@ jobs: BODY="Syncs \`dev\` with \`main\` (sync-main-to-dev workflow)." fi - PR_URL=$(gh pr create --base dev --head "${SYNC_BRANCH}" \ - --title "${TITLE}" --body "${BODY}") + EXISTING_PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr list --base dev --head "${SYNC_BRANCH}" --state open --json url --jq '.[0].url // empty') + if [ -n "${EXISTING_PR_URL}" ]; then + PR_URL="${EXISTING_PR_URL}" + else + PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr create --base dev --head "${SYNC_BRANCH}" \ + --title "${TITLE}" --body "${BODY}") + fi echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" echo "Created PR: ${PR_URL}" if [ "${CONFLICT}" = "true" ]; then gh label create "merge-conflict" --color "B60205" --force 2>/dev/null || true - gh pr edit "${SYNC_BRANCH}" --add-label "merge-conflict" || \ + uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr edit "${SYNC_BRANCH}" --add-label "merge-conflict" || \ echo "Warning: failed to add merge-conflict label." fi @@ -231,5 +251,6 @@ jobs: PR_URL: ${{ steps.create-pr.outputs.pr_url }} run: | set -euo pipefail - gh pr merge "${PR_URL}" --auto --merge || \ + uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" From 06e4aab7dd7320c552b4165b9caaa5ff5d2ead81 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 14:54:06 +0100 Subject: [PATCH 089/215] fix(ci): harden workspace release templates with retry Refs: #357 --- .../.github/actions/resolve-image/action.yml | 3 +- .../.github/workflows/prepare-release.yml | 49 +++++++++++++----- .../.github/workflows/release-core.yml | 51 +++++++++++-------- .../.github/workflows/release-publish.yml | 37 ++++++++++++-- .../workspace/.github/workflows/release.yml | 11 ++-- 5 files changed, 108 insertions(+), 43 deletions(-) diff --git a/assets/workspace/.github/actions/resolve-image/action.yml b/assets/workspace/.github/actions/resolve-image/action.yml index 27d0fbce..8782ed8d 100644 --- a/assets/workspace/.github/actions/resolve-image/action.yml +++ b/assets/workspace/.github/actions/resolve-image/action.yml @@ -56,7 +56,8 @@ runs: fi fi - echo "tag=latest" >> "$GITHUB_OUTPUT" + echo "ERROR: Could not resolve DEVCONTAINER_VERSION from .vig-os and no image-tag override was provided." + exit 1 - name: Validate image accessibility shell: bash diff --git a/assets/workspace/.github/workflows/prepare-release.yml b/assets/workspace/.github/workflows/prepare-release.yml index be264648..3a5dc5a5 100644 --- a/assets/workspace/.github/workflows/prepare-release.yml +++ b/assets/workspace/.github/workflows/prepare-release.yml @@ -171,7 +171,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - PREPARE_START_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') + PREPARE_START_SHA=$(retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') echo "prepare_start_sha=$PREPARE_START_SHA" >> "$GITHUB_OUTPUT" - name: Prepare CHANGELOG (freeze + reset) @@ -214,10 +215,19 @@ jobs: RELEASE_BRANCH: ${{ needs.validate.outputs.release_branch }} run: | set -euo pipefail - DEV_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') - gh api "repos/${{ github.repository }}/git/refs" \ + DEV_SHA=$(retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') + retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/refs" \ -f ref="refs/heads/$RELEASE_BRANCH" \ - -f sha="$DEV_SHA" + -f sha="$DEV_SHA" || { + if retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/$RELEASE_BRANCH" >/dev/null 2>&1; then + echo "Release branch already exists: $RELEASE_BRANCH" + else + exit 1 + fi + } echo "dev_sha=$DEV_SHA" >> "$GITHUB_OUTPUT" - name: Strip empty Unreleased section for release branch @@ -266,12 +276,19 @@ jobs: $CHANGELOG_CONTENT " - PR_URL=$(gh pr create \ - --base main \ - --head "$RELEASE_BRANCH" \ - --title "chore: release $VERSION" \ - --body "$PR_BODY" \ - --draft) + EXISTING_PR_URL=$(retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh pr list --head "$RELEASE_BRANCH" --base main --state open --json url --jq '.[0].url // empty') + if [ -n "$EXISTING_PR_URL" ]; then + PR_URL="$EXISTING_PR_URL" + else + PR_URL=$(retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh pr create \ + --base main \ + --head "$RELEASE_BRANCH" \ + --title "chore: release $VERSION" \ + --body "$PR_BODY" \ + --draft) + fi PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" @@ -287,21 +304,25 @@ jobs: run: | set -euo pipefail CHANGELOG_ROLLBACK_NEEDED=false - gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/$RELEASE_BRANCH" || true + retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/$RELEASE_BRANCH" || true if [ -z "${PREPARE_START_SHA:-}" ] || [ -z "${POST_FREEZE_DEV_SHA:-}" ]; then echo "changelog_rollback_needed=false" >> "$GITHUB_OUTPUT" exit 0 fi - CURRENT_DEV_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') + CURRENT_DEV_SHA=$(retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/git/ref/heads/dev" --jq '.object.sha') if [ "$CURRENT_DEV_SHA" != "$POST_FREEZE_DEV_SHA" ]; then echo "changelog_rollback_needed=false" >> "$GITHUB_OUTPUT" exit 0 fi - gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=$PREPARE_START_SHA" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.pre.md - gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=dev" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.current.md + retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=$PREPARE_START_SHA" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.pre.md + retry --retries 3 --backoff 5 --max-backoff 60 -- \ + gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=dev" --jq '.content' | tr -d '\n' | base64 -d > /tmp/changelog.current.md if ! cmp -s /tmp/changelog.pre.md /tmp/changelog.current.md; then cp /tmp/changelog.pre.md CHANGELOG.md CHANGELOG_ROLLBACK_NEEDED=true diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index f2817a70..e5be010d 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -68,9 +68,29 @@ permissions: contents: read jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + validate: name: Validate Release Core + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 outputs: version: ${{ steps.vars.outputs.version }} @@ -79,7 +99,7 @@ jobs: pre_finalize_sha: ${{ steps.pre_sha.outputs.pre_finalize_sha }} release_kind: ${{ steps.vars.outputs.release_kind }} publish_version: ${{ steps.publish_meta.outputs.publish_version }} - image_tag: ${{ steps.resolve_image.outputs.image_tag }} + image_tag: ${{ needs.resolve-image.outputs.image-tag }} steps: - name: Validate contract version @@ -133,27 +153,14 @@ jobs: fetch-depth: 0 token: ${{ steps.auth.outputs.token }} - - name: Resolve container image tag - id: resolve_image - run: | - set -euo pipefail - TAG="" - if [ -f ".vig-os" ]; then - TAG=$(awk -F= '/^DEVCONTAINER_VERSION=/{gsub(/^[ \t"]+|[ \t"]+$/, "", $2); print $2; exit}' .vig-os || true) - fi - if [ -z "${TAG:-}" ]; then - TAG="latest" - fi - echo "image_tag=$TAG" >> "$GITHUB_OUTPUT" - - name: Validate image accessibility env: - IMAGE_TAG: ${{ steps.resolve_image.outputs.image_tag }} + IMAGE_TAG: ${{ needs.resolve-image.outputs.image-tag }} run: | set -euo pipefail IMAGE="ghcr.io/vig-os/devcontainer:${IMAGE_TAG}" echo "Validating image availability: $IMAGE" - if ! docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + if ! retry --retries 3 --backoff 5 --max-backoff 30 -- docker manifest inspect "$IMAGE" > /dev/null 2>&1; then echo "ERROR: Cannot access image manifest: $IMAGE" echo "Check whether the tag exists and whether this workflow has access to GHCR." exit 1 @@ -237,7 +244,7 @@ jobs: run: | set -euo pipefail - PR_JSON=$(gh pr list \ + PR_JSON=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr list \ --head "release/$VERSION" \ --base main \ --json number,isDraft,reviewDecision,statusCheckRollup \ @@ -279,7 +286,7 @@ jobs: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} RELEASE_DATE: ${{ steps.vars.outputs.release_date }} DRY_RUN: ${{ inputs.dry_run }} - IMAGE_TAG: ${{ steps.resolve_image.outputs.image_tag }} + IMAGE_TAG: ${{ needs.resolve-image.outputs.image-tag }} run: | echo "Validation passed" echo "" @@ -365,7 +372,8 @@ jobs: VERSION: ${{ needs.validate.outputs.version }} run: | set -euo pipefail - gh workflow run sync-issues.yml -f "target-branch=release/$VERSION" + retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh workflow run sync-issues.yml -f "target-branch=release/$VERSION" - name: Wait for sync-issues completion if: ${{ inputs.release_kind == 'final' }} @@ -417,7 +425,7 @@ jobs: VERSION: ${{ needs.validate.outputs.version }} run: | set -euo pipefail - git fetch origin "release/$VERSION" + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" git reset --hard "origin/release/$VERSION" - name: Output finalize SHA @@ -431,7 +439,8 @@ jobs: if [ "$RELEASE_KIND" = "final" ]; then FINALIZE_SHA=$(git rev-parse HEAD) else - FINALIZE_SHA=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/release/$VERSION" --jq '.object.sha') + FINALIZE_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/release/$VERSION" --jq '.object.sha') fi echo "finalize_sha=$FINALIZE_SHA" >> "$GITHUB_OUTPUT" diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index 83dbaace..ea2f263f 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -52,9 +52,29 @@ permissions: contents: read jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + publish: name: Publish Release + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 permissions: contents: write @@ -113,7 +133,14 @@ jobs: run: | set -euo pipefail git tag -a "$PUBLISH_VERSION" -m "Release $PUBLISH_VERSION" - git push origin "$PUBLISH_VERSION" + if ! retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then + if git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then + echo "Tag already present on origin after push attempt: $PUBLISH_VERSION" + else + echo "ERROR: Failed to push tag $PUBLISH_VERSION" + exit 1 + fi + fi - name: Extract release notes from CHANGELOG env: @@ -136,14 +163,18 @@ jobs: GH_TOKEN: ${{ steps.auth.outputs.token }} run: | set -euo pipefail + if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" + exit 1 + fi if [ "$RELEASE_KIND" = "candidate" ]; then - gh release create "$PUBLISH_VERSION" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$PUBLISH_VERSION" \ --title "$PUBLISH_VERSION" \ --notes-file /tmp/release-notes.md \ --verify-tag \ --prerelease else - gh release create "$PUBLISH_VERSION" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$PUBLISH_VERSION" \ --title "$PUBLISH_VERSION" \ --notes-file /tmp/release-notes.md \ --verify-tag diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index 5664100f..2e02dcb3 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -82,6 +82,8 @@ jobs: name: Rollback on Failure needs: [core, extension, publish] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.core.outputs.image_tag }} timeout-minutes: 10 if: ${{ failure() && inputs.dry-run != true }} permissions: @@ -111,10 +113,10 @@ jobs: PRE_SHA: ${{ needs.core.outputs.pre_finalize_sha }} run: | set -euo pipefail - git fetch origin "release/$VERSION" + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" git checkout "release/$VERSION" if git reset --hard "$PRE_SHA" 2>/dev/null; then - if git push --force-with-lease origin "release/$VERSION"; then + if retry --retries 3 --backoff 5 --max-backoff 30 -- git push --force-with-lease origin "release/$VERSION"; then echo "Release branch rolled back" fi fi @@ -125,8 +127,9 @@ jobs: env: PUBLISH_VERSION: ${{ needs.core.outputs.publish_version }} run: | + set -euo pipefail if git ls-remote origin "refs/tags/$PUBLISH_VERSION" | grep -q "$PUBLISH_VERSION"; then - git push origin ":refs/tags/$PUBLISH_VERSION" || true + retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin ":refs/tags/$PUBLISH_VERSION" || true fi - name: Create failure issue @@ -138,7 +141,7 @@ jobs: run: | set -euo pipefail WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - gh issue create \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh issue create \ --title "Release $VERSION failed — automatic rollback" \ --label "bug" \ --body "Release $VERSION failed during the automated release workflow. From b8b53043847562505e97d42a2b51b767c7c477e5 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 14:54:14 +0100 Subject: [PATCH 090/215] fix(ci): harden smoke-test dispatch retries and image resolution Refs: #357 --- .../.github/workflows/repository-dispatch.yml | 111 ++++++++++++++---- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 15fbb0b3..023614c8 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -119,11 +119,42 @@ jobs: echo "source_sha=${SOURCE_SHA}" >> "${GITHUB_OUTPUT}" echo "correlation_id=${CORRELATION_ID}" >> "${GITHUB_OUTPUT}" + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image tag from .vig-os + id: resolve + shell: bash + run: | + set -euo pipefail + if [ ! -f .vig-os ]; then + echo "ERROR: .vig-os is required to resolve DEVCONTAINER_VERSION" + exit 1 + fi + TAG="$(awk -F= '/^DEVCONTAINER_VERSION=/{gsub(/^[ \t"]+|[ \t"]+$/, "", $2); print $2; exit}' .vig-os || true)" + if [ -z "${TAG:-}" ]; then + echo "ERROR: DEVCONTAINER_VERSION not found in .vig-os" + exit 1 + fi + echo "image-tag=$TAG" >> "$GITHUB_OUTPUT" + deploy: name: Deploy tag and open PR to dev runs-on: ubuntu-22.04 + needs: [validate, resolve-image] + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 15 - needs: validate outputs: pr_url: ${{ steps.create_pr.outputs.pr_url }} steps: @@ -155,12 +186,13 @@ jobs: env: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} run: | - gh label create deploy --color 0E8A16 \ + set -euo pipefail + retry --retries 3 --backoff 5 --max-backoff 30 -- gh label create deploy --color 0E8A16 \ --description "Automated smoke-test deploy PRs" \ --force mapfile -t OPEN_DEPLOY_PRS < <( - gh pr list --base dev --state open --label deploy \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr list --base dev --state open --label deploy \ --json number --jq '.[].number' ) @@ -171,7 +203,7 @@ jobs: for pr_number in "${OPEN_DEPLOY_PRS[@]}"; do echo "Closing stale deploy PR #${pr_number}" - gh pr close "${pr_number}" --delete-branch + retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr close "${pr_number}" --delete-branch done - name: Run installer from dispatch tag @@ -179,7 +211,8 @@ jobs: TAG: ${{ needs.validate.outputs.tag }} run: | set -euo pipefail - curl -sSf "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/install.sh" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + curl -sSf "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/install.sh" \ | bash -s -- --version "${TAG}" --smoke-test --force --docker . # Docker-based initialization can leave bind-mounted files owned by root. @@ -211,19 +244,31 @@ jobs: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} TAG: ${{ needs.validate.outputs.tag }} run: | + set -euo pipefail BRANCH_NAME="chore/deploy-${TAG}" echo "branch_name=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" - DEV_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" --jq '.object.sha')" + DEV_SHA="$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" --jq '.object.sha')" - if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH_NAME}" >/dev/null 2>&1; then - gh api -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH_NAME}" \ + if retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH_NAME}" >/dev/null 2>&1; then + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH_NAME}" \ -f sha="${DEV_SHA}" \ -F force=true >/dev/null else - gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ -f ref="refs/heads/${BRANCH_NAME}" \ - -f sha="${DEV_SHA}" >/dev/null + -f sha="${DEV_SHA}" >/dev/null || { + if retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH_NAME}" >/dev/null 2>&1; then + echo "Deploy branch already exists: ${BRANCH_NAME}" + else + exit 1 + fi + } fi - name: Commit and push deploy changes via signed commit-action @@ -244,6 +289,7 @@ jobs: TAG: ${{ needs.validate.outputs.tag }} BRANCH_NAME: ${{ steps.prepare_branch.outputs.branch_name }} run: | + set -euo pipefail PR_BODY="$( printf '%s\n' \ "Automated smoke-test deployment commit created by repository_dispatch." \ @@ -252,14 +298,23 @@ jobs: "- Branch: ${BRANCH_NAME}" \ "- Target: dev" )" - PR_URL="$( - gh pr create \ - --base dev \ - --head "${BRANCH_NAME}" \ - --title "chore: deploy ${TAG}" \ - --body "${PR_BODY}" \ - --label deploy + EXISTING_PR_URL="$( + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr list --base dev --head "${BRANCH_NAME}" --state open --json url --jq '.[0].url // empty' )" + if [ -n "${EXISTING_PR_URL}" ]; then + PR_URL="${EXISTING_PR_URL}" + else + PR_URL="$( + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh pr create \ + --base dev \ + --head "${BRANCH_NAME}" \ + --title "chore: deploy ${TAG}" \ + --body "${PR_BODY}" \ + --label deploy + )" + fi echo "pr_url=${PR_URL}" >> "${GITHUB_OUTPUT}" echo "Created PR: ${PR_URL}" @@ -268,14 +323,18 @@ jobs: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} PR_URL: ${{ steps.create_pr.outputs.pr_url }} run: | - gh pr merge "${PR_URL}" --auto --merge || \ + set -euo pipefail + retry --retries 2 --backoff 5 --max-backoff 20 -- \ + gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge" publish-release: name: Publish smoke-test release artifact runs-on: ubuntu-22.04 + needs: [validate, resolve-image, deploy] + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 - needs: [validate, deploy] permissions: contents: write env: @@ -318,7 +377,8 @@ jobs: RELEASE_LOOKUP_OUTPUT="" set +e - RELEASE_LOOKUP_OUTPUT=$(gh api -i "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>&1) + RELEASE_LOOKUP_OUTPUT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api -i "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>&1) RELEASE_LOOKUP_EXIT=$? set -e @@ -332,26 +392,27 @@ jobs: )" if [ "$RELEASE_LOOKUP_EXIT" -eq 0 ] && [ "$RELEASE_LOOKUP_STATUS" = "200" ]; then - EXISTING_RELEASE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}") + EXISTING_RELEASE=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}") EXISTING_PRERELEASE=$(echo "$EXISTING_RELEASE" | jq -r '.prerelease') if [ "$EXISTING_PRERELEASE" != "$EXPECTED_PRERELEASE" ]; then echo "ERROR: Existing release '$TAG' prerelease=$EXISTING_PRERELEASE (expected $EXPECTED_PRERELEASE)" exit 1 fi - gh release edit "$TAG" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh release edit "$TAG" \ --title "$TAG" \ --notes-file /tmp/smoke-test-release-notes.md echo "Updated existing release '$TAG' with expected prerelease=$EXPECTED_PRERELEASE." else if [ "$RELEASE_LOOKUP_STATUS" = "404" ]; then if [ "$EXPECTED_PRERELEASE" = "true" ]; then - gh release create "$TAG" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$TAG" \ --title "$TAG" \ --notes-file /tmp/smoke-test-release-notes.md \ --prerelease else - gh release create "$TAG" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$TAG" \ --title "$TAG" \ --notes-file /tmp/smoke-test-release-notes.md fi @@ -374,7 +435,7 @@ jobs: name: Dispatch summary runs-on: ubuntu-22.04 timeout-minutes: 5 - needs: [validate, deploy, publish-release] + needs: [validate, resolve-image, deploy, publish-release] if: always() steps: - name: Write source context summary From 1d2dce4532d6f054e77ff5fd96ebfbaba95adf95 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 14:54:25 +0100 Subject: [PATCH 091/215] docs(ci): record retry cli rollout in workflows Refs: #357 --- CHANGELOG.md | 7 +++++++ assets/workspace/.devcontainer/CHANGELOG.md | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c9cca0..e52aee37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test release lookup no longer treats missing tags as existing releases** ([#355](https://github.com/vig-os/devcontainer/issues/355)) - Change `assets/smoke-test/.github/workflows/repository-dispatch.yml` to branch on `gh api` exit status when querying `releases/tags/` - Ensure missing release tags follow the create path instead of failing with `prerelease=null` mismatch +- **Bounded retry added for network-dependent setup and prepare-release calls** ([#357](https://github.com/vig-os/devcontainer/issues/357)) + - Replace shell-based retry helper with pure Python `retry` CLI in `vig-utils` (`packages/vig-utils/src/vig_utils/retry.py`) + - Update this repository CI workflows to call `uv run retry` after `setup-env` dependency sync + - Update downstream workflow templates to call `retry` directly in devcontainer jobs and remove `source` lines + - Ensure downstream containerized jobs resolve image tags from `.vig-os` instead of hardcoded `latest` + - Bundle idempotency guards for branch/PR/tag/release creation paths to keep retried network calls safe on reruns + - Remove synced `retry.sh` artifacts and BATS retry tests in favor of `vig-utils` pytest coverage ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 96c9cca0..e52aee37 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -73,6 +73,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test release lookup no longer treats missing tags as existing releases** ([#355](https://github.com/vig-os/devcontainer/issues/355)) - Change `assets/smoke-test/.github/workflows/repository-dispatch.yml` to branch on `gh api` exit status when querying `releases/tags/` - Ensure missing release tags follow the create path instead of failing with `prerelease=null` mismatch +- **Bounded retry added for network-dependent setup and prepare-release calls** ([#357](https://github.com/vig-os/devcontainer/issues/357)) + - Replace shell-based retry helper with pure Python `retry` CLI in `vig-utils` (`packages/vig-utils/src/vig_utils/retry.py`) + - Update this repository CI workflows to call `uv run retry` after `setup-env` dependency sync + - Update downstream workflow templates to call `retry` directly in devcontainer jobs and remove `source` lines + - Ensure downstream containerized jobs resolve image tags from `.vig-os` instead of hardcoded `latest` + - Bundle idempotency guards for branch/PR/tag/release creation paths to keep retried network calls safe on reruns + - Remove synced `retry.sh` artifacts and BATS retry tests in favor of `vig-utils` pytest coverage ### Security From b5e2c7c087f4501c6383018cb78dc2c191e283c3 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 15:29:04 +0100 Subject: [PATCH 092/215] fix(ci): validate tag target and release fallback retry Refs: #357 --- .github/workflows/release.yml | 16 ++++++++++++++-- .../.github/workflows/release-publish.yml | 14 +++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ad6ecdc..45afe0c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -806,7 +806,19 @@ jobs: if ! uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then if git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then - echo "Tag already present on origin after push attempt: $PUBLISH_VERSION" + LOCAL_TAG_TARGET_SHA=$(git rev-parse "$PUBLISH_VERSION^{}") + REMOTE_TAG_TARGET_SHA=$(git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') + if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then + echo "ERROR: Remote tag exists but target SHA could not be resolved: $PUBLISH_VERSION" + exit 1 + fi + if [ "$REMOTE_TAG_TARGET_SHA" != "$LOCAL_TAG_TARGET_SHA" ]; then + echo "ERROR: Remote tag target SHA mismatch for $PUBLISH_VERSION" + echo "Local tag target: $LOCAL_TAG_TARGET_SHA" + echo "Remote tag target: $REMOTE_TAG_TARGET_SHA" + exit 1 + fi + echo "Tag already present on origin with matching target SHA: $PUBLISH_VERSION" else echo "ERROR: Failed to push tag $PUBLISH_VERSION" exit 1 @@ -1082,7 +1094,7 @@ jobs: --title "$PUBLISH_VERSION" \ --notes-file /tmp/github-release-notes.md \ --verify-tag || { - if gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then echo "GitHub Release already present after create attempt: $PUBLISH_VERSION" else exit 1 diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index ea2f263f..e517ed6c 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -135,7 +135,19 @@ jobs: git tag -a "$PUBLISH_VERSION" -m "Release $PUBLISH_VERSION" if ! retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then if git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then - echo "Tag already present on origin after push attempt: $PUBLISH_VERSION" + LOCAL_TAG_TARGET_SHA=$(git rev-parse "$PUBLISH_VERSION^{}") + REMOTE_TAG_TARGET_SHA=$(git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') + if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then + echo "ERROR: Remote tag exists but target SHA could not be resolved: $PUBLISH_VERSION" + exit 1 + fi + if [ "$REMOTE_TAG_TARGET_SHA" != "$LOCAL_TAG_TARGET_SHA" ]; then + echo "ERROR: Remote tag target SHA mismatch for $PUBLISH_VERSION" + echo "Local tag target: $LOCAL_TAG_TARGET_SHA" + echo "Remote tag target: $REMOTE_TAG_TARGET_SHA" + exit 1 + fi + echo "Tag already present on origin with matching target SHA: $PUBLISH_VERSION" else echo "ERROR: Failed to push tag $PUBLISH_VERSION" exit 1 From 7b6e155ff95c196621bbf9aa787cc7c5c94dd36f Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 15:30:54 +0100 Subject: [PATCH 093/215] fix(ci): harden sync workflow retry setup and portability Refs: #357 --- .github/workflows/sync-issues.yml | 5 +++++ .github/workflows/sync-main-to-dev.yml | 10 ++++++++++ assets/workspace/.github/workflows/sync-issues.yml | 5 +++++ .../workspace/.github/workflows/sync-main-to-dev.yml | 10 ++++++++++ 4 files changed, 30 insertions(+) diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 43c60710..38d49e5c 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -63,6 +63,11 @@ jobs: ref: ${{ github.event.inputs.target-branch || 'dev' }} persist-credentials: false + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Restore sync state (last synced timestamp) id: restore-state uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 7f883201..81ed09f3 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -49,6 +49,11 @@ jobs: with: fetch-depth: 0 + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Check if dev is up to date with main id: check run: | @@ -99,6 +104,11 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Re-check if dev is still behind main id: recheck run: | diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index 43c60710..38d49e5c 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -63,6 +63,11 @@ jobs: ref: ${{ github.event.inputs.target-branch || 'dev' }} persist-credentials: false + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Restore sync state (last synced timestamp) id: restore-state uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 7f883201..81ed09f3 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -49,6 +49,11 @@ jobs: with: fetch-depth: 0 + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Check if dev is up to date with main id: check run: | @@ -99,6 +104,11 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Re-check if dev is still behind main id: recheck run: | From 1f5851d2f9548f3748cea4ea9205bf02a7d5b17d Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 15:36:24 +0100 Subject: [PATCH 094/215] fix(ci): run sync workflows in container image Refs: #357 --- .github/workflows/sync-issues.yml | 31 +++++++--- .github/workflows/sync-main-to-dev.yml | 60 +++++++++++-------- .../.github/workflows/sync-issues.yml | 31 +++++++--- .../.github/workflows/sync-main-to-dev.yml | 60 +++++++++++-------- 4 files changed, 120 insertions(+), 62 deletions(-) diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 38d49e5c..74f63b83 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -36,8 +36,30 @@ on: # yamllint disable-line rule:truthy permissions: {} # restrict default; job declares its own jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + permissions: + contents: read + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + sync: + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 # Prevent concurrent runs to avoid race conditions when committing and cache collisions concurrency: @@ -63,11 +85,6 @@ jobs: ref: ${{ github.event.inputs.target-branch || 'dev' }} persist-credentials: false - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Restore sync state (last synced timestamp) id: restore-state uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 @@ -88,11 +105,11 @@ jobs: sleep 2 # Try to delete cache using GitHub API CACHE_KEY="sync-issues-state-${{ github.repository }}" - CACHE_ID=$(uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + CACHE_ID=$(retry --retries 3 --backoff 3 --max-backoff 15 -- \ gh api repos/${{ github.repository }}/actions/caches --jq ".actions_caches[] | select(.key == \"$CACHE_KEY\") | .id" | head -1) if [ -n "$CACHE_ID" ]; then echo "Found cache ID: $CACHE_ID, attempting deletion..." - uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + retry --retries 3 --backoff 3 --max-backoff 15 -- \ gh api repos/${{ github.repository }}/actions/caches/$CACHE_ID -X DELETE && echo "Cache deleted successfully" || echo "Cache deletion failed (may be locked or already deleted)" else echo "No cache found with key: $CACHE_KEY (this is OK for first run)" diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 81ed09f3..16a55203 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -36,9 +36,29 @@ permissions: contents: read jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + check: name: Check if dev is up to date + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 5 outputs: up_to_date: ${{ steps.check.outputs.up_to_date }} @@ -49,16 +69,11 @@ jobs: with: fetch-depth: 0 - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Check if dev is up to date with main id: check run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev if ! git show-ref --verify --quiet refs/remotes/origin/main; then echo "Error: remote branch 'origin/main' not found after fetch." exit 1 @@ -78,9 +93,11 @@ jobs: sync: name: Merge main into dev via PR - needs: check + needs: [resolve-image, check] if: needs.check.outputs.up_to_date != 'true' runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} @@ -104,16 +121,11 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Re-check if dev is still behind main id: recheck run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev BEHIND=$(git rev-list --count origin/main ^origin/dev) if [ "${BEHIND}" = "0" ]; then echo "up_to_date=true" >> "$GITHUB_OUTPUT" @@ -138,7 +150,7 @@ jobs: GH_TOKEN: ${{ steps.release-app-token.outputs.token }} run: | set -euo pipefail - OPEN=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + OPEN=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr list --base dev --state open --limit 200 \ --json headRefName \ --jq '[.[] | select(.headRefName | startswith("chore/sync-main-to-dev-"))] | length') @@ -153,15 +165,15 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} run: | set -euo pipefail - REFS=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + REFS=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh api --paginate "repos/${{ github.repository }}/git/matching-refs/heads/chore/sync-main-to-dev-" \ --jq '.[].ref' | sed 's|refs/heads/||') || true for branch in ${REFS}; do - HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr list --base dev --head "${branch}" \ --state open --json number --jq 'length') if [ "${HAS_PR}" = "0" ]; then - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${branch}" 2>/dev/null || true echo "Deleted stale sync branch: ${branch}" fi @@ -172,7 +184,7 @@ jobs: id: merge-check run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main if git merge --no-commit --no-ff origin/main 2>/dev/null; then echo "conflict=false" >> "$GITHUB_OUTPUT" else @@ -187,11 +199,11 @@ jobs: run: | set -euo pipefail MAIN_SHA=$(git rev-parse origin/main) - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh api "repos/${{ github.repository }}/git/refs" \ -f ref="refs/heads/${SYNC_BRANCH}" \ -f sha="${MAIN_SHA}" || { - if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + if retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh api "repos/${{ github.repository }}/git/ref/heads/${SYNC_BRANCH}" >/dev/null 2>&1; then echo "Sync branch already exists: ${SYNC_BRANCH}" else @@ -233,12 +245,12 @@ jobs: BODY="Syncs \`dev\` with \`main\` (sync-main-to-dev workflow)." fi - EXISTING_PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + EXISTING_PR_URL=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr list --base dev --head "${SYNC_BRANCH}" --state open --json url --jq '.[0].url // empty') if [ -n "${EXISTING_PR_URL}" ]; then PR_URL="${EXISTING_PR_URL}" else - PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + PR_URL=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr create --base dev --head "${SYNC_BRANCH}" \ --title "${TITLE}" --body "${BODY}") fi @@ -247,7 +259,7 @@ jobs: if [ "${CONFLICT}" = "true" ]; then gh label create "merge-conflict" --color "B60205" --force 2>/dev/null || true - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr edit "${SYNC_BRANCH}" --add-label "merge-conflict" || \ echo "Warning: failed to add merge-conflict label." fi @@ -261,6 +273,6 @@ jobs: PR_URL: ${{ steps.create-pr.outputs.pr_url }} run: | set -euo pipefail - uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index 38d49e5c..74f63b83 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -36,8 +36,30 @@ on: # yamllint disable-line rule:truthy permissions: {} # restrict default; job declares its own jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + permissions: + contents: read + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + sync: + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 # Prevent concurrent runs to avoid race conditions when committing and cache collisions concurrency: @@ -63,11 +85,6 @@ jobs: ref: ${{ github.event.inputs.target-branch || 'dev' }} persist-credentials: false - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Restore sync state (last synced timestamp) id: restore-state uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 @@ -88,11 +105,11 @@ jobs: sleep 2 # Try to delete cache using GitHub API CACHE_KEY="sync-issues-state-${{ github.repository }}" - CACHE_ID=$(uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + CACHE_ID=$(retry --retries 3 --backoff 3 --max-backoff 15 -- \ gh api repos/${{ github.repository }}/actions/caches --jq ".actions_caches[] | select(.key == \"$CACHE_KEY\") | .id" | head -1) if [ -n "$CACHE_ID" ]; then echo "Found cache ID: $CACHE_ID, attempting deletion..." - uv run retry --retries 3 --backoff 3 --max-backoff 15 -- \ + retry --retries 3 --backoff 3 --max-backoff 15 -- \ gh api repos/${{ github.repository }}/actions/caches/$CACHE_ID -X DELETE && echo "Cache deleted successfully" || echo "Cache deletion failed (may be locked or already deleted)" else echo "No cache found with key: $CACHE_KEY (this is OK for first run)" diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 81ed09f3..16a55203 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -36,9 +36,29 @@ permissions: contents: read jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + check: name: Check if dev is up to date + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 5 outputs: up_to_date: ${{ steps.check.outputs.up_to_date }} @@ -49,16 +69,11 @@ jobs: with: fetch-depth: 0 - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Check if dev is up to date with main id: check run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev if ! git show-ref --verify --quiet refs/remotes/origin/main; then echo "Error: remote branch 'origin/main' not found after fetch." exit 1 @@ -78,9 +93,11 @@ jobs: sync: name: Merge main into dev via PR - needs: check + needs: [resolve-image, check] if: needs.check.outputs.up_to_date != 'true' runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} @@ -104,16 +121,11 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Re-check if dev is still behind main id: recheck run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev BEHIND=$(git rev-list --count origin/main ^origin/dev) if [ "${BEHIND}" = "0" ]; then echo "up_to_date=true" >> "$GITHUB_OUTPUT" @@ -138,7 +150,7 @@ jobs: GH_TOKEN: ${{ steps.release-app-token.outputs.token }} run: | set -euo pipefail - OPEN=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + OPEN=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr list --base dev --state open --limit 200 \ --json headRefName \ --jq '[.[] | select(.headRefName | startswith("chore/sync-main-to-dev-"))] | length') @@ -153,15 +165,15 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} run: | set -euo pipefail - REFS=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + REFS=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh api --paginate "repos/${{ github.repository }}/git/matching-refs/heads/chore/sync-main-to-dev-" \ --jq '.[].ref' | sed 's|refs/heads/||') || true for branch in ${REFS}; do - HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + HAS_PR=$(GH_TOKEN="${{ steps.release-app-token.outputs.token }}" retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr list --base dev --head "${branch}" \ --state open --json number --jq 'length') if [ "${HAS_PR}" = "0" ]; then - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${branch}" 2>/dev/null || true echo "Deleted stale sync branch: ${branch}" fi @@ -172,7 +184,7 @@ jobs: id: merge-check run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main if git merge --no-commit --no-ff origin/main 2>/dev/null; then echo "conflict=false" >> "$GITHUB_OUTPUT" else @@ -187,11 +199,11 @@ jobs: run: | set -euo pipefail MAIN_SHA=$(git rev-parse origin/main) - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh api "repos/${{ github.repository }}/git/refs" \ -f ref="refs/heads/${SYNC_BRANCH}" \ -f sha="${MAIN_SHA}" || { - if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + if retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh api "repos/${{ github.repository }}/git/ref/heads/${SYNC_BRANCH}" >/dev/null 2>&1; then echo "Sync branch already exists: ${SYNC_BRANCH}" else @@ -233,12 +245,12 @@ jobs: BODY="Syncs \`dev\` with \`main\` (sync-main-to-dev workflow)." fi - EXISTING_PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + EXISTING_PR_URL=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr list --base dev --head "${SYNC_BRANCH}" --state open --json url --jq '.[0].url // empty') if [ -n "${EXISTING_PR_URL}" ]; then PR_URL="${EXISTING_PR_URL}" else - PR_URL=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + PR_URL=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr create --base dev --head "${SYNC_BRANCH}" \ --title "${TITLE}" --body "${BODY}") fi @@ -247,7 +259,7 @@ jobs: if [ "${CONFLICT}" = "true" ]; then gh label create "merge-conflict" --color "B60205" --force 2>/dev/null || true - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr edit "${SYNC_BRANCH}" --add-label "merge-conflict" || \ echo "Warning: failed to add merge-conflict label." fi @@ -261,6 +273,6 @@ jobs: PR_URL: ${{ steps.create-pr.outputs.pr_url }} run: | set -euo pipefail - uv run retry --retries 2 --backoff 5 --max-backoff 20 -- \ + retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" From 21764df5f822a15115c5be6a43fbee2181ad2d40 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 18:06:23 +0100 Subject: [PATCH 095/215] fix(ci): add resolve-image action and vig-os config Refs: #357 --- .github/actions/resolve-image/action.yml | 74 ++++++++++++++++++++++++ .vig-os | 2 + assets/smoke-test/.vig-os | 2 + 3 files changed, 78 insertions(+) create mode 100644 .github/actions/resolve-image/action.yml create mode 100644 .vig-os create mode 100644 assets/smoke-test/.vig-os diff --git a/.github/actions/resolve-image/action.yml b/.github/actions/resolve-image/action.yml new file mode 100644 index 00000000..8782ed8d --- /dev/null +++ b/.github/actions/resolve-image/action.yml @@ -0,0 +1,74 @@ +name: Resolve devcontainer image +description: Resolve and validate the devcontainer image tag for CI jobs + +inputs: + image-tag: + description: Optional image tag override + required: false + default: '' + +outputs: + image-tag: + description: Resolved image tag + value: ${{ steps.resolve.outputs.tag }} + +runs: + using: composite + steps: + - name: Resolve image tag + id: resolve + shell: bash + env: + INPUT_IMAGE_TAG: ${{ inputs.image-tag }} + run: | + set -euo pipefail + + if [[ -n "$INPUT_IMAGE_TAG" ]]; then + echo "tag=$INPUT_IMAGE_TAG" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ -f .vig-os ]]; then + VERSION="" + while IFS= read -r line || [[ -n "${line:-}" ]]; do + [[ -z "${line//[[:space:]]/}" ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + case "$line" in + DEVCONTAINER_VERSION=*) + VERSION="${line#*=}" + VERSION="${VERSION#"${VERSION%%[![:space:]]*}"}" + VERSION="${VERSION%"${VERSION##*[![:space:]]}"}" + + if [[ "$VERSION" =~ ^\".*\"$ ]]; then + VERSION="${VERSION:1:-1}" + elif [[ "$VERSION" =~ ^\'.*\'$ ]]; then + VERSION="${VERSION:1:-1}" + fi + break + ;; + esac + done < .vig-os + + if [[ -n "$VERSION" ]]; then + echo "tag=$VERSION" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + echo "ERROR: Could not resolve DEVCONTAINER_VERSION from .vig-os and no image-tag override was provided." + exit 1 + + - name: Validate image accessibility + shell: bash + env: + IMAGE_TAG: ${{ steps.resolve.outputs.tag }} + run: | + set -euo pipefail + IMAGE="ghcr.io/vig-os/devcontainer:${IMAGE_TAG}" + echo "Validating image availability: $IMAGE" + if ! docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + echo "ERROR: Cannot access image manifest: $IMAGE" + echo "Check whether the tag exists and whether this workflow has access to GHCR." + exit 1 + fi diff --git a/.vig-os b/.vig-os new file mode 100644 index 00000000..e0bce969 --- /dev/null +++ b/.vig-os @@ -0,0 +1,2 @@ +# devcontainer image configuration +DEVCONTAINER_VERSION=latest diff --git a/assets/smoke-test/.vig-os b/assets/smoke-test/.vig-os new file mode 100644 index 00000000..762e9344 --- /dev/null +++ b/assets/smoke-test/.vig-os @@ -0,0 +1,2 @@ +# smoke-test devcontainer image configuration +DEVCONTAINER_VERSION=latest From e93f9b256e2bb4ee47a850b4422fcd02f91f53a2 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 18:06:43 +0100 Subject: [PATCH 096/215] fix(ci): retry remote tag checks in push fallback Refs: #357 --- .github/workflows/release.yml | 6 +++--- assets/workspace/.github/workflows/release-publish.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45afe0c8..b181ecf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -797,7 +797,7 @@ jobs: run: | set -euo pipefail if [ "$RELEASE_KIND" = "candidate" ]; then - if git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then + if uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then echo "ERROR: Candidate tag '$PUBLISH_VERSION' already exists on origin" echo "Another candidate publish likely completed concurrently; re-run workflow to infer next RC." exit 1 @@ -805,9 +805,9 @@ jobs: fi if ! uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then - if git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then + if uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then LOCAL_TAG_TARGET_SHA=$(git rev-parse "$PUBLISH_VERSION^{}") - REMOTE_TAG_TARGET_SHA=$(git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') + REMOTE_TAG_TARGET_SHA=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then echo "ERROR: Remote tag exists but target SHA could not be resolved: $PUBLISH_VERSION" exit 1 diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index e517ed6c..badc3b14 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -134,9 +134,9 @@ jobs: set -euo pipefail git tag -a "$PUBLISH_VERSION" -m "Release $PUBLISH_VERSION" if ! retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then - if git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then + if retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then LOCAL_TAG_TARGET_SHA=$(git rev-parse "$PUBLISH_VERSION^{}") - REMOTE_TAG_TARGET_SHA=$(git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') + REMOTE_TAG_TARGET_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then echo "ERROR: Remote tag exists but target SHA could not be resolved: $PUBLISH_VERSION" exit 1 From 992ed2fa8739ac9579611424a6b82929e795fce3 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 18:32:27 +0100 Subject: [PATCH 097/215] fix(vigutils): handle command execution errors in retry CLI Catch OS-level subprocess launch errors so `retry` exits with stable shell-compatible codes instead of crashing with a traceback. Refs: #357 --- packages/vig-utils/src/vig_utils/retry.py | 30 +++++++++++++++++------ packages/vig-utils/tests/test_retry.py | 21 ++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/vig-utils/src/vig_utils/retry.py b/packages/vig-utils/src/vig_utils/retry.py index b0370ec9..94657084 100644 --- a/packages/vig-utils/src/vig_utils/retry.py +++ b/packages/vig-utils/src/vig_utils/retry.py @@ -17,6 +17,14 @@ from collections.abc import Sequence +def _execution_error_exit_code(error: OSError) -> int: + if isinstance(error, FileNotFoundError): + return 127 + if isinstance(error, PermissionError): + return 126 + return 1 + + def _parse_positive_int(value: str, option_name: str) -> int: if not value.isdigit() or int(value) <= 0: raise ValueError(f"retry: {option_name} must be a positive integer") @@ -70,15 +78,23 @@ def retry_command( max_backoff: int, ) -> int: """Run command with bounded exponential retry.""" + command_display = " ".join(command) exit_code = 0 for attempt in range(1, retries + 1): - result = subprocess.run( - command, - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, - check=False, - ) + try: + result = subprocess.run( + command, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + check=False, + ) + except OSError as error: + print( + f"retry: failed to execute command: {command_display} ({error})", + file=sys.stderr, + ) + return _execution_error_exit_code(error) if result.returncode == 0: return 0 exit_code = result.returncode diff --git a/packages/vig-utils/tests/test_retry.py b/packages/vig-utils/tests/test_retry.py index 0af2abcc..cdf87037 100644 --- a/packages/vig-utils/tests/test_retry.py +++ b/packages/vig-utils/tests/test_retry.py @@ -132,3 +132,24 @@ def test_retry_cli_module_invocation_succeeds() -> None: cwd=REPO_ROOT, ) assert result.returncode == 0, result.stderr + + +def test_retry_handles_command_execution_oserror( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + sleep_calls: list[int] = [] + + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + raise FileNotFoundError(2, "No such file or directory", "missing-cmd") + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(retry.time, "sleep", sleep_calls.append) + + rc = retry.retry_command( + ["missing-cmd", "--flag"], retries=3, backoff=1, max_backoff=5 + ) + captured = capsys.readouterr() + + assert rc == 127 + assert sleep_calls == [] + assert "retry: failed to execute command: missing-cmd --flag" in captured.err From 13924bbc05cf8bd80fbc08d507f13c84f4eff954 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 18 Mar 2026 23:27:24 +0100 Subject: [PATCH 098/215] test(ci): add smoke-test dispatch orchestration assertions Refs: #358 --- tests/bats/just.bats | 49 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/tests/bats/just.bats b/tests/bats/just.bats index f0d5a5af..26fea7b2 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -70,7 +70,52 @@ setup() { assert_success } -@test "smoke-test dispatch copies changelog from devcontainer to repo root" { - run bash -lc "grep -Fq -- 'cp \".devcontainer/CHANGELOG.md\" \"CHANGELOG.md\"' assets/smoke-test/.github/workflows/repository-dispatch.yml" +@test "smoke-test dispatch computes base version output from tag" { + run bash -lc "grep -Fq -- 'base_version:' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- \"sed 's/-rc[0-9]*\\$//'\" assets/smoke-test/.github/workflows/repository-dispatch.yml" + assert_success +} + +@test "smoke-test dispatch generates minimal changelog for prepare-release freeze" { + run bash -lc "grep -Fq -- 'cat > CHANGELOG.md < Date: Wed, 18 Mar 2026 23:29:36 +0100 Subject: [PATCH 099/215] ci(ci): redesign smoke-test dispatch release orchestration Refs: #358 --- CHANGELOG.md | 4 + .../.github/workflows/repository-dispatch.yml | 629 +++++++++++++----- assets/workspace/.devcontainer/CHANGELOG.md | 4 + tests/bats/just.bats | 6 +- 4 files changed, 462 insertions(+), 181 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e52aee37..74c43ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Move final GitHub Release creation to the end of publish so artifact publication/signing completes before release object creation - Add concurrency control to `assets/smoke-test/.github/workflows/repository-dispatch.yml` to prevent overlapping dispatch races - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released +- **Redesigned smoke-test dispatch release orchestration** ([#358](https://github.com/vig-os/devcontainer/issues/358)) + - Replace premature `publish-release` behavior with full downstream orchestration: deploy-to-dev merge gate, `prepare-release.yml`, release PR readiness/approval, and `release.yml` dispatch polling + - Add release-branch CHANGELOG sync so smoke-test `main` ends with the same `CHANGELOG.md` content as `vig-os/devcontainer` at the dispatched tag + - Add upstream failure issue reporting with job-phase results and cleanup guidance when dispatch orchestration fails ### Fixed diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 023614c8..8b84da99 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -36,6 +36,7 @@ jobs: timeout-minutes: 5 outputs: tag: ${{ steps.extract.outputs.tag }} + base_version: ${{ steps.extract.outputs.base_version }} release_kind: ${{ steps.extract.outputs.release_kind }} source_repo: ${{ steps.extract.outputs.source_repo }} source_workflow: ${{ steps.extract.outputs.source_workflow }} @@ -105,12 +106,14 @@ jobs: exit 1 fi + BASE_VERSION="$(printf '%s' "${TAG}" | sed 's/-rc[0-9]*$//')" EFFECTIVE_SOURCE_RUN_URL="${SOURCE_RUN_URL}" if [ -z "${EFFECTIVE_SOURCE_RUN_URL}" ] && [ -n "${SOURCE_REPO}" ] && [ -n "${SOURCE_RUN_ID}" ]; then EFFECTIVE_SOURCE_RUN_URL="${GITHUB_SERVER_URL}/${SOURCE_REPO}/actions/runs/${SOURCE_RUN_ID}" fi echo "tag=${TAG}" >> "${GITHUB_OUTPUT}" + echo "base_version=${BASE_VERSION}" >> "${GITHUB_OUTPUT}" echo "release_kind=${EFFECTIVE_RELEASE_KIND}" >> "${GITHUB_OUTPUT}" echo "source_repo=${SOURCE_REPO}" >> "${GITHUB_OUTPUT}" echo "source_workflow=${SOURCE_WORKFLOW}" >> "${GITHUB_OUTPUT}" @@ -119,42 +122,11 @@ jobs: echo "source_sha=${SOURCE_SHA}" >> "${GITHUB_OUTPUT}" echo "correlation_id=${CORRELATION_ID}" >> "${GITHUB_OUTPUT}" - resolve-image: - name: Resolve image tag - runs-on: ubuntu-22.04 - timeout-minutes: 2 - outputs: - image-tag: ${{ steps.resolve.outputs.image-tag }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: .vig-os - sparse-checkout-cone-mode: false - - - name: Resolve container image tag from .vig-os - id: resolve - shell: bash - run: | - set -euo pipefail - if [ ! -f .vig-os ]; then - echo "ERROR: .vig-os is required to resolve DEVCONTAINER_VERSION" - exit 1 - fi - TAG="$(awk -F= '/^DEVCONTAINER_VERSION=/{gsub(/^[ \t"]+|[ \t"]+$/, "", $2); print $2; exit}' .vig-os || true)" - if [ -z "${TAG:-}" ]; then - echo "ERROR: DEVCONTAINER_VERSION not found in .vig-os" - exit 1 - fi - echo "image-tag=$TAG" >> "$GITHUB_OUTPUT" - deploy: name: Deploy tag and open PR to dev runs-on: ubuntu-22.04 - needs: [validate, resolve-image] - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 15 + needs: validate outputs: pr_url: ${{ steps.create_pr.outputs.pr_url }} steps: @@ -186,13 +158,12 @@ jobs: env: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} run: | - set -euo pipefail - retry --retries 3 --backoff 5 --max-backoff 30 -- gh label create deploy --color 0E8A16 \ + gh label create deploy --color 0E8A16 \ --description "Automated smoke-test deploy PRs" \ --force mapfile -t OPEN_DEPLOY_PRS < <( - retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr list --base dev --state open --label deploy \ + gh pr list --base dev --state open --label deploy \ --json number --jq '.[].number' ) @@ -203,7 +174,7 @@ jobs: for pr_number in "${OPEN_DEPLOY_PRS[@]}"; do echo "Closing stale deploy PR #${pr_number}" - retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr close "${pr_number}" --delete-branch + gh pr close "${pr_number}" --delete-branch done - name: Run installer from dispatch tag @@ -211,32 +182,22 @@ jobs: TAG: ${{ needs.validate.outputs.tag }} run: | set -euo pipefail - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - curl -sSf "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/install.sh" \ + curl -sSf "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/install.sh" \ | bash -s -- --version "${TAG}" --smoke-test --force --docker . - # Docker-based initialization can leave bind-mounted files owned by root. - # Probe writability of actual copy source/destination paths before repair. - NEEDS_CHOWN=false - if [ ! -r ".devcontainer/CHANGELOG.md" ]; then - NEEDS_CHOWN=true - fi - if [ -e "CHANGELOG.md" ]; then - if [ ! -w "CHANGELOG.md" ]; then - NEEDS_CHOWN=true - fi - elif [ ! -w "." ]; then - NEEDS_CHOWN=true - fi - if [ "${NEEDS_CHOWN}" = "true" ]; then - sudo chown -R "$(id -u):$(id -g)" . - fi - if [ ! -f ".devcontainer/CHANGELOG.md" ]; then echo "ERROR: expected .devcontainer/CHANGELOG.md after install" exit 1 fi - cp ".devcontainer/CHANGELOG.md" "CHANGELOG.md" + cat > "CHANGELOG.md" <> "${GITHUB_OUTPUT}" - DEV_SHA="$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" --jq '.object.sha')" + DEV_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" --jq '.object.sha')" - if retry --retries 2 --backoff 5 --max-backoff 20 -- \ - gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH_NAME}" >/dev/null 2>&1; then - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh api -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH_NAME}" \ + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH_NAME}" >/dev/null 2>&1; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH_NAME}" \ -f sha="${DEV_SHA}" \ -F force=true >/dev/null else - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ -f ref="refs/heads/${BRANCH_NAME}" \ - -f sha="${DEV_SHA}" >/dev/null || { - if retry --retries 2 --backoff 5 --max-backoff 20 -- \ - gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH_NAME}" >/dev/null 2>&1; then - echo "Deploy branch already exists: ${BRANCH_NAME}" - else - exit 1 - fi - } + -f sha="${DEV_SHA}" >/dev/null fi - name: Commit and push deploy changes via signed commit-action @@ -289,7 +238,6 @@ jobs: TAG: ${{ needs.validate.outputs.tag }} BRANCH_NAME: ${{ steps.prepare_branch.outputs.branch_name }} run: | - set -euo pipefail PR_BODY="$( printf '%s\n' \ "Automated smoke-test deployment commit created by repository_dispatch." \ @@ -298,23 +246,14 @@ jobs: "- Branch: ${BRANCH_NAME}" \ "- Target: dev" )" - EXISTING_PR_URL="$( - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh pr list --base dev --head "${BRANCH_NAME}" --state open --json url --jq '.[0].url // empty' + PR_URL="$( + gh pr create \ + --base dev \ + --head "${BRANCH_NAME}" \ + --title "chore: deploy ${TAG}" \ + --body "${PR_BODY}" \ + --label deploy )" - if [ -n "${EXISTING_PR_URL}" ]; then - PR_URL="${EXISTING_PR_URL}" - else - PR_URL="$( - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh pr create \ - --base dev \ - --head "${BRANCH_NAME}" \ - --title "chore: deploy ${TAG}" \ - --body "${PR_BODY}" \ - --label deploy - )" - fi echo "pr_url=${PR_URL}" >> "${GITHUB_OUTPUT}" echo "Created PR: ${PR_URL}" @@ -323,119 +262,345 @@ jobs: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} PR_URL: ${{ steps.create_pr.outputs.pr_url }} run: | - set -euo pipefail - retry --retries 2 --backoff 5 --max-backoff 20 -- \ - gh pr merge "${PR_URL}" --auto --merge || \ + gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge" - publish-release: - name: Publish smoke-test release artifact + wait-deploy-merge: + name: Wait for deploy PR merge runs-on: ubuntu-22.04 - needs: [validate, resolve-image, deploy] - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} - timeout-minutes: 10 - permissions: - contents: write - env: - GH_TOKEN: ${{ github.token }} + timeout-minutes: 35 + needs: deploy steps: - - name: Create release note - id: notes + - name: Poll deploy PR merge status env: - TAG: ${{ needs.validate.outputs.tag }} - SOURCE_REPO: ${{ needs.validate.outputs.source_repo }} - SOURCE_WORKFLOW: ${{ needs.validate.outputs.source_workflow }} - SOURCE_RUN_ID: ${{ needs.validate.outputs.source_run_id }} - SOURCE_RUN_URL: ${{ needs.validate.outputs.source_run_url }} - SOURCE_SHA: ${{ needs.validate.outputs.source_sha }} - CORRELATION_ID: ${{ needs.validate.outputs.correlation_id }} + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ needs.deploy.outputs.pr_url }} run: | set -euo pipefail - cat > /tmp/smoke-test-release-notes.md </dev/null || echo unknown)" + if [ "${STATE}" = "MERGED" ]; then + echo "Deploy PR merged: ${PR_URL}" + exit 0 + fi + if [ "${STATE}" = "CLOSED" ]; then + echo "ERROR: deploy PR closed without merge: ${PR_URL}" + exit 1 + fi + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + echo "Waiting for deploy PR merge... (${ELAPSED}s/${TIMEOUT}s)" + done + + echo "ERROR: timed out waiting for deploy PR merge" + exit 1 + + cleanup-release: + name: Cleanup stale release branch and PR + runs-on: ubuntu-22.04 + timeout-minutes: 5 + needs: [validate, wait-deploy-merge] + steps: + - name: Generate release app token for PR/branch cleanup + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} - - name: Create or validate GitHub release + - name: Close stale release PR and delete stale release branch env: - TAG: ${{ needs.validate.outputs.tag }} - RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BASE_VERSION: ${{ needs.validate.outputs.base_version }} run: | set -euo pipefail - if [ "$RELEASE_KIND" = "candidate" ]; then - EXPECTED_PRERELEASE=true - else - EXPECTED_PRERELEASE=false + RELEASE_BRANCH="release/${BASE_VERSION}" + + if ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" >/dev/null 2>&1; then + echo "No stale release branch found: ${RELEASE_BRANCH}" + exit 0 fi - RELEASE_LOOKUP_OUTPUT="" - set +e - RELEASE_LOOKUP_OUTPUT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh api -i "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>&1) - RELEASE_LOOKUP_EXIT=$? - set -e - - RELEASE_LOOKUP_STATUS="$( - printf '%s\n' "$RELEASE_LOOKUP_OUTPUT" | awk ' - toupper($1) ~ /^HTTP\// { - print $2 - exit - } - ' + mapfile -t RELEASE_PRS < <( + gh pr list --head "${RELEASE_BRANCH}" --state open --json number --jq '.[].number' + ) + for pr_number in "${RELEASE_PRS[@]}"; do + echo "Closing stale release PR #${pr_number}" + gh pr close "${pr_number}" + done + + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${RELEASE_BRANCH}" + echo "Deleted stale release branch: ${RELEASE_BRANCH}" + + trigger-prepare-release: + name: Trigger and wait for prepare-release workflow + runs-on: ubuntu-22.04 + timeout-minutes: 25 + needs: [validate, cleanup-release] + outputs: + before_run_id: ${{ steps.capture_prepare_before.outputs.before_run_id }} + steps: + - name: Generate release app token for workflow dispatch + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Capture latest prepare-release run id + id: capture_prepare_before + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + run: | + set -euo pipefail + BEFORE_RUN_ID="$( + gh run list --workflow prepare-release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 )" + echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" - if [ "$RELEASE_LOOKUP_EXIT" -eq 0 ] && [ "$RELEASE_LOOKUP_STATUS" = "200" ]; then - EXISTING_RELEASE=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}") - EXISTING_PRERELEASE=$(echo "$EXISTING_RELEASE" | jq -r '.prerelease') - if [ "$EXISTING_PRERELEASE" != "$EXPECTED_PRERELEASE" ]; then - echo "ERROR: Existing release '$TAG' prerelease=$EXISTING_PRERELEASE (expected $EXPECTED_PRERELEASE)" - exit 1 - fi + - name: Trigger prepare-release + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BASE_VERSION: ${{ needs.validate.outputs.base_version }} + run: | + set -euo pipefail + gh workflow run prepare-release.yml -f version="${BASE_VERSION}" - retry --retries 3 --backoff 5 --max-backoff 30 -- gh release edit "$TAG" \ - --title "$TAG" \ - --notes-file /tmp/smoke-test-release-notes.md - echo "Updated existing release '$TAG' with expected prerelease=$EXPECTED_PRERELEASE." - else - if [ "$RELEASE_LOOKUP_STATUS" = "404" ]; then - if [ "$EXPECTED_PRERELEASE" = "true" ]; then - retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$TAG" \ - --title "$TAG" \ - --notes-file /tmp/smoke-test-release-notes.md \ - --prerelease - else - retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$TAG" \ - --title "$TAG" \ - --notes-file /tmp/smoke-test-release-notes.md + - name: Wait for prepare-release completion + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BEFORE_RUN_ID: ${{ steps.capture_prepare_before.outputs.before_run_id }} + run: | + set -euo pipefail + TIMEOUT=1200 + INTERVAL=30 + ELAPSED=0 + + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + RUN_ID="$(gh run list --workflow prepare-release.yml --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then + STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" + if [ "${STATUS}" = "completed" ]; then + CONCLUSION="$(gh run view "${RUN_ID}" --json conclusion --jq '.conclusion' 2>/dev/null || echo unknown)" + if [ "${CONCLUSION}" != "success" ]; then + echo "ERROR: prepare-release workflow concluded with '${CONCLUSION}'" + exit 1 + fi + echo "prepare-release workflow completed successfully" + exit 0 fi - echo "Created release '$TAG' with prerelease=$EXPECTED_PRERELEASE." - else - echo "ERROR: Failed to query release '$TAG' (status='${RELEASE_LOOKUP_STATUS:-unknown}', exit='${RELEASE_LOOKUP_EXIT}'); refusing to create release." - echo "$RELEASE_LOOKUP_OUTPUT" - exit 1 fi - fi - - name: Summary + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + echo "Waiting for prepare-release workflow... (${ELAPSED}s/${TIMEOUT}s)" + done + + echo "ERROR: timed out waiting for prepare-release workflow completion" + exit 1 + + ready-release-pr: + name: Sync changelog and prepare release PR + runs-on: ubuntu-22.04 + timeout-minutes: 35 + needs: [validate, trigger-prepare-release] + outputs: + release_pr: ${{ steps.locate_release_pr.outputs.release_pr }} + release_pr_url: ${{ steps.locate_release_pr.outputs.release_pr_url }} + steps: + - name: Generate release app token for PR and contents operations + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Sync upstream CHANGELOG onto release branch env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} TAG: ${{ needs.validate.outputs.tag }} + BASE_VERSION: ${{ needs.validate.outputs.base_version }} + run: | + set -euo pipefail + curl -sSf \ + "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/CHANGELOG.md" \ + -o /tmp/upstream-changelog.md + + FILE_SHA="$(gh api \ + "repos/${GITHUB_REPOSITORY}/contents/CHANGELOG.md?ref=release/${BASE_VERSION}" \ + --jq '.sha')" + CONTENT="$(base64 -w0 < /tmp/upstream-changelog.md)" + + gh api -X PUT "repos/${GITHUB_REPOSITORY}/contents/CHANGELOG.md" \ + -f message="chore: sync CHANGELOG from devcontainer ${TAG}" \ + -f content="${CONTENT}" \ + -f sha="${FILE_SHA}" \ + -f branch="release/${BASE_VERSION}" >/dev/null + echo "Synced upstream CHANGELOG to release/${BASE_VERSION}" + + - name: Locate release PR + id: locate_release_pr + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BASE_VERSION: ${{ needs.validate.outputs.base_version }} + run: | + set -euo pipefail + PR_NUMBER="$(gh pr list --base main --head "release/${BASE_VERSION}" --state open --json number --jq '.[0].number // empty')" + if [ -z "${PR_NUMBER}" ]; then + echo "ERROR: could not find release PR for release/${BASE_VERSION}" + exit 1 + fi + PR_URL="$(gh pr view "${PR_NUMBER}" --json url --jq '.url')" + echo "release_pr=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" + echo "release_pr_url=${PR_URL}" >> "${GITHUB_OUTPUT}" + + - name: Mark release PR ready and approve + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} + run: | + set -euo pipefail + IS_DRAFT="$(gh pr view "${PR_NUMBER}" --json isDraft --jq '.isDraft')" + if [ "${IS_DRAFT}" = "true" ]; then + gh pr ready "${PR_NUMBER}" + fi + gh pr review "${PR_NUMBER}" --approve + + - name: Wait for release PR CI and merge + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} + run: | + set -euo pipefail + TIMEOUT=1800 + INTERVAL=30 + ELAPSED=0 + AUTO_MERGE_SET=false + + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + PR_STATE="$(gh pr view "${PR_NUMBER}" --json state --jq '.state' 2>/dev/null || echo unknown)" + if [ "${PR_STATE}" = "MERGED" ]; then + echo "Release PR merged: #${PR_NUMBER}" + exit 0 + fi + if [ "${PR_STATE}" = "CLOSED" ]; then + echo "ERROR: release PR closed without merge: #${PR_NUMBER}" + exit 1 + fi + + MERGE_STATE="$(gh pr view "${PR_NUMBER}" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null || echo unknown)" + if [ "${MERGE_STATE}" = "CLEAN" ] && [ "${AUTO_MERGE_SET}" = "false" ]; then + gh pr merge "${PR_NUMBER}" --auto --merge + AUTO_MERGE_SET=true + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + echo "Waiting for release PR merge... (${ELAPSED}s/${TIMEOUT}s)" + done + + echo "ERROR: timed out waiting for release PR merge" + exit 1 + + trigger-release: + name: Trigger and wait for release workflow + runs-on: ubuntu-22.04 + timeout-minutes: 35 + needs: [validate, ready-release-pr] + outputs: + before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} + steps: + - name: Generate release app token for release workflow dispatch + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Capture latest release run id + id: capture_release_before + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + run: | + set -euo pipefail + BEFORE_RUN_ID="$( + gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 + )" + echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" + + - name: Trigger release workflow + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BASE_VERSION: ${{ needs.validate.outputs.base_version }} + RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} run: | - echo "✓ Smoke-test release artifact ready" - echo " Tag: $TAG" + set -euo pipefail + gh workflow run release.yml \ + -f version="${BASE_VERSION}" \ + -f release-kind="${RELEASE_KIND}" + + - name: Wait for release workflow completion + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BEFORE_RUN_ID: ${{ steps.capture_release_before.outputs.before_run_id }} + run: | + set -euo pipefail + TIMEOUT=1800 + INTERVAL=30 + ELAPSED=0 + + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + RUN_ID="$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then + STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" + if [ "${STATUS}" = "completed" ]; then + CONCLUSION="$(gh run view "${RUN_ID}" --json conclusion --jq '.conclusion' 2>/dev/null || echo unknown)" + if [ "${CONCLUSION}" != "success" ]; then + echo "ERROR: release workflow concluded with '${CONCLUSION}'" + exit 1 + fi + echo "release workflow completed successfully" + exit 0 + fi + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + echo "Waiting for release workflow... (${ELAPSED}s/${TIMEOUT}s)" + done + + echo "ERROR: timed out waiting for release workflow completion" + exit 1 summary: name: Dispatch summary runs-on: ubuntu-22.04 timeout-minutes: 5 - needs: [validate, resolve-image, deploy, publish-release] + needs: + - validate + - deploy + - wait-deploy-merge + - cleanup-release + - trigger-prepare-release + - ready-release-pr + - trigger-release if: always() steps: - name: Write source context summary @@ -467,8 +632,13 @@ jobs: echo "" echo "Validate: ${{ needs.validate.result }}" echo "Deploy: ${{ needs.deploy.result }}" - echo "Release: ${{ needs.publish-release.result }}" + echo "Wait deploy: ${{ needs.wait-deploy-merge.result }}" + echo "Cleanup: ${{ needs.cleanup-release.result }}" + echo "Prepare: ${{ needs.trigger-prepare-release.result }}" + echo "Release PR: ${{ needs.ready-release-pr.result }}" + echo "Release: ${{ needs.trigger-release.result }}" echo "Deploy PR: ${{ needs.deploy.outputs.pr_url }}" + echo "Release PR: ${{ needs.ready-release-pr.outputs.release_pr_url }}" echo "" FAILED=false @@ -483,8 +653,28 @@ jobs: FAILED=true fi - if [ "${{ needs.publish-release.result }}" != "success" ]; then - echo "ERROR: Release publication job failed" + if [ "${{ needs.wait-deploy-merge.result }}" != "success" ]; then + echo "ERROR: Wait-for-deploy-merge job failed" + FAILED=true + fi + + if [ "${{ needs.cleanup-release.result }}" != "success" ]; then + echo "ERROR: Cleanup release job failed" + FAILED=true + fi + + if [ "${{ needs.trigger-prepare-release.result }}" != "success" ]; then + echo "ERROR: Prepare-release orchestration job failed" + FAILED=true + fi + + if [ "${{ needs.ready-release-pr.result }}" != "success" ]; then + echo "ERROR: Release PR readiness job failed" + FAILED=true + fi + + if [ "${{ needs.trigger-release.result }}" != "success" ]; then + echo "ERROR: Release workflow orchestration job failed" FAILED=true fi @@ -496,3 +686,86 @@ jobs: echo "" echo "Dispatch orchestration passed" + + notify-failure: + name: Notify upstream on smoke-test dispatch failure + runs-on: ubuntu-22.04 + timeout-minutes: 5 + if: failure() + needs: + - validate + - deploy + - wait-deploy-merge + - cleanup-release + - trigger-prepare-release + - ready-release-pr + - trigger-release + - summary + steps: + - name: Generate release app token for upstream issue creation + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: vig-os + repositories: devcontainer + + - name: Create upstream failure issue + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + TAG: ${{ needs.validate.outputs.tag }} + RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} + SOURCE_REPO: ${{ needs.validate.outputs.source_repo }} + SOURCE_WORKFLOW: ${{ needs.validate.outputs.source_workflow }} + SOURCE_RUN_ID: ${{ needs.validate.outputs.source_run_id }} + SOURCE_RUN_URL: ${{ needs.validate.outputs.source_run_url }} + SOURCE_SHA: ${{ needs.validate.outputs.source_sha }} + CORRELATION_ID: ${{ needs.validate.outputs.correlation_id }} + DEPLOY_PR_URL: ${{ needs.deploy.outputs.pr_url }} + RELEASE_PR_URL: ${{ needs.ready-release-pr.outputs.release_pr_url }} + run: | + set -euo pipefail + WORKFLOW_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + ISSUE_BODY="$( + cat <\` branch. + - Re-dispatch using a new RC tag/version once root cause is fixed. + EOF + )" + + gh issue create \ + --repo vig-os/devcontainer \ + --title "Smoke-test dispatch failed for ${TAG:-unknown}" \ + --label bug \ + --body "${ISSUE_BODY}" diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index e52aee37..74c43ea5 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -58,6 +58,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Move final GitHub Release creation to the end of publish so artifact publication/signing completes before release object creation - Add concurrency control to `assets/smoke-test/.github/workflows/repository-dispatch.yml` to prevent overlapping dispatch races - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released +- **Redesigned smoke-test dispatch release orchestration** ([#358](https://github.com/vig-os/devcontainer/issues/358)) + - Replace premature `publish-release` behavior with full downstream orchestration: deploy-to-dev merge gate, `prepare-release.yml`, release PR readiness/approval, and `release.yml` dispatch polling + - Add release-branch CHANGELOG sync so smoke-test `main` ends with the same `CHANGELOG.md` content as `vig-os/devcontainer` at the dispatched tag + - Add upstream failure issue reporting with job-phase results and cleanup guidance when dispatch orchestration fails ### Fixed diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 26fea7b2..5062079f 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -76,12 +76,12 @@ setup() { } @test "smoke-test dispatch generates minimal changelog for prepare-release freeze" { - run bash -lc "grep -Fq -- 'cat > CHANGELOG.md < \"CHANGELOG.md\" < Date: Thu, 19 Mar 2026 07:23:54 +0100 Subject: [PATCH 100/215] fix(ci): harden smoke-test dispatch review feedback Refs: #358 --- .../.github/workflows/repository-dispatch.yml | 44 ++++++++++++++++--- tests/bats/just.bats | 10 +++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 8b84da99..75f05267 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -182,8 +182,21 @@ jobs: TAG: ${{ needs.validate.outputs.tag }} run: | set -euo pipefail - curl -sSf "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/install.sh" \ - | bash -s -- --version "${TAG}" --smoke-test --force --docker . + INSTALL_URL="https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/install.sh" + ATTEMPT=1 + MAX_ATTEMPTS=3 + until [ "${ATTEMPT}" -gt "${MAX_ATTEMPTS}" ]; do + if curl -sSf "${INSTALL_URL}" | bash -s -- --version "${TAG}" --smoke-test --force --docker .; then + break + fi + if [ "${ATTEMPT}" -eq "${MAX_ATTEMPTS}" ]; then + echo "ERROR: failed to download/install after ${MAX_ATTEMPTS} attempts: ${INSTALL_URL}" + exit 1 + fi + echo "Install attempt ${ATTEMPT}/${MAX_ATTEMPTS} failed; retrying in 5s..." + ATTEMPT=$((ATTEMPT + 1)) + sleep 5 + done if [ ! -f ".devcontainer/CHANGELOG.md" ]; then echo "ERROR: expected .devcontainer/CHANGELOG.md after install" @@ -270,6 +283,8 @@ jobs: runs-on: ubuntu-22.04 timeout-minutes: 35 needs: deploy + permissions: + pull-requests: read steps: - name: Poll deploy PR merge status env: @@ -437,9 +452,21 @@ jobs: BASE_VERSION: ${{ needs.validate.outputs.base_version }} run: | set -euo pipefail - curl -sSf \ - "https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/CHANGELOG.md" \ - -o /tmp/upstream-changelog.md + CHANGELOG_URL="https://raw.githubusercontent.com/vig-os/devcontainer/${TAG}/CHANGELOG.md" + ATTEMPT=1 + MAX_ATTEMPTS=3 + until [ "${ATTEMPT}" -gt "${MAX_ATTEMPTS}" ]; do + if curl -sSf "${CHANGELOG_URL}" -o /tmp/upstream-changelog.md; then + break + fi + if [ "${ATTEMPT}" -eq "${MAX_ATTEMPTS}" ]; then + echo "ERROR: failed to download upstream changelog after ${MAX_ATTEMPTS} attempts: ${CHANGELOG_URL}" + exit 1 + fi + echo "Changelog download attempt ${ATTEMPT}/${MAX_ATTEMPTS} failed; retrying in 5s..." + ATTEMPT=$((ATTEMPT + 1)) + sleep 5 + done FILE_SHA="$(gh api \ "repos/${GITHUB_REPOSITORY}/contents/CHANGELOG.md?ref=release/${BASE_VERSION}" \ @@ -505,8 +532,11 @@ jobs: MERGE_STATE="$(gh pr view "${PR_NUMBER}" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null || echo unknown)" if [ "${MERGE_STATE}" = "CLEAN" ] && [ "${AUTO_MERGE_SET}" = "false" ]; then - gh pr merge "${PR_NUMBER}" --auto --merge - AUTO_MERGE_SET=true + if gh pr merge "${PR_NUMBER}" --auto --merge; then + AUTO_MERGE_SET=true + else + echo "Warning: could not enable auto-merge yet; will retry" + fi fi sleep "${INTERVAL}" diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 5062079f..dfadf80d 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -85,6 +85,11 @@ setup() { assert_success } +@test "smoke-test dispatch grants PR read permission for deploy-merge polling" { + run bash -lc 'grep -Fq -- "wait-deploy-merge:" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "pull-requests: read" assets/smoke-test/.github/workflows/repository-dispatch.yml' + assert_success +} + @test "smoke-test dispatch removes publish-release job" { run bash -lc "! grep -Fq -- 'publish-release:' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success @@ -110,6 +115,11 @@ setup() { assert_success } +@test "smoke-test dispatch tolerates transient auto-merge enable failures" { + run bash -lc 'grep -Fq -- "could not enable auto-merge yet; will retry" assets/smoke-test/.github/workflows/repository-dispatch.yml' + assert_success +} + @test "smoke-test dispatch notifies upstream on orchestration failure" { run bash -lc "grep -Fq -- 'notify-failure:' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh issue create \\' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- '--repo vig-os/devcontainer' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success From 125d7779c47d4319e00161d388bee4e641fb3aa7 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 07:51:47 +0100 Subject: [PATCH 101/215] chore(ci): add temporary trivy ignore for CVE-2026-33186 Refs: #361 --- .trivyignore | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.trivyignore b/.trivyignore index 407498ee..1fa18d95 100644 --- a/.trivyignore +++ b/.trivyignore @@ -105,6 +105,17 @@ CVE-2025-61730 Expiration: 2026-04-15 CVE-2025-15558 +# CVE-2026-33186: gRPC-Go authorization bypass via missing leading slash in :path +# Risk Assessment: LOW (devcontainer context) +# - CRITICAL severity in google.golang.org/grpc v1.79.2 embedded in gh v2.88.1 +# - Upstream gh latest release still ships grpc v1.79.2 in go.mod +# - Fix is available in grpc v1.79.3 and requires upstream gh release/rebuild +# - gh is used as a client to GitHub APIs in trusted workflows in this image +# - Temporary exception to keep blocking policy for other HIGH/CRITICAL findings +# - Tracking: https://github.com/vig-os/devcontainer/issues/361 +Expiration: 2026-05-15 +CVE-2026-33186 + # CVE-2026-31812: quinn-proto unauthenticated remote DoS via QUIC transport parameter panic # Risk Assessment: LOW (devcontainer context) # - HIGH severity in quinn-proto v0.11.12 embedded in uv/uvx Rust binaries From aeefa6a678d2028a7a09dbf642600e02a1085a6c Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 08:00:46 +0100 Subject: [PATCH 102/215] fix(ci): repair root-owned files after smoke-test installer Refs: #358 --- .../.github/workflows/repository-dispatch.yml | 23 +++++++++++++++++++ tests/bats/just.bats | 5 ++++ 2 files changed, 28 insertions(+) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 75f05267..5f634727 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -198,10 +198,33 @@ jobs: sleep 5 done + # Docker-based install can leave bind-mounted files owned by root. + # Repair ownership only when required by local writability/readability probes. + NEEDS_CHOWN=false + if [ ! -r ".devcontainer/CHANGELOG.md" ]; then + NEEDS_CHOWN=true + fi + if [ -e "CHANGELOG.md" ] && [ ! -w "CHANGELOG.md" ]; then + NEEDS_CHOWN=true + fi + + if [ "${NEEDS_CHOWN}" = "true" ]; then + OWNER_UID_GID="$(id -u):$(id -g)" + if command -v sudo >/dev/null 2>&1; then + sudo chown -R "${OWNER_UID_GID}" . + else + chown -R "${OWNER_UID_GID}" . + fi + fi + if [ ! -f ".devcontainer/CHANGELOG.md" ]; then echo "ERROR: expected .devcontainer/CHANGELOG.md after install" exit 1 fi + if [ ! -r ".devcontainer/CHANGELOG.md" ]; then + echo "ERROR: .devcontainer/CHANGELOG.md is not readable after ownership repair" + exit 1 + fi cat > "CHANGELOG.md" < Date: Thu, 19 Mar 2026 09:20:10 +0100 Subject: [PATCH 103/215] fix(ci): ensure release retry steps do not require implicit uv Refs: #365 --- .github/workflows/release.yml | 102 +++++++++++++++++++- CHANGELOG.md | 3 + assets/workspace/.devcontainer/CHANGELOG.md | 3 + 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b181ecf5..8c0223f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,15 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install uv + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 + with: + enable-cache: true + version: "0.10.0" + + - name: Sync Python dependencies + run: uv sync --frozen --all-extras + - name: Validate and prepare variables id: vars env: @@ -497,7 +506,6 @@ jobs: persist-credentials: true - name: Set up environment - if: needs.validate.outputs.release_kind == 'final' uses: ./.github/actions/setup-env with: sync-dependencies: 'true' @@ -774,6 +782,15 @@ jobs: token: ${{ steps.app-token.outputs.token }} persist-credentials: true + - name: Install uv + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 + with: + enable-cache: true + version: "0.10.0" + + - name: Sync Python dependencies + run: uv sync --frozen --all-extras + - name: Configure git env: GIT_USER_NAME: ${{ github.event.inputs.git-user-name }} @@ -1166,7 +1183,32 @@ jobs: CORRELATION_ID: ${{ github.repository }}:${{ github.run_id }}:${{ needs.validate.outputs.publish_version }} run: | set -euo pipefail - uv run retry --retries 3 --backoff 10 --max-backoff 10 -- \ + retry() { + local retries=$1 + local backoff=$2 + local max_backoff=$3 + shift 3 + local attempt=1 + while [ "$attempt" -le "$retries" ]; do + if "$@"; then + return 0 + fi + if [ "$attempt" -lt "$retries" ]; then + local wait="$backoff" + if [ "$wait" -gt "$max_backoff" ]; then + wait="$max_backoff" + fi + echo "Retry $attempt/$retries failed, waiting ${wait}s..." + sleep "$wait" + backoff=$((backoff * 2)) + fi + attempt=$((attempt + 1)) + done + echo "ERROR: Command failed after $retries attempts: $*" + return 1 + } + + retry 3 10 10 \ gh api repos/vig-os/devcontainer-smoke-test/dispatches \ -f event_type=smoke-test-trigger \ -f "client_payload[tag]=$RELEASE_TAG" \ @@ -1253,8 +1295,33 @@ jobs: PRE_SHA: ${{ needs.validate.outputs.pre_finalize_sha }} run: | set -euo pipefail + retry() { + local retries=$1 + local backoff=$2 + local max_backoff=$3 + shift 3 + local attempt=1 + while [ "$attempt" -le "$retries" ]; do + if "$@"; then + return 0 + fi + if [ "$attempt" -lt "$retries" ]; then + local wait="$backoff" + if [ "$wait" -gt "$max_backoff" ]; then + wait="$max_backoff" + fi + echo "Retry $attempt/$retries failed, waiting ${wait}s..." + sleep "$wait" + backoff=$((backoff * 2)) + fi + attempt=$((attempt + 1)) + done + echo "ERROR: Command failed after $retries attempts: $*" + return 1 + } + echo "Rolling back release branch to pre-finalization state..." - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh api "repos/${{ github.repository }}/git/refs/heads/release/$VERSION" \ + retry 3 5 30 gh api "repos/${{ github.repository }}/git/refs/heads/release/$VERSION" \ -X PATCH \ -f sha="$PRE_SHA" \ -F force=true @@ -1268,10 +1335,35 @@ jobs: PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} run: | set -euo pipefail + retry() { + local retries=$1 + local backoff=$2 + local max_backoff=$3 + shift 3 + local attempt=1 + while [ "$attempt" -le "$retries" ]; do + if "$@"; then + return 0 + fi + if [ "$attempt" -lt "$retries" ]; then + local wait="$backoff" + if [ "$wait" -gt "$max_backoff" ]; then + wait="$max_backoff" + fi + echo "Retry $attempt/$retries failed, waiting ${wait}s..." + sleep "$wait" + backoff=$((backoff * 2)) + fi + attempt=$((attempt + 1)) + done + echo "ERROR: Command failed after $retries attempts: $*" + return 1 + } + TAG="$PUBLISH_VERSION" - if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then + if retry 2 5 20 gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then echo "Deleting remote tag: $TAG" - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" -X DELETE + retry 3 5 30 gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" -X DELETE echo "✓ Tag deleted" else echo "Tag does not exist on remote (not created)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c43ea5..5c4d8f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure downstream containerized jobs resolve image tags from `.vig-os` instead of hardcoded `latest` - Bundle idempotency guards for branch/PR/tag/release creation paths to keep retried network calls safe on reruns - Remove synced `retry.sh` artifacts and BATS retry tests in favor of `vig-utils` pytest coverage +- **Release workflow no longer fails when retry tooling is unavailable** ([#365](https://github.com/vig-os/devcontainer/issues/365)) + - Provision `uv` and synced dependencies before `uv run retry` calls in validate, finalize, and publish release jobs + - Replace `uv run retry` in no-checkout smoke-test and rollback jobs with inline shell retry helpers to keep rollback paths reliable ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 74c43ea5..5c4d8f49 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -84,6 +84,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure downstream containerized jobs resolve image tags from `.vig-os` instead of hardcoded `latest` - Bundle idempotency guards for branch/PR/tag/release creation paths to keep retried network calls safe on reruns - Remove synced `retry.sh` artifacts and BATS retry tests in favor of `vig-utils` pytest coverage +- **Release workflow no longer fails when retry tooling is unavailable** ([#365](https://github.com/vig-os/devcontainer/issues/365)) + - Provision `uv` and synced dependencies before `uv run retry` calls in validate, finalize, and publish release jobs + - Replace `uv run retry` in no-checkout smoke-test and rollback jobs with inline shell retry helpers to keep rollback paths reliable ### Security From 3aa4f4ca6fe8e8f16060ea5cbbddf6f8543453f5 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 09:51:03 +0100 Subject: [PATCH 104/215] fix(ci): centralize release retry helper via setup-env Refs: #365 --- .github/actions/setup-env/action.yml | 122 +++++++++++++-- .github/workflows/release.yml | 156 ++++++-------------- CHANGELOG.md | 5 +- assets/workspace/.devcontainer/CHANGELOG.md | 5 +- 4 files changed, 163 insertions(+), 125 deletions(-) diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index 44d604c4..5349ab84 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -7,11 +7,15 @@ # - hadolint (for Containerfile linting in pre-commit) # - BATS + helper libraries (for shell script testing) # -# IMPORTANT: The caller must checkout the repository before using this action. -# This action does NOT checkout code, allowing callers to control ref, token, -# persist-credentials, and other checkout options. +# IMPORTANT: +# - This action does NOT checkout code, allowing callers to control ref, token, +# persist-credentials, and other checkout options. +# - Checkout is only required for operations that read repository files +# (for example, sync-dependencies or devcontainer CLI version lookup). # # Inputs: +# install-python: Install Python (default: true) +# python-version: Python version fallback when pyproject.toml is unavailable (default: '3.12') # sync-dependencies: Run uv sync to install project deps (default: false) # install-podman: Install podman (default: false) # install-node: Install Node.js (default: false) @@ -25,10 +29,15 @@ # uv-version: The version of uv that was installed # # Usage: -# # Minimal (Python + uv only) +# # Default (Python + uv only) # - uses: actions/checkout@v4 # - uses: ./.github/actions/setup-env # +# # uv only (skip Python setup) +# - uses: ./.github/actions/setup-env +# with: +# install-python: 'false' +# # # With project dependencies # - uses: actions/checkout@v4 # - uses: ./.github/actions/setup-env @@ -47,6 +56,14 @@ name: 'Setup Environment' description: 'Set up CI environment with Python, uv, and optional tools (podman, Node.js, devcontainer CLI, hadolint, BATS)' inputs: + install-python: + description: 'Install Python runtime' + required: false + default: 'true' + python-version: + description: 'Python version fallback when pyproject.toml is unavailable' + required: false + default: '3.12' sync-dependencies: description: 'Run uv sync to install project dependencies' required: false @@ -93,11 +110,18 @@ runs: using: composite steps: # ── Python ─────────────────────────────────────────────────────────── - - name: "Set up Python" + - name: "Set up Python from pyproject" + if: inputs.install-python == 'true' && hashFiles('pyproject.toml') != '' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version-file: "pyproject.toml" + - name: "Set up Python fallback" + if: inputs.install-python == 'true' && hashFiles('pyproject.toml') == '' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ inputs.python-version }} + # ── uv ───────────────────────────────────────────────────────────── - name: Install uv id: setup-uv @@ -107,6 +131,86 @@ runs: # Install a specific version of uv. version: "0.10.0" + # ── retry() shell helper ─────────────────────────────────────────── + - name: Export retry helper function + shell: bash + run: | + set -euo pipefail + RETRY_HELPER="$RUNNER_TEMP/setup-env-retry.sh" + PREV_BASH_ENV="${BASH_ENV:-}" + + cat > "$RETRY_HELPER" <<'EOF' + retry() { + local retries=3 + local backoff=1 + local max_backoff=60 + local rc=1 + + while [ "$#" -gt 0 ]; do + case "$1" in + --retries) + retries="$2" + shift 2 + ;; + --backoff) + backoff="$2" + shift 2 + ;; + --max-backoff) + max_backoff="$2" + shift 2 + ;; + --) + shift + break + ;; + *) + echo "ERROR: Unknown retry option '$1'" + return 2 + ;; + esac + done + + if [ "$#" -eq 0 ]; then + echo "ERROR: retry requires a command after '--'" + return 2 + fi + + local attempt=1 + local current_backoff="$backoff" + while [ "$attempt" -le "$retries" ]; do + if "$@"; then + return 0 + fi + rc=$? + if [ "$attempt" -lt "$retries" ]; then + local wait="$current_backoff" + if [ "$wait" -gt "$max_backoff" ]; then + wait="$max_backoff" + fi + echo "Retry $attempt/$retries failed (exit $rc), waiting ${wait}s..." + sleep "$wait" + current_backoff=$((current_backoff * 2)) + fi + attempt=$((attempt + 1)) + done + + echo "ERROR: Command failed after $retries attempts: $*" + return "$rc" + } + export -f retry + EOF + + if [ -n "$PREV_BASH_ENV" ] && [ -f "$PREV_BASH_ENV" ]; then + { + echo "source \"$PREV_BASH_ENV\"" + cat "$RETRY_HELPER" + } > "${RETRY_HELPER}.merged" + mv "${RETRY_HELPER}.merged" "$RETRY_HELPER" + fi + + echo "BASH_ENV=$RETRY_HELPER" >> "$GITHUB_ENV" + # ── Python dependencies ─────────────────────────────────────────────── - name: Sync Python dependencies if: inputs.sync-dependencies == 'true' @@ -162,9 +266,9 @@ runs: BIN_FILE="hadolint-${ARCH}" SHA_FILE="${BIN_FILE}.sha256" - uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + retry --retries 3 --backoff 5 --max-backoff 60 -- \ curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" - uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + retry --retries 3 --backoff 5 --max-backoff 60 -- \ curl -fsSL "${BASE_URL}/${SHA_FILE}" -o "${SHA_FILE}" EXPECTED_SHA="$(awk '{print $1}' "${SHA_FILE}")" @@ -191,7 +295,7 @@ runs: ;; esac - TAPLO_VERSION="$(uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + TAPLO_VERSION="$(retry --retries 3 --backoff 5 --max-backoff 60 -- \ curl -fsSL https://api.github.com/repos/tamasfe/taplo/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')" if [ -z "${TAPLO_VERSION:-}" ]; then echo "ERROR: Failed to resolve Taplo version from GitHub releases API" @@ -200,7 +304,7 @@ runs: BASE_URL="https://github.com/tamasfe/taplo/releases/download/${TAPLO_VERSION}" BIN_FILE="taplo-linux-${ARCH}.gz" - uv run retry --retries 3 --backoff 5 --max-backoff 60 -- \ + retry --retries 3 --backoff 5 --max-backoff 60 -- \ curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" gunzip "${BIN_FILE}" sudo install -m 0755 "taplo-linux-${ARCH}" /usr/local/bin/taplo diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c0223f4..f5ab0402 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,14 +80,11 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install uv - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 + - name: Set up environment + uses: ./.github/actions/setup-env with: - enable-cache: true - version: "0.10.0" - - - name: Sync Python dependencies - run: uv sync --frozen --all-extras + install-python: 'false' + install-just: 'false' - name: Validate and prepare variables id: vars @@ -127,7 +124,7 @@ jobs: VERSION: ${{ steps.vars.outputs.version }} run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" || { + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" || { echo "ERROR: Release branch not found: release/$VERSION" echo "Did you run: just prepare-release $VERSION" exit 1 @@ -343,7 +340,7 @@ jobs: RELEASE_BRANCH="release/$VERSION" # Find PR from release branch to main - PR_JSON=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr list \ + PR_JSON=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh pr list \ --head "$RELEASE_BRANCH" \ --base main \ --json number,isDraft,reviewDecision,statusCheckRollup \ @@ -509,6 +506,7 @@ jobs: uses: ./.github/actions/setup-env with: sync-dependencies: 'true' + install-just: 'false' - name: Set release date in CHANGELOG if: needs.validate.outputs.release_kind == 'final' @@ -584,7 +582,7 @@ jobs: run: | set -euo pipefail echo "Triggering sync-issues workflow..." - uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh workflow run sync-issues.yml \ + retry --retries 2 --backoff 5 --max-backoff 20 -- gh workflow run sync-issues.yml \ -f "target-branch=release/$VERSION" echo "✓ sync-issues workflow triggered" @@ -639,7 +637,7 @@ jobs: VERSION: ${{ needs.validate.outputs.version }} run: | set -euo pipefail - uv run retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin "release/$VERSION" git reset --hard "origin/release/$VERSION" echo "✓ Synced with remote release branch" @@ -669,7 +667,7 @@ jobs: $CHANGELOG_CONTENT EOF - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh pr edit "$PR_NUMBER" --body-file /tmp/release-pr-body.md echo "✓ Refreshed PR #$PR_NUMBER body from finalized CHANGELOG.md" @@ -684,7 +682,7 @@ jobs: if [ "$RELEASE_KIND" = "final" ]; then FINALIZE_SHA=$(git rev-parse HEAD) else - FINALIZE_SHA=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + FINALIZE_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/release/$VERSION" --jq '.object.sha') fi echo "finalize_sha=$FINALIZE_SHA" >> $GITHUB_OUTPUT @@ -782,14 +780,11 @@ jobs: token: ${{ steps.app-token.outputs.token }} persist-credentials: true - - name: Install uv - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 + - name: Set up environment + uses: ./.github/actions/setup-env with: - enable-cache: true - version: "0.10.0" - - - name: Sync Python dependencies - run: uv sync --frozen --all-extras + install-python: 'false' + install-just: 'false' - name: Configure git env: @@ -814,17 +809,17 @@ jobs: run: | set -euo pipefail if [ "$RELEASE_KIND" = "candidate" ]; then - if uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then + if retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then echo "ERROR: Candidate tag '$PUBLISH_VERSION' already exists on origin" echo "Another candidate publish likely completed concurrently; re-run workflow to infer next RC." exit 1 fi fi - if ! uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then - if uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then + if ! retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin "$PUBLISH_VERSION"; then + if retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then LOCAL_TAG_TARGET_SHA=$(git rev-parse "$PUBLISH_VERSION^{}") - REMOTE_TAG_TARGET_SHA=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') + REMOTE_TAG_TARGET_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then echo "ERROR: Remote tag exists but target SHA could not be resolved: $PUBLISH_VERSION" exit 1 @@ -904,7 +899,7 @@ jobs: IMAGE_TAG="$REPO:$PUBLISH_VERSION-$arch" docker tag "$SOURCE_IMAGE_TAG" "$IMAGE_TAG" echo "Pushing $arch image: $IMAGE_TAG" - uv run retry --retries 3 --backoff 3 --max-backoff 3 -- docker push "$IMAGE_TAG" + retry --retries 3 --backoff 3 --max-backoff 3 -- docker push "$IMAGE_TAG" echo "✓ Pushed: $IMAGE_TAG" done @@ -927,7 +922,7 @@ jobs: done echo "Creating version manifest: $REPO:$PUBLISH_VERSION" - uv run retry --retries 3 --backoff 3 --max-backoff 3 -- \ + retry --retries 3 --backoff 3 --max-backoff 3 -- \ docker buildx imagetools create \ --tag "$REPO:$PUBLISH_VERSION" \ "${ARCH_IMAGES[@]}" @@ -936,7 +931,7 @@ jobs: # Update latest manifest only for final releases building both architectures. if [ "$RELEASE_KIND" = "final" ] && [ ${#ARCH_ARRAY[@]} -eq 2 ]; then echo "Creating/updating latest manifest: $REPO:latest" - uv run retry --retries 3 --backoff 3 --max-backoff 3 -- \ + retry --retries 3 --backoff 3 --max-backoff 3 -- \ docker buildx imagetools create \ --tag "$REPO:latest" \ "${ARCH_IMAGES[@]}" @@ -1030,12 +1025,12 @@ jobs: REPO="ghcr.io/vig-os/devcontainer" # Get the digest for the multi-arch manifest - DIGEST=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + DIGEST=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ docker buildx imagetools inspect "$REPO:$PUBLISH_VERSION" --format '{{json .Manifest.Digest}}' | tr -d '"') echo "Signing image: $REPO@$DIGEST" # Keyless signing using GitHub Actions OIDC identity - uv run retry --retries 3 --backoff 15 --max-backoff 15 -- \ + retry --retries 3 --backoff 15 --max-backoff 15 -- \ cosign sign --yes "$REPO@$DIGEST" echo "✓ Image signed with cosign (keyless)" @@ -1046,7 +1041,7 @@ jobs: run: | set -euo pipefail REPO="ghcr.io/vig-os/devcontainer" - DIGEST=$(uv run retry --retries 3 --backoff 5 --max-backoff 30 -- \ + DIGEST=$(retry --retries 3 --backoff 5 --max-backoff 30 -- \ docker buildx imagetools inspect "$REPO:$PUBLISH_VERSION" --format '{{json .Manifest.Digest}}' | tr -d '"') echo "digest=$DIGEST" >> $GITHUB_OUTPUT echo "Image digest: $DIGEST" @@ -1102,16 +1097,16 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" exit 1 fi - uv run retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$PUBLISH_VERSION" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$PUBLISH_VERSION" \ --title "$PUBLISH_VERSION" \ --notes-file /tmp/github-release-notes.md \ --verify-tag || { - if uv run retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then echo "GitHub Release already present after create attempt: $PUBLISH_VERSION" else exit 1 @@ -1170,6 +1165,12 @@ jobs: owner: vig-os repositories: devcontainer-smoke-test + - name: Set up environment + uses: ./.github/actions/setup-env + with: + install-python: 'false' + install-just: 'false' + - name: Trigger smoke-test repository dispatch env: GH_TOKEN: ${{ steps.smoke-app-token.outputs.token }} @@ -1183,32 +1184,7 @@ jobs: CORRELATION_ID: ${{ github.repository }}:${{ github.run_id }}:${{ needs.validate.outputs.publish_version }} run: | set -euo pipefail - retry() { - local retries=$1 - local backoff=$2 - local max_backoff=$3 - shift 3 - local attempt=1 - while [ "$attempt" -le "$retries" ]; do - if "$@"; then - return 0 - fi - if [ "$attempt" -lt "$retries" ]; then - local wait="$backoff" - if [ "$wait" -gt "$max_backoff" ]; then - wait="$max_backoff" - fi - echo "Retry $attempt/$retries failed, waiting ${wait}s..." - sleep "$wait" - backoff=$((backoff * 2)) - fi - attempt=$((attempt + 1)) - done - echo "ERROR: Command failed after $retries attempts: $*" - return 1 - } - - retry 3 10 10 \ + retry --retries 3 --backoff 10 --max-backoff 10 -- \ gh api repos/vig-os/devcontainer-smoke-test/dispatches \ -f event_type=smoke-test-trigger \ -f "client_payload[tag]=$RELEASE_TAG" \ @@ -1286,6 +1262,12 @@ jobs: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Set up environment + uses: ./.github/actions/setup-env + with: + install-python: 'false' + install-just: 'false' + - name: Rollback release branch id: rollback-branch continue-on-error: true @@ -1295,33 +1277,8 @@ jobs: PRE_SHA: ${{ needs.validate.outputs.pre_finalize_sha }} run: | set -euo pipefail - retry() { - local retries=$1 - local backoff=$2 - local max_backoff=$3 - shift 3 - local attempt=1 - while [ "$attempt" -le "$retries" ]; do - if "$@"; then - return 0 - fi - if [ "$attempt" -lt "$retries" ]; then - local wait="$backoff" - if [ "$wait" -gt "$max_backoff" ]; then - wait="$max_backoff" - fi - echo "Retry $attempt/$retries failed, waiting ${wait}s..." - sleep "$wait" - backoff=$((backoff * 2)) - fi - attempt=$((attempt + 1)) - done - echo "ERROR: Command failed after $retries attempts: $*" - return 1 - } - echo "Rolling back release branch to pre-finalization state..." - retry 3 5 30 gh api "repos/${{ github.repository }}/git/refs/heads/release/$VERSION" \ + retry --retries 3 --backoff 5 --max-backoff 30 -- gh api "repos/${{ github.repository }}/git/refs/heads/release/$VERSION" \ -X PATCH \ -f sha="$PRE_SHA" \ -F force=true @@ -1335,35 +1292,10 @@ jobs: PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} run: | set -euo pipefail - retry() { - local retries=$1 - local backoff=$2 - local max_backoff=$3 - shift 3 - local attempt=1 - while [ "$attempt" -le "$retries" ]; do - if "$@"; then - return 0 - fi - if [ "$attempt" -lt "$retries" ]; then - local wait="$backoff" - if [ "$wait" -gt "$max_backoff" ]; then - wait="$max_backoff" - fi - echo "Retry $attempt/$retries failed, waiting ${wait}s..." - sleep "$wait" - backoff=$((backoff * 2)) - fi - attempt=$((attempt + 1)) - done - echo "ERROR: Command failed after $retries attempts: $*" - return 1 - } - TAG="$PUBLISH_VERSION" - if retry 2 5 20 gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then + if retry --retries 2 --backoff 5 --max-backoff 20 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then echo "Deleting remote tag: $TAG" - retry 3 5 30 gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" -X DELETE + retry --retries 3 --backoff 5 --max-backoff 30 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" -X DELETE echo "✓ Tag deleted" else echo "Tag does not exist on remote (not created)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4d8f49..e61c4557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,8 +85,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bundle idempotency guards for branch/PR/tag/release creation paths to keep retried network calls safe on reruns - Remove synced `retry.sh` artifacts and BATS retry tests in favor of `vig-utils` pytest coverage - **Release workflow no longer fails when retry tooling is unavailable** ([#365](https://github.com/vig-os/devcontainer/issues/365)) - - Provision `uv` and synced dependencies before `uv run retry` calls in validate, finalize, and publish release jobs - - Replace `uv run retry` in no-checkout smoke-test and rollback jobs with inline shell retry helpers to keep rollback paths reliable + - Extend `.github/actions/setup-env/action.yml` with a reusable `retry` shell function exported via `BASH_ENV` as the retry single source of truth + - Add `setup-env` input support for uv-only usage by allowing Python setup to be disabled when jobs only need retry tooling + - Switch release workflow retry calls from `uv run retry` to shared `retry` and remove duplicated inline retry implementations ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 5c4d8f49..e61c4557 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -85,8 +85,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bundle idempotency guards for branch/PR/tag/release creation paths to keep retried network calls safe on reruns - Remove synced `retry.sh` artifacts and BATS retry tests in favor of `vig-utils` pytest coverage - **Release workflow no longer fails when retry tooling is unavailable** ([#365](https://github.com/vig-os/devcontainer/issues/365)) - - Provision `uv` and synced dependencies before `uv run retry` calls in validate, finalize, and publish release jobs - - Replace `uv run retry` in no-checkout smoke-test and rollback jobs with inline shell retry helpers to keep rollback paths reliable + - Extend `.github/actions/setup-env/action.yml` with a reusable `retry` shell function exported via `BASH_ENV` as the retry single source of truth + - Add `setup-env` input support for uv-only usage by allowing Python setup to be disabled when jobs only need retry tooling + - Switch release workflow retry calls from `uv run retry` to shared `retry` and remove duplicated inline retry implementations ### Security From e9ddd3e67de5344454746a82defcc520fb1a0fb1 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 10:16:58 +0100 Subject: [PATCH 105/215] fix(ci): decouple sync workflows from devcontainer image Run upstream and workspace sync workflows directly on GitHub-hosted runners by removing resolve-image container coupling and standardizing setup-env bootstrapping for retry-dependent steps. Refs: #367 --- .github/workflows/sync-issues.yml | 27 +++------------ .github/workflows/sync-main-to-dev.yml | 34 ++++++------------- .../.github/workflows/sync-issues.yml | 27 +++------------ .../.github/workflows/sync-main-to-dev.yml | 34 ++++++------------- 4 files changed, 32 insertions(+), 90 deletions(-) diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 74f63b83..b36b7fb8 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -36,30 +36,8 @@ on: # yamllint disable-line rule:truthy permissions: {} # restrict default; job declares its own jobs: - resolve-image: - name: Resolve image tag - runs-on: ubuntu-22.04 - timeout-minutes: 2 - permissions: - contents: read - outputs: - image-tag: ${{ steps.resolve.outputs.image-tag }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: .vig-os - sparse-checkout-cone-mode: false - - - name: Resolve container image - id: resolve - uses: ./.github/actions/resolve-image - sync: - needs: [resolve-image] runs-on: ubuntu-22.04 - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 # Prevent concurrent runs to avoid race conditions when committing and cache collisions concurrency: @@ -85,6 +63,11 @@ jobs: ref: ${{ github.event.inputs.target-branch || 'dev' }} persist-credentials: false + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Restore sync state (last synced timestamp) id: restore-state uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 16a55203..0e598dbc 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -36,29 +36,9 @@ permissions: contents: read jobs: - resolve-image: - name: Resolve image tag - runs-on: ubuntu-22.04 - timeout-minutes: 2 - outputs: - image-tag: ${{ steps.resolve.outputs.image-tag }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: .vig-os - sparse-checkout-cone-mode: false - - - name: Resolve container image - id: resolve - uses: ./.github/actions/resolve-image - check: name: Check if dev is up to date - needs: [resolve-image] runs-on: ubuntu-22.04 - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 5 outputs: up_to_date: ${{ steps.check.outputs.up_to_date }} @@ -69,6 +49,11 @@ jobs: with: fetch-depth: 0 + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Check if dev is up to date with main id: check run: | @@ -93,11 +78,9 @@ jobs: sync: name: Merge main into dev via PR - needs: [resolve-image, check] + needs: [check] if: needs.check.outputs.up_to_date != 'true' runs-on: ubuntu-22.04 - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} @@ -121,6 +104,11 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Re-check if dev is still behind main id: recheck run: | diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index 74f63b83..b36b7fb8 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -36,30 +36,8 @@ on: # yamllint disable-line rule:truthy permissions: {} # restrict default; job declares its own jobs: - resolve-image: - name: Resolve image tag - runs-on: ubuntu-22.04 - timeout-minutes: 2 - permissions: - contents: read - outputs: - image-tag: ${{ steps.resolve.outputs.image-tag }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: .vig-os - sparse-checkout-cone-mode: false - - - name: Resolve container image - id: resolve - uses: ./.github/actions/resolve-image - sync: - needs: [resolve-image] runs-on: ubuntu-22.04 - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 # Prevent concurrent runs to avoid race conditions when committing and cache collisions concurrency: @@ -85,6 +63,11 @@ jobs: ref: ${{ github.event.inputs.target-branch || 'dev' }} persist-credentials: false + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Restore sync state (last synced timestamp) id: restore-state uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 16a55203..0e598dbc 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -36,29 +36,9 @@ permissions: contents: read jobs: - resolve-image: - name: Resolve image tag - runs-on: ubuntu-22.04 - timeout-minutes: 2 - outputs: - image-tag: ${{ steps.resolve.outputs.image-tag }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: .vig-os - sparse-checkout-cone-mode: false - - - name: Resolve container image - id: resolve - uses: ./.github/actions/resolve-image - check: name: Check if dev is up to date - needs: [resolve-image] runs-on: ubuntu-22.04 - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 5 outputs: up_to_date: ${{ steps.check.outputs.up_to_date }} @@ -69,6 +49,11 @@ jobs: with: fetch-depth: 0 + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Check if dev is up to date with main id: check run: | @@ -93,11 +78,9 @@ jobs: sync: name: Merge main into dev via PR - needs: [resolve-image, check] + needs: [check] if: needs.check.outputs.up_to_date != 'true' runs-on: ubuntu-22.04 - container: - image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} @@ -121,6 +104,11 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} + - name: Set up environment + uses: ./.github/actions/setup-env + with: + sync-dependencies: 'true' + - name: Re-check if dev is still behind main id: recheck run: | From af86e08ef3dc721be5870e61ce159b6ecaece3e1 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 10:17:28 +0100 Subject: [PATCH 106/215] fix(ci): remove upstream .vig-os files and document sync decoupling Drop upstream-only .vig-os files from repository and smoke-test roots, and record the CI decoupling fix in changelog entries propagated to workspace payload artifacts. Refs: #367 --- .vig-os | 2 -- CHANGELOG.md | 3 +++ assets/smoke-test/.vig-os | 2 -- assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 4 files changed, 6 insertions(+), 4 deletions(-) delete mode 100644 .vig-os delete mode 100644 assets/smoke-test/.vig-os diff --git a/.vig-os b/.vig-os deleted file mode 100644 index e0bce969..00000000 --- a/.vig-os +++ /dev/null @@ -1,2 +0,0 @@ -# devcontainer image configuration -DEVCONTAINER_VERSION=latest diff --git a/CHANGELOG.md b/CHANGELOG.md index e61c4557..e76ec4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extend `.github/actions/setup-env/action.yml` with a reusable `retry` shell function exported via `BASH_ENV` as the retry single source of truth - Add `setup-env` input support for uv-only usage by allowing Python setup to be disabled when jobs only need retry tooling - Switch release workflow retry calls from `uv run retry` to shared `retry` and remove duplicated inline retry implementations +- **Upstream sync workflows no longer depend on pre-published GHCR image tags** ([#367](https://github.com/vig-os/devcontainer/issues/367)) + - Remove upstream `.vig-os` files at repository root and `assets/smoke-test/` to eliminate downstream-only configuration from upstream CI + - Refactor `.github/workflows/sync-issues.yml` and `.github/workflows/sync-main-to-dev.yml` to run natively on runners via `./.github/actions/setup-env` instead of `resolve-image` + `container` ### Security diff --git a/assets/smoke-test/.vig-os b/assets/smoke-test/.vig-os deleted file mode 100644 index 762e9344..00000000 --- a/assets/smoke-test/.vig-os +++ /dev/null @@ -1,2 +0,0 @@ -# smoke-test devcontainer image configuration -DEVCONTAINER_VERSION=latest diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index e61c4557..e76ec4da 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -88,6 +88,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extend `.github/actions/setup-env/action.yml` with a reusable `retry` shell function exported via `BASH_ENV` as the retry single source of truth - Add `setup-env` input support for uv-only usage by allowing Python setup to be disabled when jobs only need retry tooling - Switch release workflow retry calls from `uv run retry` to shared `retry` and remove duplicated inline retry implementations +- **Upstream sync workflows no longer depend on pre-published GHCR image tags** ([#367](https://github.com/vig-os/devcontainer/issues/367)) + - Remove upstream `.vig-os` files at repository root and `assets/smoke-test/` to eliminate downstream-only configuration from upstream CI + - Refactor `.github/workflows/sync-issues.yml` and `.github/workflows/sync-main-to-dev.yml` to run natively on runners via `./.github/actions/setup-env` instead of `resolve-image` + `container` ### Security From b85143ed1b12f0acbdbb30148677fa671f83f5fe Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 10:27:31 +0100 Subject: [PATCH 107/215] fix(ci): remove obsolete upstream resolve-image action Delete the now-unused upstream resolve-image composite action after moving upstream workflows to runner-native setup, while keeping downstream workspace resolve-image action intact. Refs: #367 --- .github/actions/resolve-image/action.yml | 74 ------------------------ 1 file changed, 74 deletions(-) delete mode 100644 .github/actions/resolve-image/action.yml diff --git a/.github/actions/resolve-image/action.yml b/.github/actions/resolve-image/action.yml deleted file mode 100644 index 8782ed8d..00000000 --- a/.github/actions/resolve-image/action.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Resolve devcontainer image -description: Resolve and validate the devcontainer image tag for CI jobs - -inputs: - image-tag: - description: Optional image tag override - required: false - default: '' - -outputs: - image-tag: - description: Resolved image tag - value: ${{ steps.resolve.outputs.tag }} - -runs: - using: composite - steps: - - name: Resolve image tag - id: resolve - shell: bash - env: - INPUT_IMAGE_TAG: ${{ inputs.image-tag }} - run: | - set -euo pipefail - - if [[ -n "$INPUT_IMAGE_TAG" ]]; then - echo "tag=$INPUT_IMAGE_TAG" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ -f .vig-os ]]; then - VERSION="" - while IFS= read -r line || [[ -n "${line:-}" ]]; do - [[ -z "${line//[[:space:]]/}" ]] && continue - [[ "$line" =~ ^[[:space:]]*# ]] && continue - - case "$line" in - DEVCONTAINER_VERSION=*) - VERSION="${line#*=}" - VERSION="${VERSION#"${VERSION%%[![:space:]]*}"}" - VERSION="${VERSION%"${VERSION##*[![:space:]]}"}" - - if [[ "$VERSION" =~ ^\".*\"$ ]]; then - VERSION="${VERSION:1:-1}" - elif [[ "$VERSION" =~ ^\'.*\'$ ]]; then - VERSION="${VERSION:1:-1}" - fi - break - ;; - esac - done < .vig-os - - if [[ -n "$VERSION" ]]; then - echo "tag=$VERSION" >> "$GITHUB_OUTPUT" - exit 0 - fi - fi - - echo "ERROR: Could not resolve DEVCONTAINER_VERSION from .vig-os and no image-tag override was provided." - exit 1 - - - name: Validate image accessibility - shell: bash - env: - IMAGE_TAG: ${{ steps.resolve.outputs.tag }} - run: | - set -euo pipefail - IMAGE="ghcr.io/vig-os/devcontainer:${IMAGE_TAG}" - echo "Validating image availability: $IMAGE" - if ! docker manifest inspect "$IMAGE" > /dev/null 2>&1; then - echo "ERROR: Cannot access image manifest: $IMAGE" - echo "Check whether the tag exists and whether this workflow has access to GHCR." - exit 1 - fi From bcc80c47d37a053d70c0a349bffbcfb398bbb72b Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 10:49:20 +0100 Subject: [PATCH 108/215] fix(ci): decouple workspace sync workflows from upstream templates Refs: #367 --- .github/workflows/sync-issues.yml | 2 - .github/workflows/sync-main-to-dev.yml | 4 -- .../.github/workflows/sync-issues.yml | 29 ++++++++++++--- .../.github/workflows/sync-main-to-dev.yml | 37 +++++++++++++------ scripts/manifest.toml | 6 --- 5 files changed, 50 insertions(+), 28 deletions(-) diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index b36b7fb8..e92f3d26 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -65,8 +65,6 @@ jobs: - name: Set up environment uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - name: Restore sync state (last synced timestamp) id: restore-state diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 0e598dbc..23d937fb 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -51,8 +51,6 @@ jobs: - name: Set up environment uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - name: Check if dev is up to date with main id: check @@ -106,8 +104,6 @@ jobs: - name: Set up environment uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - name: Re-check if dev is still behind main id: recheck diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index b36b7fb8..fe611574 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -2,6 +2,8 @@ # Uses: # - sync-issues action from this public repository (vig-os/sync-issues-action) # - commit-action from the public repository (vig-os/commit-action) +# NOTE: This workspace workflow is intentionally decoupled from upstream +# `.github/workflows/sync-issues.yml` and maintained separately. name: Sync Issues and PRs @@ -36,8 +38,30 @@ on: # yamllint disable-line rule:truthy permissions: {} # restrict default; job declares its own jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + permissions: + contents: read + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + sync: + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 # Prevent concurrent runs to avoid race conditions when committing and cache collisions concurrency: @@ -63,11 +87,6 @@ jobs: ref: ${{ github.event.inputs.target-branch || 'dev' }} persist-credentials: false - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Restore sync state (last synced timestamp) id: restore-state uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 0e598dbc..689b740e 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -16,6 +16,9 @@ # - COMMIT_APP_* for git/ref operations (least-privilege commit identity) # - RELEASE_APP_* for PR/label operations that require pull-request scopes # +# NOTE: This workspace workflow is intentionally decoupled from upstream +# `.github/workflows/sync-main-to-dev.yml` and maintained separately. +# # With the new CHANGELOG flow, dev already has ## Unreleased (created during # prepare-release), so no CHANGELOG reset is needed here. # @@ -36,9 +39,29 @@ permissions: contents: read jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .vig-os + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + check: name: Check if dev is up to date + needs: [resolve-image] runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 5 outputs: up_to_date: ${{ steps.check.outputs.up_to_date }} @@ -49,11 +72,6 @@ jobs: with: fetch-depth: 0 - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Check if dev is up to date with main id: check run: | @@ -78,9 +96,11 @@ jobs: sync: name: Merge main into dev via PR - needs: [check] + needs: [resolve-image, check] if: needs.check.outputs.up_to_date != 'true' runs-on: ubuntu-22.04 + container: + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} @@ -104,11 +124,6 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - name: Re-check if dev is still behind main id: recheck run: | diff --git a/scripts/manifest.toml b/scripts/manifest.toml index 6cfa0f7f..3bd739ee 100644 --- a/scripts/manifest.toml +++ b/scripts/manifest.toml @@ -67,12 +67,6 @@ transforms = [ [[entries]] src = ".github/workflows/scorecard.yml" -[[entries]] -src = ".github/workflows/sync-issues.yml" - -[[entries]] -src = ".github/workflows/sync-main-to-dev.yml" - [[entries]] src = ".github/workflows/codeql.yml" From 47f848fbc951c0172ab03f41790f00857414ab4c Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 10:51:28 +0100 Subject: [PATCH 109/215] refactor(ci): remove downstream release contract handshake Remove validate-contract and contract_version wiring from workspace release templates and docs, relying on workflow_call input schemas as the single compatibility boundary. Refs: #367 --- .../actions/validate-contract/action.yml | 27 ------------------- .../.github/workflows/release-core.yml | 9 ------- .../.github/workflows/release-extension.yml | 9 ------- .../.github/workflows/release-publish.yml | 9 ------- .../workspace/.github/workflows/release.yml | 3 --- docs/DOWNSTREAM_RELEASE.md | 23 ++++------------ 6 files changed, 5 insertions(+), 75 deletions(-) delete mode 100644 assets/workspace/.github/actions/validate-contract/action.yml diff --git a/assets/workspace/.github/actions/validate-contract/action.yml b/assets/workspace/.github/actions/validate-contract/action.yml deleted file mode 100644 index 6f4c445e..00000000 --- a/assets/workspace/.github/actions/validate-contract/action.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Validate release contract version -description: Validate that the caller's contract version matches the expected version - -inputs: - contract_version: - description: "Contract version received from caller" - required: true - expected_version: - description: "Expected contract version" - required: false - default: "1" - -runs: - using: composite - steps: - - name: Validate contract version - shell: bash - env: - CONTRACT_VERSION: ${{ inputs.contract_version }} - EXPECTED: ${{ inputs.expected_version }} - run: | - set -euo pipefail - if [ "$CONTRACT_VERSION" != "$EXPECTED" ]; then - echo "ERROR: Unsupported contract_version '$CONTRACT_VERSION' (expected '$EXPECTED')." - echo "Update your workflow template and set contract_version: \"$EXPECTED\"." - exit 1 - fi diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index e5be010d..38f9681d 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -27,10 +27,6 @@ on: # yamllint disable-line rule:truthy required: false default: "41898282+github-actions[bot]@users.noreply.github.com" type: string - contract_version: - description: "Release workflow contract version" - required: true - type: string secrets: token: required: false @@ -102,11 +98,6 @@ jobs: image_tag: ${{ needs.resolve-image.outputs.image-tag }} steps: - - name: Validate contract version - uses: ./.github/actions/validate-contract - with: - contract_version: ${{ inputs.contract_version }} - - name: Resolve auth token id: auth env: diff --git a/assets/workspace/.github/workflows/release-extension.yml b/assets/workspace/.github/workflows/release-extension.yml index 835357ca..89c24501 100644 --- a/assets/workspace/.github/workflows/release-extension.yml +++ b/assets/workspace/.github/workflows/release-extension.yml @@ -23,21 +23,12 @@ on: # yamllint disable-line rule:truthy description: "Version tag that will be published (X.Y.Z or X.Y.Z-rcN)" required: true type: string - contract_version: - description: "Release workflow contract version" - required: true - type: string jobs: extension: name: Extension Hook (Default No-op) runs-on: ubuntu-22.04 steps: - - name: Validate contract version - uses: ./.github/actions/validate-contract - with: - contract_version: ${{ inputs.contract_version }} - - name: Default extension summary env: VERSION: ${{ inputs.version }} diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index badc3b14..0c8d0562 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -33,10 +33,6 @@ on: # yamllint disable-line rule:truthy required: false default: "41898282+github-actions[bot]@users.noreply.github.com" type: string - contract_version: - description: "Release workflow contract version" - required: true - type: string secrets: token: required: false @@ -83,11 +79,6 @@ jobs: release_url: ${{ steps.out.outputs.release_url }} steps: - - name: Validate contract version - uses: ./.github/actions/validate-contract - with: - contract_version: ${{ inputs.contract_version }} - - name: Resolve auth token id: auth env: diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index 2e02dcb3..3d0a5826 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -45,7 +45,6 @@ jobs: dry_run: ${{ inputs.dry-run }} git_user_name: ${{ inputs.git-user-name }} git_user_email: ${{ inputs.git-user-email }} - contract_version: "1" secrets: inherit extension: @@ -59,7 +58,6 @@ jobs: release_date: ${{ needs.core.outputs.release_date }} release_kind: ${{ needs.core.outputs.release_kind }} publish_version: ${{ needs.core.outputs.publish_version }} - contract_version: "1" secrets: inherit publish: @@ -75,7 +73,6 @@ jobs: release_kind: ${{ needs.core.outputs.release_kind }} git_user_name: ${{ inputs.git-user-name }} git_user_email: ${{ inputs.git-user-email }} - contract_version: "1" secrets: inherit rollback: diff --git a/docs/DOWNSTREAM_RELEASE.md b/docs/DOWNSTREAM_RELEASE.md index c3615035..8bacb083 100644 --- a/docs/DOWNSTREAM_RELEASE.md +++ b/docs/DOWNSTREAM_RELEASE.md @@ -1,6 +1,6 @@ # Downstream Release Workflows -This document describes the downstream release workflow contract shipped in `assets/workspace/.github/workflows/`. +This document describes the downstream release workflows shipped in `assets/workspace/.github/workflows/`. ## Overview @@ -25,19 +25,15 @@ On failure, the orchestrator runs a single consolidated rollback that resets the Candidate mode keeps release branch content unchanged (no CHANGELOG date finalization). Final mode performs changelog finalization before publish. -## Workflow Contract +## Workflow Interface -Current contract version: `"1"`. - -The following workflows require `contract_version: "1"`: +The orchestrator `release.yml` passes release context directly to the called reusable workflows: - `.github/workflows/release-core.yml` - `.github/workflows/release-extension.yml` - `.github/workflows/release-publish.yml` -Contract validation is performed by the shared composite action `.github/actions/validate-contract`. The expected version is defined once in that action's default input. When bumping the contract version, update the action default and the `contract_version` values in `release.yml`. - -If `contract_version` does not match, the workflow fails with an actionable error. +There is no separate contract-version handshake; compatibility is defined by the `workflow_call` input schema in each workflow file. ## Input Naming Convention @@ -87,10 +83,6 @@ on: publish_version: required: true type: string - contract_version: - required: true - type: string - jobs: ghcr-publish: name: Publish Container Image @@ -99,11 +91,6 @@ jobs: contents: read packages: write steps: - - name: Validate contract version - uses: ./.github/actions/validate-contract - with: - contract_version: ${{ inputs.contract_version }} - - name: Checkout finalized commit uses: actions/checkout@v4 with: @@ -130,7 +117,7 @@ jobs: 1. Upgrade downstream devcontainer version (which redeploys `assets/workspace` templates). 2. Keep project-owned `release-extension.yml` (preserved on force upgrades). -3. Ensure orchestrator and called workflows use the expected `contract_version`. +3. Ensure project-owned `release-extension.yml` matches the current `workflow_call` inputs used by `release.yml`. 4. Run `prepare-release` / `release` in `--dry-run` mode to validate integration. ## Pinning and Drift From 80de687e5aa3c683c5748ddc6af38e7c88c7ac32 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 11:10:33 +0100 Subject: [PATCH 110/215] fix(ci): minimize setup-env footprint in sync workflows Disable Python and just installation in upstream sync workflows where only the retry helper is required, reducing runtime and external bootstrap failure modes. Refs: #367 --- .github/workflows/sync-issues.yml | 3 +++ .github/workflows/sync-main-to-dev.yml | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index e92f3d26..80cb4547 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -65,6 +65,9 @@ jobs: - name: Set up environment uses: ./.github/actions/setup-env + with: + install-python: 'false' + install-just: 'false' - name: Restore sync state (last synced timestamp) id: restore-state diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 23d937fb..7fe9f280 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -51,6 +51,9 @@ jobs: - name: Set up environment uses: ./.github/actions/setup-env + with: + install-python: 'false' + install-just: 'false' - name: Check if dev is up to date with main id: check @@ -104,6 +107,9 @@ jobs: - name: Set up environment uses: ./.github/actions/setup-env + with: + install-python: 'false' + install-just: 'false' - name: Re-check if dev is still behind main id: recheck From f92dac44d646c91d2d7c0a1e8caf01eecdc9e30c Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 12:18:18 +0100 Subject: [PATCH 111/215] fix(ci): harden setup-env uv sync retry path Refs: #370 --- .github/actions/setup-env/action.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index 5349ab84..24b2c381 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -215,7 +215,21 @@ runs: - name: Sync Python dependencies if: inputs.sync-dependencies == 'true' shell: bash - run: uv sync --frozen --all-extras + run: | + set -euo pipefail + + set +e + uv sync --frozen --all-extras + rc=$? + set -e + + if [ "$rc" -ne 0 ]; then + echo "WARNING: uv sync failed (exit $rc), clearing cache and .venv before retry..." + uv cache clean + rm -rf .venv + echo "Retrying uv sync..." + uv sync --frozen --all-extras + fi # ── Podman ────────────────────────────────────────────────────────── - name: Install podman From 2dbafcb5c5a7d6205181d416e1c502859bec6028 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 12:22:10 +0100 Subject: [PATCH 112/215] docs(ci): record uv sync crash recovery fix Refs: #370 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e76ec4da..0fc25b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Upstream sync workflows no longer depend on pre-published GHCR image tags** ([#367](https://github.com/vig-os/devcontainer/issues/367)) - Remove upstream `.vig-os` files at repository root and `assets/smoke-test/` to eliminate downstream-only configuration from upstream CI - Refactor `.github/workflows/sync-issues.yml` and `.github/workflows/sync-main-to-dev.yml` to run natively on runners via `./.github/actions/setup-env` instead of `resolve-image` + `container` +- **Release test-image setup now recovers from uv sync crashes** ([#370](https://github.com/vig-os/devcontainer/issues/370)) + - Harden `.github/actions/setup-env/action.yml` to retry `uv sync --frozen --all-extras` once after clearing uv cache and removing stale `.venv` + - Prevent repeat release test failures when `setup-env` is executed multiple times in the same job ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index e76ec4da..0fc25b6f 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -91,6 +91,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Upstream sync workflows no longer depend on pre-published GHCR image tags** ([#367](https://github.com/vig-os/devcontainer/issues/367)) - Remove upstream `.vig-os` files at repository root and `assets/smoke-test/` to eliminate downstream-only configuration from upstream CI - Refactor `.github/workflows/sync-issues.yml` and `.github/workflows/sync-main-to-dev.yml` to run natively on runners via `./.github/actions/setup-env` instead of `resolve-image` + `container` +- **Release test-image setup now recovers from uv sync crashes** ([#370](https://github.com/vig-os/devcontainer/issues/370)) + - Harden `.github/actions/setup-env/action.yml` to retry `uv sync --frozen --all-extras` once after clearing uv cache and removing stale `.venv` + - Prevent repeat release test failures when `setup-env` is executed multiple times in the same job ### Security From a318ad10cdb52b08ef83c91de271e11a0ac430b8 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 12:28:19 +0100 Subject: [PATCH 113/215] fix(ci): simplify uv sync retry control flow Refs: #370 --- .github/actions/setup-env/action.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index 24b2c381..e3ab9a4c 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -218,12 +218,10 @@ runs: run: | set -euo pipefail - set +e - uv sync --frozen --all-extras - rc=$? - set -e - - if [ "$rc" -ne 0 ]; then + if uv sync --frozen --all-extras; then + : + else + rc=$? echo "WARNING: uv sync failed (exit $rc), clearing cache and .venv before retry..." uv cache clean rm -rf .venv From de38bdfc43f09f0f02000d8f78b1c3fd298502c2 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 13:45:05 +0100 Subject: [PATCH 114/215] fix(ci): checkout repository before rollback setup-env Refs: #369 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5ab0402..b53d3c0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1262,6 +1262,9 @@ jobs: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up environment uses: ./.github/actions/setup-env with: From 16235569870700eb32aa158ae19dedcbc881ddb2 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 13:54:38 +0100 Subject: [PATCH 115/215] fix(ci): add rollback contents permission for checkout Refs: #369 --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b53d3c0f..d8f2e627 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1250,6 +1250,7 @@ jobs: timeout-minutes: 10 if: ${{ always() && (needs.validate.result == 'failure' || needs.finalize.result == 'failure' || needs.build-and-test.result == 'failure' || needs.publish.result == 'failure') }} permissions: + contents: read # required by actions/checkout in rollback job issues: write # create failure issue # Branch rollback and tag deletion use the RELEASE_APP token (not GITHUB_TOKEN), # which has Contents read/write configured on the GitHub App. From 3113bd0d83511cd4019bfeed5ec6d1665e86934e Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 14:40:03 +0100 Subject: [PATCH 116/215] fix(ci): prevent setup-env BASH_ENV self-source recursion Refs: #374 --- .github/actions/setup-env/action.yml | 2 +- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index e3ab9a4c..b4a6a935 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -201,7 +201,7 @@ runs: export -f retry EOF - if [ -n "$PREV_BASH_ENV" ] && [ -f "$PREV_BASH_ENV" ]; then + if [ -n "$PREV_BASH_ENV" ] && [ -f "$PREV_BASH_ENV" ] && [ "$PREV_BASH_ENV" != "$RETRY_HELPER" ]; then { echo "source \"$PREV_BASH_ENV\"" cat "$RETRY_HELPER" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc25b6f..886665e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release test-image setup now recovers from uv sync crashes** ([#370](https://github.com/vig-os/devcontainer/issues/370)) - Harden `.github/actions/setup-env/action.yml` to retry `uv sync --frozen --all-extras` once after clearing uv cache and removing stale `.venv` - Prevent repeat release test failures when `setup-env` is executed multiple times in the same job +- **Release setup-env no longer self-sources retry helper via BASH_ENV** ([#374](https://github.com/vig-os/devcontainer/issues/374)) + - Guard the retry-helper merge logic in `.github/actions/setup-env/action.yml` to skip merging when `PREV_BASH_ENV` already equals `RETRY_HELPER` + - Prevent infinite `source` recursion and exit 139 crashes when `setup-env` is invoked multiple times in one job ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 0fc25b6f..886665e9 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -94,6 +94,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release test-image setup now recovers from uv sync crashes** ([#370](https://github.com/vig-os/devcontainer/issues/370)) - Harden `.github/actions/setup-env/action.yml` to retry `uv sync --frozen --all-extras` once after clearing uv cache and removing stale `.venv` - Prevent repeat release test failures when `setup-env` is executed multiple times in the same job +- **Release setup-env no longer self-sources retry helper via BASH_ENV** ([#374](https://github.com/vig-os/devcontainer/issues/374)) + - Guard the retry-helper merge logic in `.github/actions/setup-env/action.yml` to skip merging when `PREV_BASH_ENV` already equals `RETRY_HELPER` + - Prevent infinite `source` recursion and exit 139 crashes when `setup-env` is invoked multiple times in one job ### Security From aed2806a0fe677a351195e61428806fb38d8495c Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 16:13:29 +0100 Subject: [PATCH 117/215] fix(ci): require checkout before smoke-test setup-env Refs: #376 --- .github/workflows/release.yml | 4 ++++ CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 3 files changed, 10 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8f2e627..e816f6e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1165,6 +1165,10 @@ jobs: owner: vig-os repositories: devcontainer-smoke-test + # Local actions from ./.github/actions/* require repository checkout in this job workspace. + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up environment uses: ./.github/actions/setup-env with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 886665e9..cd85ee1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release setup-env no longer self-sources retry helper via BASH_ENV** ([#374](https://github.com/vig-os/devcontainer/issues/374)) - Guard the retry-helper merge logic in `.github/actions/setup-env/action.yml` to skip merging when `PREV_BASH_ENV` already equals `RETRY_HELPER` - Prevent infinite `source` recursion and exit 139 crashes when `setup-env` is invoked multiple times in one job +- **Smoke-test dispatch now checks out repository before local setup action** ([#376](https://github.com/vig-os/devcontainer/issues/376)) + - Add `actions/checkout` to the `smoke-test` job in `.github/workflows/release.yml` before invoking `./.github/actions/setup-env` + - Prevent dispatch failures caused by missing local action metadata (`action.yml`) in a fresh job workspace ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 886665e9..cd85ee1f 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -97,6 +97,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release setup-env no longer self-sources retry helper via BASH_ENV** ([#374](https://github.com/vig-os/devcontainer/issues/374)) - Guard the retry-helper merge logic in `.github/actions/setup-env/action.yml` to skip merging when `PREV_BASH_ENV` already equals `RETRY_HELPER` - Prevent infinite `source` recursion and exit 139 crashes when `setup-env` is invoked multiple times in one job +- **Smoke-test dispatch now checks out repository before local setup action** ([#376](https://github.com/vig-os/devcontainer/issues/376)) + - Add `actions/checkout` to the `smoke-test` job in `.github/workflows/release.yml` before invoking `./.github/actions/setup-env` + - Prevent dispatch failures caused by missing local action metadata (`action.yml`) in a fresh job workspace ### Security From 170c8b562bed0ec582647839bb4f9edabd1733a3 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 17:40:14 +0100 Subject: [PATCH 118/215] fix(ci): include resolve-image action in sparse checkout Refs: #380 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ assets/workspace/.github/workflows/ci.yml | 4 +++- assets/workspace/.github/workflows/release-core.yml | 4 +++- assets/workspace/.github/workflows/release-publish.yml | 4 +++- assets/workspace/.github/workflows/sync-issues.yml | 4 +++- assets/workspace/.github/workflows/sync-main-to-dev.yml | 4 +++- 7 files changed, 21 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd85ee1f..d85bb256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch now checks out repository before local setup action** ([#376](https://github.com/vig-os/devcontainer/issues/376)) - Add `actions/checkout` to the `smoke-test` job in `.github/workflows/release.yml` before invoking `./.github/actions/setup-env` - Prevent dispatch failures caused by missing local action metadata (`action.yml`) in a fresh job workspace +- **Workspace resolve-image jobs now checkout local action metadata** ([#380](https://github.com/vig-os/devcontainer/issues/380)) + - Update `sparse-checkout` in workspace `resolve-image` jobs to include `.github/actions/resolve-image` in addition to `.vig-os` + - Prevent CI failures in downstream deploy PRs where local composite actions were missing from sparse checkout ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index cd85ee1f..d85bb256 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -100,6 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch now checks out repository before local setup action** ([#376](https://github.com/vig-os/devcontainer/issues/376)) - Add `actions/checkout` to the `smoke-test` job in `.github/workflows/release.yml` before invoking `./.github/actions/setup-env` - Prevent dispatch failures caused by missing local action metadata (`action.yml`) in a fresh job workspace +- **Workspace resolve-image jobs now checkout local action metadata** ([#380](https://github.com/vig-os/devcontainer/issues/380)) + - Update `sparse-checkout` in workspace `resolve-image` jobs to include `.github/actions/resolve-image` in addition to `.vig-os` + - Prevent CI failures in downstream deploy PRs where local composite actions were missing from sparse checkout ### Security diff --git a/assets/workspace/.github/workflows/ci.yml b/assets/workspace/.github/workflows/ci.yml index 6590c9e7..2e382bf3 100644 --- a/assets/workspace/.github/workflows/ci.yml +++ b/assets/workspace/.github/workflows/ci.yml @@ -53,7 +53,9 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: .vig-os + sparse-checkout: | + .vig-os + .github/actions/resolve-image sparse-checkout-cone-mode: false - name: Resolve container image diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 38f9681d..08795a5f 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -74,7 +74,9 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: .vig-os + sparse-checkout: | + .vig-os + .github/actions/resolve-image sparse-checkout-cone-mode: false - name: Resolve container image diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index 0c8d0562..41c40c1d 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -58,7 +58,9 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: .vig-os + sparse-checkout: | + .vig-os + .github/actions/resolve-image sparse-checkout-cone-mode: false - name: Resolve container image diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index fe611574..d363fc83 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -50,7 +50,9 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: .vig-os + sparse-checkout: | + .vig-os + .github/actions/resolve-image sparse-checkout-cone-mode: false - name: Resolve container image diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 689b740e..3a498ff6 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -49,7 +49,9 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: .vig-os + sparse-checkout: | + .vig-os + .github/actions/resolve-image sparse-checkout-cone-mode: false - name: Resolve container image From 27891c5a4323111f9ea8338e0de4830b29a14140 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 19 Mar 2026 23:30:43 +0100 Subject: [PATCH 119/215] fix(ci): set explicit gh repo context in smoke-test dispatch Refs: #386 --- CHANGELOG.md | 3 +++ .../smoke-test/.github/workflows/repository-dispatch.yml | 8 ++++++++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d85bb256..3c5e3c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Workspace resolve-image jobs now checkout local action metadata** ([#380](https://github.com/vig-os/devcontainer/issues/380)) - Update `sparse-checkout` in workspace `resolve-image` jobs to include `.github/actions/resolve-image` in addition to `.vig-os` - Prevent CI failures in downstream deploy PRs where local composite actions were missing from sparse checkout +- **Smoke-test dispatch gh jobs now set explicit repo context** ([#386](https://github.com/vig-os/devcontainer/issues/386)) + - Add job-level `GH_REPO: ${{ github.repository }}` to `cleanup-release`, `trigger-prepare-release`, `ready-release-pr`, and `trigger-release` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` + - Prevent `gh` CLI failures (`fatal: not a git repository`) in runner jobs that do not perform `actions/checkout` ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 5f634727..304aed67 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -346,6 +346,8 @@ jobs: name: Cleanup stale release branch and PR runs-on: ubuntu-22.04 timeout-minutes: 5 + env: + GH_REPO: ${{ github.repository }} needs: [validate, wait-deploy-merge] steps: - name: Generate release app token for PR/branch cleanup @@ -385,6 +387,8 @@ jobs: name: Trigger and wait for prepare-release workflow runs-on: ubuntu-22.04 timeout-minutes: 25 + env: + GH_REPO: ${{ github.repository }} needs: [validate, cleanup-release] outputs: before_run_id: ${{ steps.capture_prepare_before.outputs.before_run_id }} @@ -454,6 +458,8 @@ jobs: name: Sync changelog and prepare release PR runs-on: ubuntu-22.04 timeout-minutes: 35 + env: + GH_REPO: ${{ github.repository }} needs: [validate, trigger-prepare-release] outputs: release_pr: ${{ steps.locate_release_pr.outputs.release_pr }} @@ -574,6 +580,8 @@ jobs: name: Trigger and wait for release workflow runs-on: ubuntu-22.04 timeout-minutes: 35 + env: + GH_REPO: ${{ github.repository }} needs: [validate, ready-release-pr] outputs: before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index d85bb256..3c5e3c8e 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -103,6 +103,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Workspace resolve-image jobs now checkout local action metadata** ([#380](https://github.com/vig-os/devcontainer/issues/380)) - Update `sparse-checkout` in workspace `resolve-image` jobs to include `.github/actions/resolve-image` in addition to `.vig-os` - Prevent CI failures in downstream deploy PRs where local composite actions were missing from sparse checkout +- **Smoke-test dispatch gh jobs now set explicit repo context** ([#386](https://github.com/vig-os/devcontainer/issues/386)) + - Add job-level `GH_REPO: ${{ github.repository }}` to `cleanup-release`, `trigger-prepare-release`, `ready-release-pr`, and `trigger-release` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` + - Prevent `gh` CLI failures (`fatal: not a git repository`) in runner jobs that do not perform `actions/checkout` ### Security From d55cab9146cc82ace4a71ea4f540a2291c1da0f2 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 09:07:59 +0100 Subject: [PATCH 120/215] fix(ci): harden smoke-test dispatch workflow contract Refs: #389 --- CHANGELOG.md | 3 ++ .../.github/workflows/repository-dispatch.yml | 30 +++++++++++++++---- assets/workspace/.devcontainer/CHANGELOG.md | 3 ++ docs/CROSS_REPO_RELEASE_GATE.md | 13 ++++++++ tests/bats/just.bats | 11 +++++-- 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5e3c8e..e6a0ff45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch gh jobs now set explicit repo context** ([#386](https://github.com/vig-os/devcontainer/issues/386)) - Add job-level `GH_REPO: ${{ github.repository }}` to `cleanup-release`, `trigger-prepare-release`, `ready-release-pr`, and `trigger-release` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` - Prevent `gh` CLI failures (`fatal: not a git repository`) in runner jobs that do not perform `actions/checkout` +- **Smoke-test release orchestration now validates workflow contract before dispatch** ([#389](https://github.com/vig-os/devcontainer/issues/389)) + - Add a preflight check that verifies `prepare-release.yml` and `release.yml` are resolvable on dispatch ref `dev` before downstream orchestration starts + - Dispatch and polling now use explicit ref/branch context (`--ref dev` / `--branch dev`) to avoid default-branch workflow registry drift and `404 workflow not found` failures ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 304aed67..dc13447e 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -389,6 +389,7 @@ jobs: timeout-minutes: 25 env: GH_REPO: ${{ github.repository }} + WORKFLOW_REF: dev needs: [validate, cleanup-release] outputs: before_run_id: ${{ steps.capture_prepare_before.outputs.before_run_id }} @@ -402,6 +403,23 @@ jobs: owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} + - name: Preflight check required release workflows on dispatch ref + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + run: | + set -euo pipefail + # Canonical cross-repo dispatch contract lives in docs/CROSS_REPO_RELEASE_GATE.md. + REQUIRED_WORKFLOWS="prepare-release.yml release.yml" + for workflow_file in ${REQUIRED_WORKFLOWS}; do + if gh workflow view "${workflow_file}" --ref "${WORKFLOW_REF}" >/dev/null 2>&1; then + echo "Workflow available on ${WORKFLOW_REF}: ${workflow_file}" + else + echo "ERROR: required workflow '${workflow_file}' is not resolvable on ref '${WORKFLOW_REF}'" + echo "Dispatch contract drift detected; aborting before orchestration dispatch." + exit 1 + fi + done + - name: Capture latest prepare-release run id id: capture_prepare_before env: @@ -409,7 +427,7 @@ jobs: run: | set -euo pipefail BEFORE_RUN_ID="$( - gh run list --workflow prepare-release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 + gh run list --workflow prepare-release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 )" echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" @@ -419,7 +437,7 @@ jobs: BASE_VERSION: ${{ needs.validate.outputs.base_version }} run: | set -euo pipefail - gh workflow run prepare-release.yml -f version="${BASE_VERSION}" + gh workflow run prepare-release.yml --ref "${WORKFLOW_REF}" -f version="${BASE_VERSION}" - name: Wait for prepare-release completion env: @@ -432,7 +450,7 @@ jobs: ELAPSED=0 while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do - RUN_ID="$(gh run list --workflow prepare-release.yml --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + RUN_ID="$(gh run list --workflow prepare-release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" if [ "${STATUS}" = "completed" ]; then @@ -582,6 +600,7 @@ jobs: timeout-minutes: 35 env: GH_REPO: ${{ github.repository }} + WORKFLOW_REF: dev needs: [validate, ready-release-pr] outputs: before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} @@ -602,7 +621,7 @@ jobs: run: | set -euo pipefail BEFORE_RUN_ID="$( - gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 + gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 )" echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" @@ -614,6 +633,7 @@ jobs: run: | set -euo pipefail gh workflow run release.yml \ + --ref "${WORKFLOW_REF}" \ -f version="${BASE_VERSION}" \ -f release-kind="${RELEASE_KIND}" @@ -628,7 +648,7 @@ jobs: ELAPSED=0 while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do - RUN_ID="$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + RUN_ID="$(gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" if [ "${STATUS}" = "completed" ]; then diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 3c5e3c8e..e6a0ff45 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -106,6 +106,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch gh jobs now set explicit repo context** ([#386](https://github.com/vig-os/devcontainer/issues/386)) - Add job-level `GH_REPO: ${{ github.repository }}` to `cleanup-release`, `trigger-prepare-release`, `ready-release-pr`, and `trigger-release` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` - Prevent `gh` CLI failures (`fatal: not a git repository`) in runner jobs that do not perform `actions/checkout` +- **Smoke-test release orchestration now validates workflow contract before dispatch** ([#389](https://github.com/vig-os/devcontainer/issues/389)) + - Add a preflight check that verifies `prepare-release.yml` and `release.yml` are resolvable on dispatch ref `dev` before downstream orchestration starts + - Dispatch and polling now use explicit ref/branch context (`--ref dev` / `--branch dev`) to avoid default-branch workflow registry drift and `404 workflow not found` failures ### Security diff --git a/docs/CROSS_REPO_RELEASE_GATE.md b/docs/CROSS_REPO_RELEASE_GATE.md index 911e69fa..8050caf0 100644 --- a/docs/CROSS_REPO_RELEASE_GATE.md +++ b/docs/CROSS_REPO_RELEASE_GATE.md @@ -34,6 +34,17 @@ Payload contract: - `client_payload[source_sha]` - `client_payload[correlation_id]` +Workflow dispatch contract: + +- Required downstream workflow IDs/files: + - `prepare-release.yml` + - `release.yml` +- Required dispatch ref: + - `dev` +- Dispatch and wait operations must use the same ref context to avoid default-branch drift: + - dispatch via `gh workflow run --ref dev ...` + - run discovery via `gh run list --workflow --branch dev ...` + ### Receiver Responsibilities The receiver workflow (`assets/smoke-test/.github/workflows/repository-dispatch.yml`) performs: @@ -44,6 +55,7 @@ The receiver workflow (`assets/smoke-test/.github/workflows/repository-dispatch. - candidate tag -> GitHub pre-release - final tag -> GitHub release 4. idempotency checks when a release object already exists +5. preflight validation that required downstream workflow IDs are resolvable on the dispatch ref before orchestration starts ### Gate Checks in the Orchestrator @@ -92,6 +104,7 @@ Common failure patterns: - downstream release type mismatch (`prerelease` flag differs from expected) - malformed/insufficient dispatch payload - downstream workflow failure prior to release artifact publication +- workflow contract drift (required workflow ID missing on expected dispatch ref), which must fail fast in preflight ## Operational Verification diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 2b4370f8..a1e4b8af 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -101,17 +101,22 @@ setup() { } @test "smoke-test dispatch triggers downstream prepare and release workflows" { - run bash -lc "grep -Fq -- 'cleanup-release:' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow run prepare-release.yml' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow run release.yml' assets/smoke-test/.github/workflows/repository-dispatch.yml" + run bash -lc "grep -Fq -- 'cleanup-release:' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow run prepare-release.yml --ref \"\${WORKFLOW_REF}\"' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow run release.yml \\' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- '--ref \"\${WORKFLOW_REF}\" \\' assets/smoke-test/.github/workflows/repository-dispatch.yml" + assert_success +} + +@test "smoke-test dispatch preflight validates required workflow contract" { + run bash -lc "grep -Fq -- 'Preflight check required release workflows on dispatch ref' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'REQUIRED_WORKFLOWS=\"prepare-release.yml release.yml\"' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow view \"\${workflow_file}\" --ref \"\${WORKFLOW_REF}\"' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success } @test "smoke-test dispatch wait logic tracks prepare-release run after dispatch" { - run bash -lc 'grep -Fq -- "Capture latest prepare-release run id" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_prepare_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/repository-dispatch.yml' + run bash -lc 'grep -Fq -- "Capture latest prepare-release run id" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "gh run list --workflow prepare-release.yml --branch \"\${WORKFLOW_REF}\"" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_prepare_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } @test "smoke-test dispatch wait logic tracks release run after dispatch" { - run bash -lc 'grep -Fq -- "Capture latest release run id" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_release_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/repository-dispatch.yml' + run bash -lc 'grep -Fq -- "Capture latest release run id" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "gh run list --workflow release.yml --branch \"\${WORKFLOW_REF}\"" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_release_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } From 6e6ebcd6326f52934e079b56f5880fc6a264c201 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 09:19:04 +0100 Subject: [PATCH 121/215] fix(ci): refine smoke-test preflight diagnostics Refs: #389 --- .../.github/workflows/repository-dispatch.yml | 21 ++++++++++++------- tests/bats/just.bats | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index dc13447e..1069a20c 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -29,6 +29,9 @@ concurrency: permissions: contents: read +env: + WORKFLOW_REF: dev + jobs: validate: name: Validate dispatch payload @@ -389,7 +392,6 @@ jobs: timeout-minutes: 25 env: GH_REPO: ${{ github.repository }} - WORKFLOW_REF: dev needs: [validate, cleanup-release] outputs: before_run_id: ${{ steps.capture_prepare_before.outputs.before_run_id }} @@ -409,13 +411,19 @@ jobs: run: | set -euo pipefail # Canonical cross-repo dispatch contract lives in docs/CROSS_REPO_RELEASE_GATE.md. - REQUIRED_WORKFLOWS="prepare-release.yml release.yml" - for workflow_file in ${REQUIRED_WORKFLOWS}; do - if gh workflow view "${workflow_file}" --ref "${WORKFLOW_REF}" >/dev/null 2>&1; then + REQUIRED_WORKFLOWS=(prepare-release.yml release.yml) + for workflow_file in "${REQUIRED_WORKFLOWS[@]}"; do + if WORKFLOW_CHECK_OUTPUT="$(gh workflow view "${workflow_file}" --ref "${WORKFLOW_REF}" 2>&1)"; then echo "Workflow available on ${WORKFLOW_REF}: ${workflow_file}" else - echo "ERROR: required workflow '${workflow_file}' is not resolvable on ref '${WORKFLOW_REF}'" - echo "Dispatch contract drift detected; aborting before orchestration dispatch." + if printf '%s' "${WORKFLOW_CHECK_OUTPUT}" | grep -Eqi "404|not found"; then + echo "ERROR: required workflow '${workflow_file}' is not resolvable on ref '${WORKFLOW_REF}'" + echo "Dispatch contract drift detected; aborting before orchestration dispatch." + else + echo "ERROR: failed to validate workflow '${workflow_file}' on ref '${WORKFLOW_REF}'" + echo "Validation failed due to a non-contract error (auth/permission/API/network)." + fi + echo "${WORKFLOW_CHECK_OUTPUT}" exit 1 fi done @@ -600,7 +608,6 @@ jobs: timeout-minutes: 35 env: GH_REPO: ${{ github.repository }} - WORKFLOW_REF: dev needs: [validate, ready-release-pr] outputs: before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} diff --git a/tests/bats/just.bats b/tests/bats/just.bats index a1e4b8af..67243b38 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -106,7 +106,7 @@ setup() { } @test "smoke-test dispatch preflight validates required workflow contract" { - run bash -lc "grep -Fq -- 'Preflight check required release workflows on dispatch ref' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'REQUIRED_WORKFLOWS=\"prepare-release.yml release.yml\"' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow view \"\${workflow_file}\" --ref \"\${WORKFLOW_REF}\"' assets/smoke-test/.github/workflows/repository-dispatch.yml" + run bash -lc "grep -Fq -- 'Preflight check required release workflows on dispatch ref' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'REQUIRED_WORKFLOWS=(prepare-release.yml release.yml)' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'for workflow_file in \"\${REQUIRED_WORKFLOWS[@]}\"; do' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'WORKFLOW_CHECK_OUTPUT=\"\$(gh workflow view \"\${workflow_file}\" --ref \"\${WORKFLOW_REF}\" 2>&1)\"' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success } From d145b29cbd1fa7abdc4139e89bbf63018ccde012 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 09:21:11 +0100 Subject: [PATCH 122/215] docs(ci): include release_kind in smoke-test dispatch payload comment Refs: #388 --- assets/smoke-test/.github/workflows/repository-dispatch.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 1069a20c..789bbba7 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -9,8 +9,9 @@ name: Repository Dispatch Listener # # Dispatch payload: # - Preferred: client_payload.tag -# - Optional: client_payload.event_type, client_payload.source_repo, -# client_payload.source_workflow, client_payload.source_run_id, +# - Optional: client_payload.event_type, client_payload.release_kind, +# client_payload.source_repo, client_payload.source_workflow, +# client_payload.source_run_id, # client_payload.source_run_url, client_payload.source_sha, # client_payload.correlation_id # From 6829b28a6ca91d0da4e0f7576636193cdd556681 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 10:24:11 +0100 Subject: [PATCH 123/215] test(ci): assert gh workflow preflight uses --yaml with --ref Refs: #392 --- tests/bats/just.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 67243b38..72332da3 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -106,7 +106,7 @@ setup() { } @test "smoke-test dispatch preflight validates required workflow contract" { - run bash -lc "grep -Fq -- 'Preflight check required release workflows on dispatch ref' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'REQUIRED_WORKFLOWS=(prepare-release.yml release.yml)' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'for workflow_file in \"\${REQUIRED_WORKFLOWS[@]}\"; do' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'WORKFLOW_CHECK_OUTPUT=\"\$(gh workflow view \"\${workflow_file}\" --ref \"\${WORKFLOW_REF}\" 2>&1)\"' assets/smoke-test/.github/workflows/repository-dispatch.yml" + run bash -lc "grep -Fq -- 'Preflight check required release workflows on dispatch ref' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'REQUIRED_WORKFLOWS=(prepare-release.yml release.yml)' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'for workflow_file in \"\${REQUIRED_WORKFLOWS[@]}\"; do' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'WORKFLOW_CHECK_OUTPUT=\"\$(gh workflow view \"\${workflow_file}\" --ref \"\${WORKFLOW_REF}\" --yaml 2>&1 >/dev/null)\"' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success } From eac1bb7225d4d465a054877713c5f864354d7f20 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 10:24:24 +0100 Subject: [PATCH 124/215] fix(ci): make smoke-test preflight compatible with gh ref validation Refs: #392 --- CHANGELOG.md | 3 +++ assets/smoke-test/.github/workflows/repository-dispatch.yml | 5 ++++- assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a0ff45..247d542f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test release orchestration now validates workflow contract before dispatch** ([#389](https://github.com/vig-os/devcontainer/issues/389)) - Add a preflight check that verifies `prepare-release.yml` and `release.yml` are resolvable on dispatch ref `dev` before downstream orchestration starts - Dispatch and polling now use explicit ref/branch context (`--ref dev` / `--branch dev`) to avoid default-branch workflow registry drift and `404 workflow not found` failures +- **Smoke-test preflight now uses gh CLI ref-compatible workflow validation** ([#392](https://github.com/vig-os/devcontainer/issues/392)) + - Update `assets/smoke-test/.github/workflows/repository-dispatch.yml` preflight checks to call `gh workflow view` with `--yaml` when `--ref` is set + - Prevent false preflight failures caused by newer GitHub CLI argument validation before `prepare-release` dispatch ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 789bbba7..b3f7cc2c 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -414,12 +414,15 @@ jobs: # Canonical cross-repo dispatch contract lives in docs/CROSS_REPO_RELEASE_GATE.md. REQUIRED_WORKFLOWS=(prepare-release.yml release.yml) for workflow_file in "${REQUIRED_WORKFLOWS[@]}"; do - if WORKFLOW_CHECK_OUTPUT="$(gh workflow view "${workflow_file}" --ref "${WORKFLOW_REF}" 2>&1)"; then + if WORKFLOW_CHECK_OUTPUT="$(gh workflow view "${workflow_file}" --ref "${WORKFLOW_REF}" --yaml 2>&1 >/dev/null)"; then echo "Workflow available on ${WORKFLOW_REF}: ${workflow_file}" else if printf '%s' "${WORKFLOW_CHECK_OUTPUT}" | grep -Eqi "404|not found"; then echo "ERROR: required workflow '${workflow_file}' is not resolvable on ref '${WORKFLOW_REF}'" echo "Dispatch contract drift detected; aborting before orchestration dispatch." + elif printf '%s' "${WORKFLOW_CHECK_OUTPUT}" | grep -Eqi -- "--yaml required|--yaml"; then + echo "ERROR: workflow preflight invocation invalid for current gh CLI (missing required --yaml with --ref)" + echo "Update preflight command to include --yaml when using --ref." else echo "ERROR: failed to validate workflow '${workflow_file}' on ref '${WORKFLOW_REF}'" echo "Validation failed due to a non-contract error (auth/permission/API/network)." diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index e6a0ff45..247d542f 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -109,6 +109,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test release orchestration now validates workflow contract before dispatch** ([#389](https://github.com/vig-os/devcontainer/issues/389)) - Add a preflight check that verifies `prepare-release.yml` and `release.yml` are resolvable on dispatch ref `dev` before downstream orchestration starts - Dispatch and polling now use explicit ref/branch context (`--ref dev` / `--branch dev`) to avoid default-branch workflow registry drift and `404 workflow not found` failures +- **Smoke-test preflight now uses gh CLI ref-compatible workflow validation** ([#392](https://github.com/vig-os/devcontainer/issues/392)) + - Update `assets/smoke-test/.github/workflows/repository-dispatch.yml` preflight checks to call `gh workflow view` with `--yaml` when `--ref` is set + - Prevent false preflight failures caused by newer GitHub CLI argument validation before `prepare-release` dispatch ### Security From 22a18d63e0de3161bd9c1d30dfaf863c92b04d39 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 10:25:07 +0100 Subject: [PATCH 125/215] test(ci): cover release rollback image and workflow hardening Refs: #394 --- tests/bats/just.bats | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 72332da3..e577cf80 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -139,3 +139,18 @@ setup() { run bash -lc "grep -Fq -- 'needs.wait-deploy-merge.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.cleanup-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-prepare-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.ready-release-pr.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success } + +@test "release workflow rollback resolves container image independently of core outputs" { + run bash -lc "grep -Fq -- 'resolve-image:' assets/workspace/.github/workflows/release.yml && grep -Fq -- 'needs: [resolve-image, core, extension, publish]' assets/workspace/.github/workflows/release.yml && grep -Fq -- 'image: ghcr.io/vig-os/devcontainer:\${{ needs.resolve-image.outputs.image-tag }}' assets/workspace/.github/workflows/release.yml" + assert_success +} + +@test "release workflows configure safe.directory in container jobs that run git" { + run bash -lc "awk '/^ validate:/{flag=1} /^ finalize:/{flag=0} flag {print}' assets/workspace/.github/workflows/release-core.yml | grep -Fq -- 'name: Fix git safe.directory' && grep -Fq -- 'name: Fix git safe.directory' assets/workspace/.github/workflows/release-publish.yml && [ \"$(grep -Fc -- 'name: Fix git safe.directory' assets/workspace/.github/workflows/sync-main-to-dev.yml)\" -ge 2 ] && grep -Fq -- 'name: Fix git safe.directory' assets/workspace/.github/workflows/release.yml" + assert_success +} + +@test "release caller and reusable workflows define explicit minimal permissions for gh operations" { + run bash -lc "awk '/^ core:/{flag=1} /^ extension:/{flag=0} flag {print}' assets/workspace/.github/workflows/release.yml | grep -Fq -- 'actions: write' && awk '/^ core:/{flag=1} /^ extension:/{flag=0} flag {print}' assets/workspace/.github/workflows/release.yml | grep -Fq -- 'pull-requests: read' && awk '/^ publish:/{flag=1} /^ rollback:/{flag=0} flag {print}' assets/workspace/.github/workflows/release.yml | grep -Fq -- 'contents: write' && awk '/^ validate:/{flag=1} /^ finalize:/{flag=0} flag {print}' assets/workspace/.github/workflows/release-core.yml | grep -Fq -- 'pull-requests: read' && awk '/^ finalize:/{flag=1} /^ test:/{flag=0} flag {print}' assets/workspace/.github/workflows/release-core.yml | grep -Fq -- 'actions: write'" + assert_success +} From 48434492a5c61cdbd9435b8b5841e368e5a8d88e Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 10:25:20 +0100 Subject: [PATCH 126/215] fix(ci): harden release workflows for smoke-test orchestration Refs: #394 --- CHANGELOG.md | 4 +++ .../.github/workflows/repository-dispatch.yml | 2 +- assets/workspace/.devcontainer/CHANGELOG.md | 4 +++ .../.github/workflows/release-core.yml | 11 +++++-- .../.github/workflows/release-publish.yml | 3 ++ .../workspace/.github/workflows/release.yml | 33 +++++++++++++++++-- .../.github/workflows/sync-main-to-dev.yml | 6 ++++ 7 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 247d542f..5431b8c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test preflight now uses gh CLI ref-compatible workflow validation** ([#392](https://github.com/vig-os/devcontainer/issues/392)) - Update `assets/smoke-test/.github/workflows/repository-dispatch.yml` preflight checks to call `gh workflow view` with `--yaml` when `--ref` is set - Prevent false preflight failures caused by newer GitHub CLI argument validation before `prepare-release` dispatch +- **Downstream release workflow templates hardened for smoke-test orchestration** ([#394](https://github.com/vig-os/devcontainer/issues/394)) + - Add missing `git config --global --add safe.directory "$GITHUB_WORKSPACE"` in containerized release and sync jobs that run git after checkout + - Decouple `release.yml` rollback container startup from `needs.core.outputs.image_tag` by resolving the image in a dedicated `resolve-image` job + - Add explicit release caller/reusable workflow permissions for `actions` and `pull-requests` operations, and update dispatch header comments to reference only current CI workflows ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index b3f7cc2c..60209c42 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -5,7 +5,7 @@ name: Repository Dispatch Listener # - Deploy the requested tag into the smoke-test repo. # - Always create a `chore/deploy-` branch and PR to `dev`. # - Create signed deploy commits via `vig-os/commit-action`. -# - CI (`ci.yml` + `ci-container.yml`) triggers on the deploy PR. +# - CI (`ci.yml`) triggers on the deploy PR. # # Dispatch payload: # - Preferred: client_payload.tag diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 247d542f..5431b8c8 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -112,6 +112,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test preflight now uses gh CLI ref-compatible workflow validation** ([#392](https://github.com/vig-os/devcontainer/issues/392)) - Update `assets/smoke-test/.github/workflows/repository-dispatch.yml` preflight checks to call `gh workflow view` with `--yaml` when `--ref` is set - Prevent false preflight failures caused by newer GitHub CLI argument validation before `prepare-release` dispatch +- **Downstream release workflow templates hardened for smoke-test orchestration** ([#394](https://github.com/vig-os/devcontainer/issues/394)) + - Add missing `git config --global --add safe.directory "$GITHUB_WORKSPACE"` in containerized release and sync jobs that run git after checkout + - Decouple `release.yml` rollback container startup from `needs.core.outputs.image_tag` by resolving the image in a dedicated `resolve-image` job + - Add explicit release caller/reusable workflow permissions for `actions` and `pull-requests` operations, and update dispatch header comments to reference only current CI workflows ### Security diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 08795a5f..22cf0eb2 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -87,6 +87,9 @@ jobs: name: Validate Release Core needs: [resolve-image] runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: read container: image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 @@ -146,6 +149,9 @@ jobs: fetch-depth: 0 token: ${{ steps.auth.outputs.token }} + - name: Fix git safe.directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Validate image accessibility env: IMAGE_TAG: ${{ needs.resolve-image.outputs.image-tag }} @@ -299,14 +305,15 @@ jobs: name: Finalize Release Core needs: validate runs-on: ubuntu-22.04 + permissions: + actions: write + contents: write container: image: ghcr.io/vig-os/devcontainer:${{ needs.validate.outputs.image_tag }} env: UV_PROJECT_ENVIRONMENT: /root/assets/workspace/.venv timeout-minutes: 15 if: ${{ inputs.dry_run != true }} - permissions: - contents: write outputs: finalize_sha: ${{ steps.finalize.outputs.finalize_sha }} diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index 41c40c1d..c5a47522 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -112,6 +112,9 @@ jobs: fetch-depth: 0 token: ${{ steps.auth.outputs.token }} + - name: Fix git safe.directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Configure git env: GIT_USER_NAME: ${{ inputs.git_user_name }} diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index 3d0a5826..679c2d4d 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -36,9 +36,32 @@ permissions: contents: read jobs: + resolve-image: + name: Resolve image tag + runs-on: ubuntu-22.04 + timeout-minutes: 2 + outputs: + image-tag: ${{ steps.resolve.outputs.image-tag }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .vig-os + .github/actions/resolve-image + sparse-checkout-cone-mode: false + + - name: Resolve container image + id: resolve + uses: ./.github/actions/resolve-image + core: name: Release Core uses: ./.github/workflows/release-core.yml + permissions: + actions: write + contents: write + pull-requests: read with: version: ${{ inputs.version }} release_kind: ${{ inputs.release-kind }} @@ -65,6 +88,8 @@ jobs: needs: [core, extension] if: ${{ inputs.dry-run != true }} uses: ./.github/workflows/release-publish.yml + permissions: + contents: write with: version: ${{ needs.core.outputs.version }} finalize_sha: ${{ needs.core.outputs.finalize_sha }} @@ -77,10 +102,10 @@ jobs: rollback: name: Rollback on Failure - needs: [core, extension, publish] + needs: [resolve-image, core, extension, publish] runs-on: ubuntu-22.04 container: - image: ghcr.io/vig-os/devcontainer:${{ needs.core.outputs.image_tag }} + image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 if: ${{ failure() && inputs.dry-run != true }} permissions: @@ -93,6 +118,10 @@ jobs: with: fetch-depth: 0 + - name: Fix git safe.directory + if: ${{ needs.core.outputs.pre_finalize_sha != '' }} + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Configure git if: ${{ needs.core.outputs.pre_finalize_sha != '' }} env: diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 3a498ff6..cdb7d043 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -74,6 +74,9 @@ jobs: with: fetch-depth: 0 + - name: Fix git safe.directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Check if dev is up to date with main id: check run: | @@ -126,6 +129,9 @@ jobs: fetch-depth: 0 token: ${{ steps.commit-app-token.outputs.token }} + - name: Fix git safe.directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Re-check if dev is still behind main id: recheck run: | From 561828db7f776caf7c63291175191b2c4b2be2fd Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 10:34:36 +0100 Subject: [PATCH 127/215] fix(ci): force bash shell in workspace container jobs Refs: #395 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ assets/workspace/.github/workflows/prepare-release.yml | 3 +++ assets/workspace/.github/workflows/release-core.yml | 9 +++++++++ assets/workspace/.github/workflows/release-publish.yml | 3 +++ assets/workspace/.github/workflows/release.yml | 3 +++ assets/workspace/.github/workflows/sync-main-to-dev.yml | 6 ++++++ 7 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5431b8c8..a4950182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add missing `git config --global --add safe.directory "$GITHUB_WORKSPACE"` in containerized release and sync jobs that run git after checkout - Decouple `release.yml` rollback container startup from `needs.core.outputs.image_tag` by resolving the image in a dedicated `resolve-image` job - Add explicit release caller/reusable workflow permissions for `actions` and `pull-requests` operations, and update dispatch header comments to reference only current CI workflows +- **Workspace containerized workflows now pin bash for run steps** ([#395](https://github.com/vig-os/devcontainer/issues/395)) + - Set `defaults.run.shell: bash` in containerized workspace release and prepare jobs so `set -euo pipefail` scripts do not execute under POSIX `sh` + - Prevent downstream smoke-test failures caused by `set: Illegal option -o pipefail` in container jobs ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 5431b8c8..a4950182 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -116,6 +116,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add missing `git config --global --add safe.directory "$GITHUB_WORKSPACE"` in containerized release and sync jobs that run git after checkout - Decouple `release.yml` rollback container startup from `needs.core.outputs.image_tag` by resolving the image in a dedicated `resolve-image` job - Add explicit release caller/reusable workflow permissions for `actions` and `pull-requests` operations, and update dispatch header comments to reference only current CI workflows +- **Workspace containerized workflows now pin bash for run steps** ([#395](https://github.com/vig-os/devcontainer/issues/395)) + - Set `defaults.run.shell: bash` in containerized workspace release and prepare jobs so `set -euo pipefail` scripts do not execute under POSIX `sh` + - Prevent downstream smoke-test failures caused by `set: Illegal option -o pipefail` in container jobs ### Security diff --git a/assets/workspace/.github/workflows/prepare-release.yml b/assets/workspace/.github/workflows/prepare-release.yml index 3a5dc5a5..a6449ca9 100644 --- a/assets/workspace/.github/workflows/prepare-release.yml +++ b/assets/workspace/.github/workflows/prepare-release.yml @@ -150,6 +150,9 @@ jobs: env: UV_PROJECT_ENVIRONMENT: /root/assets/workspace/.venv timeout-minutes: 15 + defaults: + run: + shell: bash if: ${{ inputs.dry-run != true }} permissions: contents: write diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 22cf0eb2..8326bf8e 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -93,6 +93,9 @@ jobs: container: image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 + defaults: + run: + shell: bash outputs: version: ${{ steps.vars.outputs.version }} pr_number: ${{ steps.pr.outputs.pr_number }} @@ -313,6 +316,9 @@ jobs: env: UV_PROJECT_ENVIRONMENT: /root/assets/workspace/.venv timeout-minutes: 15 + defaults: + run: + shell: bash if: ${{ inputs.dry_run != true }} outputs: finalize_sha: ${{ steps.finalize.outputs.finalize_sha }} @@ -453,6 +459,9 @@ jobs: env: UV_PROJECT_ENVIRONMENT: /root/assets/workspace/.venv timeout-minutes: 20 + defaults: + run: + shell: bash if: ${{ inputs.dry_run != true }} steps: diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index c5a47522..4c6b88cf 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -74,6 +74,9 @@ jobs: container: image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 + defaults: + run: + shell: bash permissions: contents: write outputs: diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index 679c2d4d..c9bd6c42 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -107,6 +107,9 @@ jobs: container: image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 + defaults: + run: + shell: bash if: ${{ failure() && inputs.dry-run != true }} permissions: contents: write diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index cdb7d043..061a073c 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -65,6 +65,9 @@ jobs: container: image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 5 + defaults: + run: + shell: bash outputs: up_to_date: ${{ steps.check.outputs.up_to_date }} @@ -107,6 +110,9 @@ jobs: container: image: ghcr.io/vig-os/devcontainer:${{ needs.resolve-image.outputs.image-tag }} timeout-minutes: 10 + defaults: + run: + shell: bash env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} permissions: From 79723e537b2f9b3616ae0658f907b1a826b084b4 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 11:02:03 +0100 Subject: [PATCH 128/215] fix(ci): harden rollback failure gate for workspace release Refs: #394 --- assets/workspace/.github/workflows/release.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index c9bd6c42..3bd876f5 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -110,7 +110,17 @@ jobs: defaults: run: shell: bash - if: ${{ failure() && inputs.dry-run != true }} + if: >- + ${{ + always() && + inputs.dry-run != true && + needs.resolve-image.result == 'success' && + ( + needs.core.result == 'failure' || + needs.extension.result == 'failure' || + needs.publish.result == 'failure' + ) + }} permissions: contents: write issues: write From 898d88092e4b2411b15b7aac23e846c055a0314d Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 11:02:34 +0100 Subject: [PATCH 129/215] fix(ci): remove dead --yaml preflight error branch Refs: #392 --- assets/smoke-test/.github/workflows/repository-dispatch.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 60209c42..88eac6d1 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -420,9 +420,6 @@ jobs: if printf '%s' "${WORKFLOW_CHECK_OUTPUT}" | grep -Eqi "404|not found"; then echo "ERROR: required workflow '${workflow_file}' is not resolvable on ref '${WORKFLOW_REF}'" echo "Dispatch contract drift detected; aborting before orchestration dispatch." - elif printf '%s' "${WORKFLOW_CHECK_OUTPUT}" | grep -Eqi -- "--yaml required|--yaml"; then - echo "ERROR: workflow preflight invocation invalid for current gh CLI (missing required --yaml with --ref)" - echo "Update preflight command to include --yaml when using --ref." else echo "ERROR: failed to validate workflow '${workflow_file}' on ref '${WORKFLOW_REF}'" echo "Validation failed due to a non-contract error (auth/permission/API/network)." From ea7808efbb53b85ef1e3cde96eb1fa6035e1942e Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 14:20:11 +0100 Subject: [PATCH 130/215] docs(ci): document RELEASE_APP actions permission for downstream dispatch Refs: #397 --- docs/RELEASE_CYCLE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE_CYCLE.md b/docs/RELEASE_CYCLE.md index f507c2f0..bd7d096d 100644 --- a/docs/RELEASE_CYCLE.md +++ b/docs/RELEASE_CYCLE.md @@ -524,12 +524,12 @@ Release automation relies on two GitHub Apps with different scopes: | App | Secrets | Permissions | Used by | Purpose | |-----|---------|-------------|---------|---------| -| **RELEASE_APP** | `RELEASE_APP_ID`, `RELEASE_APP_PRIVATE_KEY` | Contents read/write, Issues read/write, Pull requests read/write | `release.yml`, `prepare-release.yml`, `sync-main-to-dev.yml` | Release operations, PR creation/updates, rollback, and cross-repo validation dispatch | +| **RELEASE_APP** | `RELEASE_APP_ID`, `RELEASE_APP_PRIVATE_KEY` | Contents read/write, Issues read/write, Pull requests read/write, Actions read/write | `release.yml`, `prepare-release.yml`, `sync-main-to-dev.yml` | Release operations, PR creation/updates, rollback, and cross-repo validation dispatch | | **COMMIT_APP** | `COMMIT_APP_ID`, `COMMIT_APP_PRIVATE_KEY` | Contents read/write, Issues read, Pull requests read | `sync-issues.yml`, `sync-main-to-dev.yml` | Commits to protected branches and git ref operations | Additional requirement: - `COMMIT_APP` must be allowed in branch protection bypass rules for `dev` so sync commits can be pushed by automation. -- `RELEASE_APP` must be installed on the validation repository with Contents read permission so `release.yml` can send `repository_dispatch` for candidate and final release validation events. +- `RELEASE_APP` must be installed on the validation repository with Contents read and Actions read/write permissions so `release.yml` can send `repository_dispatch` and `repository-dispatch.yml` can trigger downstream workflow dispatch events for candidate and final release validation. #### prepare-release.yml (Release Preparation Workflow) From a5ef88613fedf1076f231a4f9ff958f487dbbcaf Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 14:21:52 +0100 Subject: [PATCH 131/215] docs(ci): add release app permission note to changelog Refs: #397 --- CHANGELOG.md | 4 ++++ assets/workspace/.devcontainer/CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4950182..8042fcba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) + - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository + - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` + - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs - Release PR body is refreshed from finalized `CHANGELOG.md` diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index a4950182..8042fcba 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -65,6 +65,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) + - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository + - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` + - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs - Release PR body is refreshed from finalized `CHANGELOG.md` From cc4dcdb15e8119c1e29fb5c190953284a53576a0 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 14:53:17 +0100 Subject: [PATCH 132/215] fix(ci): require app tokens in downstream release templates Refs: #400 --- CHANGELOG.md | 4 +++ assets/workspace/.devcontainer/CHANGELOG.md | 4 +++ .../.github/workflows/prepare-release.yml | 28 +++++++++++---- .../.github/workflows/release-core.yml | 36 ++++++++++++++++--- .../.github/workflows/release-publish.yml | 9 ++++- .../workspace/.github/workflows/release.yml | 10 +++++- .../.github/workflows/sync-issues.yml | 4 +-- docs/CROSS_REPO_RELEASE_GATE.md | 9 +++++ docs/DOWNSTREAM_RELEASE.md | 16 +++++++++ 9 files changed, 105 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8042fcba..03dd6ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Workspace containerized workflows now pin bash for run steps** ([#395](https://github.com/vig-os/devcontainer/issues/395)) - Set `defaults.run.shell: bash` in containerized workspace release and prepare jobs so `set -euo pipefail` scripts do not execute under POSIX `sh` - Prevent downstream smoke-test failures caused by `set: Illegal option -o pipefail` in container jobs +- **Downstream release templates now require explicit app tokens for write paths** ([#400](https://github.com/vig-os/devcontainer/issues/400)) + - Update `assets/workspace/.github/workflows/prepare-release.yml`, `release-core.yml`, `release-publish.yml`, `release.yml`, and `sync-issues.yml` to remove `github.token` fallback from protected write operations + - Route protected branch/ref writes through Commit App tokens and release orchestration/issue operations through Release App tokens + - Document downstream token requirements in `docs/DOWNSTREAM_RELEASE.md` and `docs/CROSS_REPO_RELEASE_GATE.md` ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 8042fcba..03dd6ac3 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -123,6 +123,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Workspace containerized workflows now pin bash for run steps** ([#395](https://github.com/vig-os/devcontainer/issues/395)) - Set `defaults.run.shell: bash` in containerized workspace release and prepare jobs so `set -euo pipefail` scripts do not execute under POSIX `sh` - Prevent downstream smoke-test failures caused by `set: Illegal option -o pipefail` in container jobs +- **Downstream release templates now require explicit app tokens for write paths** ([#400](https://github.com/vig-os/devcontainer/issues/400)) + - Update `assets/workspace/.github/workflows/prepare-release.yml`, `release-core.yml`, `release-publish.yml`, `release.yml`, and `sync-issues.yml` to remove `github.token` fallback from protected write operations + - Route protected branch/ref writes through Commit App tokens and release orchestration/issue operations through Release App tokens + - Document downstream token requirements in `docs/DOWNSTREAM_RELEASE.md` and `docs/CROSS_REPO_RELEASE_GATE.md` ### Security diff --git a/assets/workspace/.github/workflows/prepare-release.yml b/assets/workspace/.github/workflows/prepare-release.yml index a6449ca9..b316f0f8 100644 --- a/assets/workspace/.github/workflows/prepare-release.yml +++ b/assets/workspace/.github/workflows/prepare-release.yml @@ -159,6 +159,20 @@ jobs: pull-requests: write steps: + - name: Generate Commit App Token + id: commit_app_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.COMMIT_APP_ID }} + private-key: ${{ secrets.COMMIT_APP_PRIVATE_KEY }} + + - name: Generate Release App Token + id: release_app_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Checkout dev branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -171,7 +185,7 @@ jobs: - name: Capture pre-prepare dev SHA id: pre_state env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} run: | set -euo pipefail PREPARE_START_SHA=$(retry --retries 3 --backoff 5 --max-backoff 60 -- \ @@ -201,7 +215,7 @@ jobs: - name: Commit prepared CHANGELOG to dev via API uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/dev COMMIT_MESSAGE: |- @@ -214,7 +228,7 @@ jobs: - name: Create release branch from dev id: create_branch env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} RELEASE_BRANCH: ${{ needs.validate.outputs.release_branch }} run: | set -euo pipefail @@ -251,7 +265,7 @@ jobs: - name: Commit stripped CHANGELOG to release branch via API uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/${{ needs.validate.outputs.release_branch }} COMMIT_MESSAGE: |- @@ -264,7 +278,7 @@ jobs: - name: Create draft PR to main id: pr env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app_token.outputs.token }} VERSION: ${{ needs.validate.outputs.version }} RELEASE_BRANCH: ${{ needs.validate.outputs.release_branch }} CHANGELOG_CONTENT: ${{ steps.changelog.outputs.changelog }} @@ -300,7 +314,7 @@ jobs: id: rollback_prepare if: ${{ failure() }} env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} RELEASE_BRANCH: ${{ needs.validate.outputs.release_branch }} PREPARE_START_SHA: ${{ steps.pre_state.outputs.prepare_start_sha }} POST_FREEZE_DEV_SHA: ${{ steps.create_branch.outputs.dev_sha }} @@ -336,7 +350,7 @@ jobs: if: ${{ failure() && steps.rollback_prepare.outputs.changelog_rollback_needed == 'true' }} uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/dev COMMIT_MESSAGE: |- diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 8326bf8e..7642893d 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -106,11 +106,18 @@ jobs: image_tag: ${{ needs.resolve-image.outputs.image-tag }} steps: + - name: Generate release app token + id: release_app_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Resolve auth token id: auth env: PROVIDED_TOKEN: ${{ secrets.token }} - FALLBACK_TOKEN: ${{ github.token }} + FALLBACK_TOKEN: ${{ steps.release_app_token.outputs.token }} run: | set -euo pipefail if [ -n "${PROVIDED_TOKEN:-}" ]; then @@ -324,11 +331,25 @@ jobs: finalize_sha: ${{ steps.finalize.outputs.finalize_sha }} steps: + - name: Generate commit app token + id: commit_app_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.COMMIT_APP_ID }} + private-key: ${{ secrets.COMMIT_APP_PRIVATE_KEY }} + + - name: Generate release app token + id: release_app_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Resolve auth token id: auth env: PROVIDED_TOKEN: ${{ secrets.token }} - FALLBACK_TOKEN: ${{ github.token }} + FALLBACK_TOKEN: ${{ steps.release_app_token.outputs.token }} run: | set -euo pipefail if [ -n "${PROVIDED_TOKEN:-}" ]; then @@ -360,7 +381,7 @@ jobs: if: ${{ inputs.release_kind == 'final' }} uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 env: - GH_TOKEN: ${{ steps.auth.outputs.token }} + GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/release/${{ needs.validate.outputs.version }} COMMIT_MESSAGE: |- @@ -465,11 +486,18 @@ jobs: if: ${{ inputs.dry_run != true }} steps: + - name: Generate release app token + id: release_app_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Resolve auth token id: auth env: PROVIDED_TOKEN: ${{ secrets.token }} - FALLBACK_TOKEN: ${{ github.token }} + FALLBACK_TOKEN: ${{ steps.release_app_token.outputs.token }} run: | set -euo pipefail if [ -n "${PROVIDED_TOKEN:-}" ]; then diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index 4c6b88cf..60ae8dd7 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -84,11 +84,18 @@ jobs: release_url: ${{ steps.out.outputs.release_url }} steps: + - name: Generate release app token + id: release_app_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Resolve auth token id: auth env: PROVIDED_TOKEN: ${{ secrets.token }} - FALLBACK_TOKEN: ${{ github.token }} + FALLBACK_TOKEN: ${{ steps.release_app_token.outputs.token }} run: | set -euo pipefail if [ -n "${PROVIDED_TOKEN:-}" ]; then diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index 3bd876f5..05b61dea 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -125,6 +125,14 @@ jobs: contents: write issues: write steps: + - name: Generate release app token + id: release_app_token + if: ${{ needs.core.outputs.version != '' }} + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Checkout repository if: ${{ needs.core.outputs.pre_finalize_sha != '' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -176,7 +184,7 @@ jobs: env: VERSION: ${{ needs.core.outputs.version }} PR_NUMBER: ${{ needs.core.outputs.pr_number }} - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app_token.outputs.token }} run: | set -euo pipefail WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index d363fc83..f46c0343 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -102,7 +102,7 @@ jobs: if: always() continue-on-error: true env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | echo "Attempting to delete old cache to prevent save collisions..." # Wait a moment to ensure any previous cache saves have completed @@ -138,7 +138,7 @@ jobs: uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 env: # Use App token so push can bypass branch protection when App is in bypass list - GH_TOKEN: ${{ steps.generate-token.outputs.token || github.token }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/${{ github.event.inputs.target-branch || 'dev' }} COMMIT_MESSAGE: "${{ github.event.inputs.commit-msg || 'chore: sync issues and PRs' }}" diff --git a/docs/CROSS_REPO_RELEASE_GATE.md b/docs/CROSS_REPO_RELEASE_GATE.md index 8050caf0..c5fc2484 100644 --- a/docs/CROSS_REPO_RELEASE_GATE.md +++ b/docs/CROSS_REPO_RELEASE_GATE.md @@ -120,3 +120,12 @@ gh -R vig-os/devcontainer-smoke-test release view - Orchestrator logic: `.github/workflows/release.yml` - Validation receiver template: `assets/smoke-test/.github/workflows/repository-dispatch.yml` + +## Token Model for Downstream Write Paths + +For downstream workflow templates used by this gate, repositories must provide both Commit and Release app credentials. + +- Commit App token is required for protected branch writes performed by release preparation/finalization flows. +- Release App token is required for PR/release/workflow dispatch orchestration. + +Using `github.token` for protected downstream write paths is not supported by this gate contract because branch rulesets may reject direct writes without app bypass. diff --git a/docs/DOWNSTREAM_RELEASE.md b/docs/DOWNSTREAM_RELEASE.md index 8bacb083..9c08b930 100644 --- a/docs/DOWNSTREAM_RELEASE.md +++ b/docs/DOWNSTREAM_RELEASE.md @@ -35,6 +35,22 @@ The orchestrator `release.yml` passes release context directly to the called reu There is no separate contract-version handshake; compatibility is defined by the `workflow_call` input schema in each workflow file. +## Required App Secrets + +Downstream repositories are expected to provide both app credentials: + +- `COMMIT_APP_ID` +- `COMMIT_APP_PRIVATE_KEY` +- `RELEASE_APP_ID` +- `RELEASE_APP_PRIVATE_KEY` + +Template behavior relies on explicit app-token generation for release operations: + +- use **Commit App** token for protected branch/ref writes (`commit-action`, branch/tag mutation) +- use **Release App** token for release orchestration and PR/release API operations + +`github.token` is intentionally not used as a fallback for these release write paths. + ## Input Naming Convention All `workflow_call` inputs use underscores (e.g. `release_kind`, `dry_run`, `git_user_name`). The orchestrator `release.yml` translates its own `workflow_dispatch` hyphenated inputs at each call site. From 8c851ea092924b6f6f4e98255852c45b37de4585 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 15:08:10 +0100 Subject: [PATCH 133/215] fix(ci): address copilot follow-ups for release token routing Refs: #400 --- CHANGELOG.md | 2 ++ assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ assets/workspace/.github/workflows/release.yml | 9 +++++++++ assets/workspace/.github/workflows/sync-issues.yml | 2 +- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03dd6ac3..02b54e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `assets/workspace/.github/workflows/prepare-release.yml`, `release-core.yml`, `release-publish.yml`, `release.yml`, and `sync-issues.yml` to remove `github.token` fallback from protected write operations - Route protected branch/ref writes through Commit App tokens and release orchestration/issue operations through Release App tokens - Document downstream token requirements in `docs/DOWNSTREAM_RELEASE.md` and `docs/CROSS_REPO_RELEASE_GATE.md` + - Use `github.token` specifically for Actions cache deletion in `sync-issues.yml` because that API path requires explicit `actions: write` job token scope + - Use Commit App credentials for rollback checkout in `release.yml` so rollback branch/tag writes can still bypass protected refs ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 03dd6ac3..02b54e2d 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -127,6 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `assets/workspace/.github/workflows/prepare-release.yml`, `release-core.yml`, `release-publish.yml`, `release.yml`, and `sync-issues.yml` to remove `github.token` fallback from protected write operations - Route protected branch/ref writes through Commit App tokens and release orchestration/issue operations through Release App tokens - Document downstream token requirements in `docs/DOWNSTREAM_RELEASE.md` and `docs/CROSS_REPO_RELEASE_GATE.md` + - Use `github.token` specifically for Actions cache deletion in `sync-issues.yml` because that API path requires explicit `actions: write` job token scope + - Use Commit App credentials for rollback checkout in `release.yml` so rollback branch/tag writes can still bypass protected refs ### Security diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index 05b61dea..d2bb29a4 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -133,11 +133,20 @@ jobs: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Generate commit app token + id: commit_app_token + if: ${{ needs.core.outputs.pre_finalize_sha != '' }} + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.COMMIT_APP_ID }} + private-key: ${{ secrets.COMMIT_APP_PRIVATE_KEY }} + - name: Checkout repository if: ${{ needs.core.outputs.pre_finalize_sha != '' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + token: ${{ steps.commit_app_token.outputs.token }} - name: Fix git safe.directory if: ${{ needs.core.outputs.pre_finalize_sha != '' }} diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index f46c0343..f71e8fd0 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -102,7 +102,7 @@ jobs: if: always() continue-on-error: true env: - GH_TOKEN: ${{ steps.generate-token.outputs.token }} + GH_TOKEN: ${{ github.token }} run: | echo "Attempting to delete old cache to prevent save collisions..." # Wait a moment to ensure any previous cache saves have completed From 28184185689a153de811018c0190d0c7738e2b54 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 22:01:41 +0100 Subject: [PATCH 134/215] fix(ci): create sync branch via git push for CI trigger Replace GitHub Refs API branch creation with git push in sync-main-to-dev.yml so the push event triggers CI on the resulting PR. API-created branches do not emit push events, leaving sync PRs blocked by required status checks. Refs: #398 --- .../.github/workflows/sync-main-to-dev.yml | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 061a073c..6d488019 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -8,7 +8,7 @@ # check - (early exit if dev already contains all main commits) # sync - clean up stale sync branches # - trial merge to detect conflicts -# - create chore/sync-main-to-dev-- branch via API +# - create chore/sync-main-to-dev-- branch via git push # - open PR (auto-merge enabled, or labelled "merge-conflict" with # resolution instructions when conflicts exist) # @@ -211,23 +211,13 @@ jobs: - name: Create sync branch from main if: steps.existing-pr.outputs.count == '0' && steps.recheck.outputs.up_to_date != 'true' - env: - GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} run: | set -euo pipefail MAIN_SHA=$(git rev-parse origin/main) + git checkout -b "${SYNC_BRANCH}" origin/main retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh api "repos/${{ github.repository }}/git/refs" \ - -f ref="refs/heads/${SYNC_BRANCH}" \ - -f sha="${MAIN_SHA}" || { - if retry --retries 2 --backoff 5 --max-backoff 20 -- \ - gh api "repos/${{ github.repository }}/git/ref/heads/${SYNC_BRANCH}" >/dev/null 2>&1; then - echo "Sync branch already exists: ${SYNC_BRANCH}" - else - exit 1 - fi - } - echo "Sync branch ${SYNC_BRANCH} created from main at ${MAIN_SHA}" + git push origin "${SYNC_BRANCH}" + echo "Sync branch ${SYNC_BRANCH} pushed from main at ${MAIN_SHA}" - name: Create PR if: steps.existing-pr.outputs.count == '0' && steps.recheck.outputs.up_to_date != 'true' From 373e56cea02bbc49c6d013befad221117a1fdcea Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Fri, 20 Mar 2026 22:01:53 +0100 Subject: [PATCH 135/215] fix(ci): split smoke-test dispatch into two-phase orchestration Keep repository-dispatch focused on deploy and release-PR readiness, then trigger release.yml from a dedicated merged-release-PR workflow. This removes self-approval and merge-polling failures while preserving upstream failure reporting in both phases. Refs: #402, #398 --- CHANGELOG.md | 8 + .../.github/workflows/on-release-pr-merge.yml | 229 ++++++++++++++++++ .../.github/workflows/repository-dispatch.yml | 167 ++----------- assets/workspace/.devcontainer/CHANGELOG.md | 8 + 4 files changed, 261 insertions(+), 151 deletions(-) create mode 100644 assets/smoke-test/.github/workflows/on-release-pr-merge.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b54e2d..f4b44b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,12 +62,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replace premature `publish-release` behavior with full downstream orchestration: deploy-to-dev merge gate, `prepare-release.yml`, release PR readiness/approval, and `release.yml` dispatch polling - Add release-branch CHANGELOG sync so smoke-test `main` ends with the same `CHANGELOG.md` content as `vig-os/devcontainer` at the dispatched tag - Add upstream failure issue reporting with job-phase results and cleanup guidance when dispatch orchestration fails +- **Smoke-test release orchestration now runs as two phases** ([#402](https://github.com/vig-os/devcontainer/issues/402)) + - Keep `repository-dispatch.yml` focused on deploy/prepare/release-PR readiness and move release dispatch to a dedicated merged-PR workflow (`on-release-pr-merge.yml`) + - Add release-kind labeling and auto-merge enablement for release PRs, and keep upstream failure notifications in both phases ### Fixed - **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` +- **Smoke-test dispatch no longer fails on release PR self-approval** ([#402](https://github.com/vig-os/devcontainer/issues/402)) + - Remove bot self-approval from `repository-dispatch.yml` and replace with release-kind labeling plus auto-merge enablement + - Remove in-job polling for release PR merge and downstream release execution from phase 1 orchestration +- **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) + - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` so PR-related CI checks are emitted - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/smoke-test/.github/workflows/on-release-pr-merge.yml b/assets/smoke-test/.github/workflows/on-release-pr-merge.yml new file mode 100644 index 00000000..cc1d2e6e --- /dev/null +++ b/assets/smoke-test/.github/workflows/on-release-pr-merge.yml @@ -0,0 +1,229 @@ +name: Trigger release from merged release PR +# +# Purpose: +# - Continue smoke-test orchestration after the release PR is merged to main. +# - Dispatch release.yml and wait for completion. +# - Notify upstream if this second phase fails. +# +# NOTE: This workflow is part of the smoke-test template under assets/smoke-test +# and must be manually deployed/promoted in the downstream repo before use. + +on: # yamllint disable-line rule:truthy + pull_request: + types: [closed] + branches: [main] + +concurrency: + group: smoke-test-release-phase2-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + +permissions: + contents: read + +env: + WORKFLOW_REF: dev + +jobs: + validate: + name: Validate merged release PR event + if: >- + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-22.04 + timeout-minutes: 5 + outputs: + version: ${{ steps.extract.outputs.version }} + release_kind: ${{ steps.extract.outputs.release_kind }} + release_pr_url: ${{ steps.extract.outputs.release_pr_url }} + release_pr_number: ${{ steps.extract.outputs.release_pr_number }} + steps: + - name: Extract version and release kind + id: extract + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + VERSION="${HEAD_REF#release/}" + if [ "${VERSION}" = "${HEAD_REF}" ]; then + echo "ERROR: expected release/* head ref, got '${HEAD_REF}'" + exit 1 + fi + if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: invalid release version parsed from head ref '${HEAD_REF}'" + exit 1 + fi + + RELEASE_KIND="candidate" + if jq -e '.pull_request.labels[]? | select(.name=="release-kind:final")' "${GITHUB_EVENT_PATH}" >/dev/null; then + RELEASE_KIND="final" + elif jq -e '.pull_request.labels[]? | select(.name=="release-kind:candidate")' "${GITHUB_EVENT_PATH}" >/dev/null; then + RELEASE_KIND="candidate" + fi + + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "release_kind=${RELEASE_KIND}" >> "${GITHUB_OUTPUT}" + echo "release_pr_url=${PR_URL}" >> "${GITHUB_OUTPUT}" + echo "release_pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" + + trigger-release: + name: Trigger and wait for release workflow + runs-on: ubuntu-22.04 + timeout-minutes: 35 + env: + GH_REPO: ${{ github.repository }} + needs: [validate] + outputs: + before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} + steps: + - name: Generate release app token for release workflow dispatch + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Capture latest release run id + id: capture_release_before + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + run: | + set -euo pipefail + BEFORE_RUN_ID="$( + gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 + )" + echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" + + - name: Trigger release workflow + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + VERSION: ${{ needs.validate.outputs.version }} + RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} + run: | + set -euo pipefail + gh workflow run release.yml \ + --ref "${WORKFLOW_REF}" \ + -f version="${VERSION}" \ + -f release-kind="${RELEASE_KIND}" + + - name: Wait for release workflow completion + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BEFORE_RUN_ID: ${{ steps.capture_release_before.outputs.before_run_id }} + run: | + set -euo pipefail + TIMEOUT=1800 + INTERVAL=30 + ELAPSED=0 + + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + RUN_ID="$(gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then + STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" + if [ "${STATUS}" = "completed" ]; then + CONCLUSION="$(gh run view "${RUN_ID}" --json conclusion --jq '.conclusion' 2>/dev/null || echo unknown)" + if [ "${CONCLUSION}" != "success" ]; then + echo "ERROR: release workflow concluded with '${CONCLUSION}'" + exit 1 + fi + echo "release workflow completed successfully" + exit 0 + fi + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + echo "Waiting for release workflow... (${ELAPSED}s/${TIMEOUT}s)" + done + + echo "ERROR: timed out waiting for release workflow completion" + exit 1 + + summary: + name: Release phase 2 summary + runs-on: ubuntu-22.04 + timeout-minutes: 5 + needs: + - validate + - trigger-release + if: always() && needs.validate.result != 'skipped' + steps: + - name: Check release phase 2 results + run: | + echo "Release Phase 2 Results Summary" + echo "===============================" + echo "" + echo "Validate: ${{ needs.validate.result }}" + echo "Release: ${{ needs.trigger-release.result }}" + echo "Release PR: ${{ needs.validate.outputs.release_pr_url }}" + echo "" + + FAILED=false + if [ "${{ needs.validate.result }}" != "success" ]; then + echo "ERROR: validation failed" + FAILED=true + fi + if [ "${{ needs.trigger-release.result }}" != "success" ]; then + echo "ERROR: release workflow orchestration failed" + FAILED=true + fi + if [ "${FAILED}" = "true" ]; then + exit 1 + fi + + notify-failure: + name: Notify upstream on smoke-test release phase 2 failure + runs-on: ubuntu-22.04 + timeout-minutes: 5 + if: failure() && needs.validate.result != 'skipped' + needs: + - validate + - trigger-release + - summary + steps: + - name: Generate release app token for upstream issue creation + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: vig-os + repositories: devcontainer + + - name: Create upstream failure issue + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + VERSION: ${{ needs.validate.outputs.version }} + RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} + RELEASE_PR_URL: ${{ needs.validate.outputs.release_pr_url }} + run: | + set -euo pipefail + WORKFLOW_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + ISSUE_BODY="$( + cat </dev/null - echo "Synced upstream CHANGELOG to release/${BASE_VERSION}" - - name: Locate release PR id: locate_release_pr env: @@ -552,7 +517,7 @@ jobs: echo "release_pr=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" echo "release_pr_url=${PR_URL}" >> "${GITHUB_OUTPUT}" - - name: Mark release PR ready and approve + - name: Mark release PR ready env: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} @@ -562,121 +527,30 @@ jobs: if [ "${IS_DRAFT}" = "true" ]; then gh pr ready "${PR_NUMBER}" fi - gh pr review "${PR_NUMBER}" --approve - - name: Wait for release PR CI and merge + - name: Label release PR with release kind env: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} - run: | - set -euo pipefail - TIMEOUT=1800 - INTERVAL=30 - ELAPSED=0 - AUTO_MERGE_SET=false - - while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do - PR_STATE="$(gh pr view "${PR_NUMBER}" --json state --jq '.state' 2>/dev/null || echo unknown)" - if [ "${PR_STATE}" = "MERGED" ]; then - echo "Release PR merged: #${PR_NUMBER}" - exit 0 - fi - if [ "${PR_STATE}" = "CLOSED" ]; then - echo "ERROR: release PR closed without merge: #${PR_NUMBER}" - exit 1 - fi - - MERGE_STATE="$(gh pr view "${PR_NUMBER}" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null || echo unknown)" - if [ "${MERGE_STATE}" = "CLEAN" ] && [ "${AUTO_MERGE_SET}" = "false" ]; then - if gh pr merge "${PR_NUMBER}" --auto --merge; then - AUTO_MERGE_SET=true - else - echo "Warning: could not enable auto-merge yet; will retry" - fi - fi - - sleep "${INTERVAL}" - ELAPSED=$((ELAPSED + INTERVAL)) - echo "Waiting for release PR merge... (${ELAPSED}s/${TIMEOUT}s)" - done - - echo "ERROR: timed out waiting for release PR merge" - exit 1 - - trigger-release: - name: Trigger and wait for release workflow - runs-on: ubuntu-22.04 - timeout-minutes: 35 - env: - GH_REPO: ${{ github.repository }} - needs: [validate, ready-release-pr] - outputs: - before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} - steps: - - name: Generate release app token for release workflow dispatch - id: generate_release_token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - - - name: Capture latest release run id - id: capture_release_before - env: - GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - run: | - set -euo pipefail - BEFORE_RUN_ID="$( - gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 - )" - echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" - - - name: Trigger release workflow - env: - GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - BASE_VERSION: ${{ needs.validate.outputs.base_version }} RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} run: | set -euo pipefail - gh workflow run release.yml \ - --ref "${WORKFLOW_REF}" \ - -f version="${BASE_VERSION}" \ - -f release-kind="${RELEASE_KIND}" - - - name: Wait for release workflow completion + LABEL="release-kind:${RELEASE_KIND}" + gh label create "${LABEL}" --color "5319E7" \ + --description "Automated release kind label for dispatch orchestration" \ + --force >/dev/null 2>&1 || true + gh pr edit "${PR_NUMBER}" --remove-label "release-kind:candidate" >/dev/null 2>&1 || true + gh pr edit "${PR_NUMBER}" --remove-label "release-kind:final" >/dev/null 2>&1 || true + gh pr edit "${PR_NUMBER}" --add-label "${LABEL}" + + - name: Enable release PR auto-merge env: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - BEFORE_RUN_ID: ${{ steps.capture_release_before.outputs.before_run_id }} + PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} run: | set -euo pipefail - TIMEOUT=1800 - INTERVAL=30 - ELAPSED=0 - - while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do - RUN_ID="$(gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" - if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then - STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" - if [ "${STATUS}" = "completed" ]; then - CONCLUSION="$(gh run view "${RUN_ID}" --json conclusion --jq '.conclusion' 2>/dev/null || echo unknown)" - if [ "${CONCLUSION}" != "success" ]; then - echo "ERROR: release workflow concluded with '${CONCLUSION}'" - exit 1 - fi - echo "release workflow completed successfully" - exit 0 - fi - fi - - sleep "${INTERVAL}" - ELAPSED=$((ELAPSED + INTERVAL)) - echo "Waiting for release workflow... (${ELAPSED}s/${TIMEOUT}s)" - done - - echo "ERROR: timed out waiting for release workflow completion" - exit 1 + gh pr merge "${PR_NUMBER}" --auto --merge || \ + echo "Warning: could not enable auto-merge yet" summary: name: Dispatch summary @@ -689,7 +563,6 @@ jobs: - cleanup-release - trigger-prepare-release - ready-release-pr - - trigger-release if: always() steps: - name: Write source context summary @@ -725,7 +598,6 @@ jobs: echo "Cleanup: ${{ needs.cleanup-release.result }}" echo "Prepare: ${{ needs.trigger-prepare-release.result }}" echo "Release PR: ${{ needs.ready-release-pr.result }}" - echo "Release: ${{ needs.trigger-release.result }}" echo "Deploy PR: ${{ needs.deploy.outputs.pr_url }}" echo "Release PR: ${{ needs.ready-release-pr.outputs.release_pr_url }}" echo "" @@ -762,11 +634,6 @@ jobs: FAILED=true fi - if [ "${{ needs.trigger-release.result }}" != "success" ]; then - echo "ERROR: Release workflow orchestration job failed" - FAILED=true - fi - if [ "${FAILED}" = "true" ]; then echo "" echo "Dispatch orchestration failed" @@ -788,7 +655,6 @@ jobs: - cleanup-release - trigger-prepare-release - ready-release-pr - - trigger-release - summary steps: - name: Generate release app token for upstream issue creation @@ -843,7 +709,6 @@ jobs: - cleanup-release: \`${{ needs.cleanup-release.result }}\` - trigger-prepare-release: \`${{ needs.trigger-prepare-release.result }}\` - ready-release-pr: \`${{ needs.ready-release-pr.result }}\` - - trigger-release: \`${{ needs.trigger-release.result }}\` - summary: \`${{ needs.summary.result }}\` ## Manual cleanup guidance diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 02b54e2d..f4b44b2b 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -62,12 +62,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replace premature `publish-release` behavior with full downstream orchestration: deploy-to-dev merge gate, `prepare-release.yml`, release PR readiness/approval, and `release.yml` dispatch polling - Add release-branch CHANGELOG sync so smoke-test `main` ends with the same `CHANGELOG.md` content as `vig-os/devcontainer` at the dispatched tag - Add upstream failure issue reporting with job-phase results and cleanup guidance when dispatch orchestration fails +- **Smoke-test release orchestration now runs as two phases** ([#402](https://github.com/vig-os/devcontainer/issues/402)) + - Keep `repository-dispatch.yml` focused on deploy/prepare/release-PR readiness and move release dispatch to a dedicated merged-PR workflow (`on-release-pr-merge.yml`) + - Add release-kind labeling and auto-merge enablement for release PRs, and keep upstream failure notifications in both phases ### Fixed - **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` +- **Smoke-test dispatch no longer fails on release PR self-approval** ([#402](https://github.com/vig-os/devcontainer/issues/402)) + - Remove bot self-approval from `repository-dispatch.yml` and replace with release-kind labeling plus auto-merge enablement + - Remove in-job polling for release PR merge and downstream release execution from phase 1 orchestration +- **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) + - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` so PR-related CI checks are emitted - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs From 54f86b7535e02c47cee75f4bf28b13c53466e698 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sat, 21 Mar 2026 09:18:18 +0100 Subject: [PATCH 136/215] test(ci): add phase 2 tests and update phase 1 dispatch assertions Refs: #402 --- tests/bats/just.bats | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/tests/bats/just.bats b/tests/bats/just.bats index e577cf80..5ebeeca5 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -100,8 +100,8 @@ setup() { assert_success } -@test "smoke-test dispatch triggers downstream prepare and release workflows" { - run bash -lc "grep -Fq -- 'cleanup-release:' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow run prepare-release.yml --ref \"\${WORKFLOW_REF}\"' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh workflow run release.yml \\' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- '--ref \"\${WORKFLOW_REF}\" \\' assets/smoke-test/.github/workflows/repository-dispatch.yml" +@test "smoke-test dispatch triggers downstream prepare-release workflow" { + run bash -lc 'grep -Fq -- "cleanup-release:" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "gh workflow run prepare-release.yml" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } @@ -115,18 +115,18 @@ setup() { assert_success } -@test "smoke-test dispatch wait logic tracks release run after dispatch" { - run bash -lc 'grep -Fq -- "Capture latest release run id" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "gh run list --workflow release.yml --branch \"\${WORKFLOW_REF}\"" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_release_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/repository-dispatch.yml' +@test "smoke-test phase 2 wait logic tracks release run after dispatch" { + run bash -lc 'grep -Fq -- "Capture latest release run id" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "gh run list --workflow release.yml --branch \"\${WORKFLOW_REF}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_release_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' assert_success } -@test "smoke-test dispatch readies release PR and syncs upstream changelog" { - run bash -lc "grep -Fq -- 'gh pr ready' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh pr review' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'raw.githubusercontent.com/vig-os/devcontainer' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'contents/CHANGELOG.md' assets/smoke-test/.github/workflows/repository-dispatch.yml" +@test "smoke-test dispatch readies release PR with release kind label and auto-merge" { + run bash -lc 'grep -Fq -- "gh pr ready" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "release-kind:candidate" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "gh pr merge" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "--auto --merge" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } @test "smoke-test dispatch tolerates transient auto-merge enable failures" { - run bash -lc 'grep -Fq -- "could not enable auto-merge yet; will retry" assets/smoke-test/.github/workflows/repository-dispatch.yml' + run bash -lc 'grep -Fq -- "Warning: could not enable auto-merge yet" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } @@ -136,7 +136,32 @@ setup() { } @test "smoke-test dispatch summary includes release-orchestration job results" { - run bash -lc "grep -Fq -- 'needs.wait-deploy-merge.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.cleanup-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-prepare-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.ready-release-pr.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml" + run bash -lc "grep -Fq -- 'needs.wait-deploy-merge.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.cleanup-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-prepare-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.ready-release-pr.result' assets/smoke-test/.github/workflows/repository-dispatch.yml" + assert_success +} + +@test "smoke-test phase 2 triggers on merged release PR to main" { + run bash -lc 'grep -Fq -- "types: [closed]" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "branches: [main]" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "github.event.pull_request.merged == true" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "startsWith(github.event.pull_request.head.ref, '\''release/'\'')" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' + assert_success +} + +@test "smoke-test phase 2 extracts semver version from release head ref" { + run bash -lc 'grep -Fq -- "VERSION=\"\${HEAD_REF#release/}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "invalid release version parsed from head ref" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' + assert_success +} + +@test "smoke-test phase 2 fails when release-kind label is missing" { + run bash -lc 'grep -Fq -- "ERROR: missing required release-kind label" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' + assert_success +} + +@test "smoke-test phase 2 dispatches release workflow with version inputs" { + run bash -lc 'grep -Fq -- "gh workflow run release.yml \\" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "-f version=\"\${VERSION}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "-f release-kind=\"\${RELEASE_KIND}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' + assert_success +} + +@test "smoke-test phase 2 notifies upstream on failure" { + run bash -lc 'grep -Fq -- "notify-failure:" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "gh issue create \\" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "--repo vig-os/devcontainer" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' assert_success } From 5c3f58558d298bd210579fb97902eebb33af4f0f Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sat, 21 Mar 2026 09:18:56 +0100 Subject: [PATCH 137/215] fix(ci): fail on missing release-kind label in phase 2 validation Refs: #402 --- CHANGELOG.md | 1 + assets/smoke-test/.github/workflows/on-release-pr-merge.yml | 5 ++++- assets/workspace/.devcontainer/CHANGELOG.md | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b44b2b..d66d3d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch no longer fails on release PR self-approval** ([#402](https://github.com/vig-os/devcontainer/issues/402)) - Remove bot self-approval from `repository-dispatch.yml` and replace with release-kind labeling plus auto-merge enablement - Remove in-job polling for release PR merge and downstream release execution from phase 1 orchestration + - Phase 2 (`on-release-pr-merge.yml`) fails validation unless the merged release PR has `release-kind:final` or `release-kind:candidate` - **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` so PR-related CI checks are emitted diff --git a/assets/smoke-test/.github/workflows/on-release-pr-merge.yml b/assets/smoke-test/.github/workflows/on-release-pr-merge.yml index cc1d2e6e..764bb957 100644 --- a/assets/smoke-test/.github/workflows/on-release-pr-merge.yml +++ b/assets/smoke-test/.github/workflows/on-release-pr-merge.yml @@ -55,11 +55,14 @@ jobs: exit 1 fi - RELEASE_KIND="candidate" + RELEASE_KIND="" if jq -e '.pull_request.labels[]? | select(.name=="release-kind:final")' "${GITHUB_EVENT_PATH}" >/dev/null; then RELEASE_KIND="final" elif jq -e '.pull_request.labels[]? | select(.name=="release-kind:candidate")' "${GITHUB_EVENT_PATH}" >/dev/null; then RELEASE_KIND="candidate" + else + echo "ERROR: missing required release-kind label (expected 'release-kind:final' or 'release-kind:candidate')" + exit 1 fi echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index f4b44b2b..d66d3d7d 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -74,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch no longer fails on release PR self-approval** ([#402](https://github.com/vig-os/devcontainer/issues/402)) - Remove bot self-approval from `repository-dispatch.yml` and replace with release-kind labeling plus auto-merge enablement - Remove in-job polling for release PR merge and downstream release execution from phase 1 orchestration + - Phase 2 (`on-release-pr-merge.yml`) fails validation unless the merged release PR has `release-kind:final` or `release-kind:candidate` - **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` so PR-related CI checks are emitted From c8a3ad60a91a8ef3c9ac19ac94299d3e9c739159 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sat, 21 Mar 2026 13:40:12 +0100 Subject: [PATCH 138/215] docs: align 0.3.1 smoke-test notes with CHANGELOG sync removal Refs: #402 --- CHANGELOG.md | 2 +- assets/workspace/.devcontainer/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d66d3d7d..a2253bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,11 +60,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released - **Redesigned smoke-test dispatch release orchestration** ([#358](https://github.com/vig-os/devcontainer/issues/358)) - Replace premature `publish-release` behavior with full downstream orchestration: deploy-to-dev merge gate, `prepare-release.yml`, release PR readiness/approval, and `release.yml` dispatch polling - - Add release-branch CHANGELOG sync so smoke-test `main` ends with the same `CHANGELOG.md` content as `vig-os/devcontainer` at the dispatched tag - Add upstream failure issue reporting with job-phase results and cleanup guidance when dispatch orchestration fails - **Smoke-test release orchestration now runs as two phases** ([#402](https://github.com/vig-os/devcontainer/issues/402)) - Keep `repository-dispatch.yml` focused on deploy/prepare/release-PR readiness and move release dispatch to a dedicated merged-PR workflow (`on-release-pr-merge.yml`) - Add release-kind labeling and auto-merge enablement for release PRs, and keep upstream failure notifications in both phases + - Remove release-branch upstream `CHANGELOG.md` sync from `repository-dispatch.yml` (previously added in [#358](https://github.com/vig-os/devcontainer/issues/358)) ### Fixed diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index d66d3d7d..a2253bf1 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -60,11 +60,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released - **Redesigned smoke-test dispatch release orchestration** ([#358](https://github.com/vig-os/devcontainer/issues/358)) - Replace premature `publish-release` behavior with full downstream orchestration: deploy-to-dev merge gate, `prepare-release.yml`, release PR readiness/approval, and `release.yml` dispatch polling - - Add release-branch CHANGELOG sync so smoke-test `main` ends with the same `CHANGELOG.md` content as `vig-os/devcontainer` at the dispatched tag - Add upstream failure issue reporting with job-phase results and cleanup guidance when dispatch orchestration fails - **Smoke-test release orchestration now runs as two phases** ([#402](https://github.com/vig-os/devcontainer/issues/402)) - Keep `repository-dispatch.yml` focused on deploy/prepare/release-PR readiness and move release dispatch to a dedicated merged-PR workflow (`on-release-pr-merge.yml`) - Add release-kind labeling and auto-merge enablement for release PRs, and keep upstream failure notifications in both phases + - Remove release-branch upstream `CHANGELOG.md` sync from `repository-dispatch.yml` (previously added in [#358](https://github.com/vig-os/devcontainer/issues/358)) ### Fixed From ec93e79a30c9022f54841de41d73e542764c4ab9 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sat, 21 Mar 2026 15:48:45 +0100 Subject: [PATCH 139/215] fix(ci): dispatch CI after sync-main-to-dev PR creation Refs: #405 --- .github/workflows/sync-main-to-dev.yml | 13 +++++++++++++ CHANGELOG.md | 5 ++++- assets/workspace/.devcontainer/CHANGELOG.md | 5 ++++- .../.github/workflows/sync-main-to-dev.yml | 13 +++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 7fe9f280..70c7b926 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -86,6 +86,7 @@ jobs: env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} permissions: + actions: write contents: write pull-requests: write issues: write @@ -266,3 +267,15 @@ jobs: retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" + + - name: Trigger CI on sync branch + if: >- + steps.create-pr.outputs.pr_url != '' + && steps.merge-check.outputs.conflict != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh workflow run ci.yml --ref "${SYNC_BRANCH}" + echo "Dispatched CI workflow on ${SYNC_BRANCH}" diff --git a/CHANGELOG.md b/CHANGELOG.md index a2253bf1..64529cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,7 +76,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove in-job polling for release PR merge and downstream release execution from phase 1 orchestration - Phase 2 (`on-release-pr-merge.yml`) fails validation unless the merged release PR has `release-kind:final` or `release-kind:candidate` - **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) - - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` so PR-related CI checks are emitted + - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` +- **Sync-main-to-dev PRs dispatch CI after opening the sync PR** ([#405](https://github.com/vig-os/devcontainer/issues/405)) + - GitHub App push and PR creation do not start workflows that listen on `pull_request` / `push`; the sync job now calls `workflow_dispatch` on `ci.yml` with `GITHUB_TOKEN` on the sync branch + - Grant `actions: write` on the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index a2253bf1..64529cd5 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -76,7 +76,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove in-job polling for release PR merge and downstream release execution from phase 1 orchestration - Phase 2 (`on-release-pr-merge.yml`) fails validation unless the merged release PR has `release-kind:final` or `release-kind:candidate` - **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) - - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` so PR-related CI checks are emitted + - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` +- **Sync-main-to-dev PRs dispatch CI after opening the sync PR** ([#405](https://github.com/vig-os/devcontainer/issues/405)) + - GitHub App push and PR creation do not start workflows that listen on `pull_request` / `push`; the sync job now calls `workflow_dispatch` on `ci.yml` with `GITHUB_TOKEN` on the sync branch + - Grant `actions: write` on the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 6d488019..c8504759 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -116,6 +116,7 @@ jobs: env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} permissions: + actions: write contents: write pull-requests: write issues: write @@ -283,3 +284,15 @@ jobs: retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" + + - name: Trigger CI on sync branch + if: >- + steps.create-pr.outputs.pr_url != '' + && steps.merge-check.outputs.conflict != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh workflow run ci.yml --ref "${SYNC_BRANCH}" + echo "Dispatched CI workflow on ${SYNC_BRANCH}" From 5864c403d7723dd3f3cbe3b789eef97df4547485 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sat, 21 Mar 2026 16:13:55 +0100 Subject: [PATCH 140/215] fix(ci): dispatch sync PR CI before auto-merge for all PRs Refs: #405 --- .github/workflows/sync-main-to-dev.yml | 28 +++++++++---------- CHANGELOG.md | 1 + assets/workspace/.devcontainer/CHANGELOG.md | 1 + .../.github/workflows/sync-main-to-dev.yml | 28 +++++++++---------- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 70c7b926..12840765 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -9,8 +9,10 @@ # sync - clean up stale sync branches # - trial merge to detect conflicts # - create chore/sync-main-to-dev-- branch via API -# - open PR (auto-merge enabled, or labelled "merge-conflict" with -# resolution instructions when conflicts exist) +# - open PR, then workflow_dispatch CI on sync branch (before auto-merge; +# runs for conflict PRs too) +# - enable auto-merge when clean, or label "merge-conflict" with +# resolution instructions when conflicts exist # # Auth: Two GitHub App tokens: # - COMMIT_APP_* for git/ref operations (least-privilege commit identity) @@ -255,6 +257,16 @@ jobs: echo "Warning: failed to add merge-conflict label." fi + - name: Trigger CI on sync branch + if: steps.create-pr.outputs.pr_url != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh workflow run ci.yml --ref "${SYNC_BRANCH}" + echo "Dispatched CI workflow on ${SYNC_BRANCH}" + - name: Enable auto-merge if: >- steps.create-pr.outputs.pr_url != '' @@ -267,15 +279,3 @@ jobs: retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" - - - name: Trigger CI on sync branch - if: >- - steps.create-pr.outputs.pr_url != '' - && steps.merge-check.outputs.conflict != 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh workflow run ci.yml --ref "${SYNC_BRANCH}" - echo "Dispatched CI workflow on ${SYNC_BRANCH}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 64529cd5..f64f66c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Sync-main-to-dev PRs dispatch CI after opening the sync PR** ([#405](https://github.com/vig-os/devcontainer/issues/405)) - GitHub App push and PR creation do not start workflows that listen on `pull_request` / `push`; the sync job now calls `workflow_dispatch` on `ci.yml` with `GITHUB_TOKEN` on the sync branch - Grant `actions: write` on the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` + - Dispatch runs before auto-merge and whenever a sync PR is opened, including merge-conflict PRs - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 64529cd5..f64f66c7 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -80,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Sync-main-to-dev PRs dispatch CI after opening the sync PR** ([#405](https://github.com/vig-os/devcontainer/issues/405)) - GitHub App push and PR creation do not start workflows that listen on `pull_request` / `push`; the sync job now calls `workflow_dispatch` on `ci.yml` with `GITHUB_TOKEN` on the sync branch - Grant `actions: write` on the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` + - Dispatch runs before auto-merge and whenever a sync PR is opened, including merge-conflict PRs - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index c8504759..223a72fe 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -9,8 +9,10 @@ # sync - clean up stale sync branches # - trial merge to detect conflicts # - create chore/sync-main-to-dev-- branch via git push -# - open PR (auto-merge enabled, or labelled "merge-conflict" with -# resolution instructions when conflicts exist) +# - open PR, then workflow_dispatch CI on sync branch (before auto-merge; +# runs for conflict PRs too) +# - enable auto-merge when clean, or label "merge-conflict" with +# resolution instructions when conflicts exist # # Auth: Two GitHub App tokens: # - COMMIT_APP_* for git/ref operations (least-privilege commit identity) @@ -272,6 +274,16 @@ jobs: echo "Warning: failed to add merge-conflict label." fi + - name: Trigger CI on sync branch + if: steps.create-pr.outputs.pr_url != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + retry --retries 3 --backoff 5 --max-backoff 30 -- \ + gh workflow run ci.yml --ref "${SYNC_BRANCH}" + echo "Dispatched CI workflow on ${SYNC_BRANCH}" + - name: Enable auto-merge if: >- steps.create-pr.outputs.pr_url != '' @@ -284,15 +296,3 @@ jobs: retry --retries 2 --backoff 5 --max-backoff 20 -- \ gh pr merge "${PR_URL}" --auto --merge || \ echo "Warning: could not enable auto-merge (may require branch protection settings)" - - - name: Trigger CI on sync branch - if: >- - steps.create-pr.outputs.pr_url != '' - && steps.merge-check.outputs.conflict != 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh workflow run ci.yml --ref "${SYNC_BRANCH}" - echo "Dispatched CI workflow on ${SYNC_BRANCH}" From 99efd68dbec51bb268aee071318b2ae8bb495a46 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sat, 21 Mar 2026 23:49:33 +0100 Subject: [PATCH 141/215] fix(ci): remove post-PR workflow_dispatch CI trigger Refs: #405 --- .github/workflows/sync-main-to-dev.yml | 15 +-------------- CHANGELOG.md | 7 +++---- assets/workspace/.devcontainer/CHANGELOG.md | 7 +++---- .../.github/workflows/sync-main-to-dev.yml | 15 +-------------- 4 files changed, 8 insertions(+), 36 deletions(-) diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 12840765..956288fc 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -9,9 +9,7 @@ # sync - clean up stale sync branches # - trial merge to detect conflicts # - create chore/sync-main-to-dev-- branch via API -# - open PR, then workflow_dispatch CI on sync branch (before auto-merge; -# runs for conflict PRs too) -# - enable auto-merge when clean, or label "merge-conflict" with +# - open PR; enable auto-merge when clean, or label "merge-conflict" with # resolution instructions when conflicts exist # # Auth: Two GitHub App tokens: @@ -88,7 +86,6 @@ jobs: env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} permissions: - actions: write contents: write pull-requests: write issues: write @@ -257,16 +254,6 @@ jobs: echo "Warning: failed to add merge-conflict label." fi - - name: Trigger CI on sync branch - if: steps.create-pr.outputs.pr_url != '' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh workflow run ci.yml --ref "${SYNC_BRANCH}" - echo "Dispatched CI workflow on ${SYNC_BRANCH}" - - name: Enable auto-merge if: >- steps.create-pr.outputs.pr_url != '' diff --git a/CHANGELOG.md b/CHANGELOG.md index f64f66c7..756973d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,10 +77,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Phase 2 (`on-release-pr-merge.yml`) fails validation unless the merged release PR has `release-kind:final` or `release-kind:candidate` - **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` -- **Sync-main-to-dev PRs dispatch CI after opening the sync PR** ([#405](https://github.com/vig-os/devcontainer/issues/405)) - - GitHub App push and PR creation do not start workflows that listen on `pull_request` / `push`; the sync job now calls `workflow_dispatch` on `ci.yml` with `GITHUB_TOKEN` on the sync branch - - Grant `actions: write` on the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` - - Dispatch runs before auto-merge and whenever a sync PR is opened, including merge-conflict PRs +- **Sync-main-to-dev no longer dispatches CI via workflow_dispatch** ([#405](https://github.com/vig-os/devcontainer/issues/405)) + - `workflow_dispatch` runs are omitted from the PR status check rollup, so they do not satisfy branch protection on the sync PR + - Remove the post-PR `gh workflow run ci.yml` step and drop `actions: write` from the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index f64f66c7..756973d0 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -77,10 +77,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Phase 2 (`on-release-pr-merge.yml`) fails validation unless the merged release PR has `release-kind:final` or `release-kind:candidate` - **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` -- **Sync-main-to-dev PRs dispatch CI after opening the sync PR** ([#405](https://github.com/vig-os/devcontainer/issues/405)) - - GitHub App push and PR creation do not start workflows that listen on `pull_request` / `push`; the sync job now calls `workflow_dispatch` on `ci.yml` with `GITHUB_TOKEN` on the sync branch - - Grant `actions: write` on the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` - - Dispatch runs before auto-merge and whenever a sync PR is opened, including merge-conflict PRs +- **Sync-main-to-dev no longer dispatches CI via workflow_dispatch** ([#405](https://github.com/vig-os/devcontainer/issues/405)) + - `workflow_dispatch` runs are omitted from the PR status check rollup, so they do not satisfy branch protection on the sync PR + - Remove the post-PR `gh workflow run ci.yml` step and drop `actions: write` from the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index 223a72fe..c38192fc 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -9,9 +9,7 @@ # sync - clean up stale sync branches # - trial merge to detect conflicts # - create chore/sync-main-to-dev-- branch via git push -# - open PR, then workflow_dispatch CI on sync branch (before auto-merge; -# runs for conflict PRs too) -# - enable auto-merge when clean, or label "merge-conflict" with +# - open PR; enable auto-merge when clean, or label "merge-conflict" with # resolution instructions when conflicts exist # # Auth: Two GitHub App tokens: @@ -118,7 +116,6 @@ jobs: env: SYNC_BRANCH: chore/sync-main-to-dev-${{ github.run_number }}-${{ github.run_attempt }} permissions: - actions: write contents: write pull-requests: write issues: write @@ -274,16 +271,6 @@ jobs: echo "Warning: failed to add merge-conflict label." fi - - name: Trigger CI on sync branch - if: steps.create-pr.outputs.pr_url != '' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - retry --retries 3 --backoff 5 --max-backoff 30 -- \ - gh workflow run ci.yml --ref "${SYNC_BRANCH}" - echo "Dispatched CI workflow on ${SYNC_BRANCH}" - - name: Enable auto-merge if: >- steps.create-pr.outputs.pr_url != '' From 0c49fe5156ddd321bb28e9b08de2c2cbf311834a Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sun, 22 Mar 2026 13:23:20 +0100 Subject: [PATCH 142/215] fix(ci): retry setup-env uv install on transient download failure Refs: #407 --- .github/actions/setup-env/action.yml | 17 ++++++++++++++++- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index b4a6a935..5715d4b6 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -104,7 +104,7 @@ inputs: outputs: uv-version: description: 'Version of uv installed' - value: ${{ steps.setup-uv.outputs.uv-version }} + value: ${{ steps.setup-uv-retry.outputs.uv-version || steps.setup-uv.outputs.uv-version }} runs: using: composite @@ -125,6 +125,21 @@ runs: # ── uv ───────────────────────────────────────────────────────────── - name: Install uv id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 + with: + enable-cache: true + # Install a specific version of uv. + version: "0.10.0" + + - name: Wait before retrying uv install + if: steps.setup-uv.outcome == 'failure' + shell: bash + run: sleep 15 + + - name: Install uv (retry) + id: setup-uv-retry + if: steps.setup-uv.outcome == 'failure' uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 with: enable-cache: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 756973d0..d0dd75b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document downstream token requirements in `docs/DOWNSTREAM_RELEASE.md` and `docs/CROSS_REPO_RELEASE_GATE.md` - Use `github.token` specifically for Actions cache deletion in `sync-issues.yml` because that API path requires explicit `actions: write` job token scope - Use Commit App credentials for rollback checkout in `release.yml` so rollback branch/tag writes can still bypass protected refs +- **setup-env retries uv install on transient GitHub Releases download failures** ([#407](https://github.com/vig-os/devcontainer/issues/407)) + - Add `continue-on-error` plus a delayed second attempt for `astral-sh/setup-uv` in `.github/actions/setup-env/action.yml` + - Reduce flaky release publish failures when GitHub CDN returns transient HTTP errors for uv release assets ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 756973d0..d0dd75b1 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -141,6 +141,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document downstream token requirements in `docs/DOWNSTREAM_RELEASE.md` and `docs/CROSS_REPO_RELEASE_GATE.md` - Use `github.token` specifically for Actions cache deletion in `sync-issues.yml` because that API path requires explicit `actions: write` job token scope - Use Commit App credentials for rollback checkout in `release.yml` so rollback branch/tag writes can still bypass protected refs +- **setup-env retries uv install on transient GitHub Releases download failures** ([#407](https://github.com/vig-os/devcontainer/issues/407)) + - Add `continue-on-error` plus a delayed second attempt for `astral-sh/setup-uv` in `.github/actions/setup-env/action.yml` + - Reduce flaky release publish failures when GitHub CDN returns transient HTTP errors for uv release assets ### Security From c801766a837f88f83c7d85443f959cfbb9eb9d0d Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Sun, 22 Mar 2026 13:41:58 +0100 Subject: [PATCH 143/215] fix(ci): detect sync-main-to-dev conflicts with merge-tree Refs: #410 --- .github/workflows/sync-main-to-dev.yml | 19 ++++++++++++++----- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ .../.github/workflows/sync-main-to-dev.yml | 19 ++++++++++++++----- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 956288fc..653afe95 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -7,7 +7,7 @@ # Pipeline: # check - (early exit if dev already contains all main commits) # sync - clean up stale sync branches -# - trial merge to detect conflicts +# - merge-tree (in-memory) merge to detect conflicts # - create chore/sync-main-to-dev-- branch via API # - open PR; enable auto-merge when clean, or label "merge-conflict" with # resolution instructions when conflicts exist @@ -174,13 +174,22 @@ jobs: id: merge-check run: | set -euo pipefail - retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main - if git merge --no-commit --no-ff origin/main 2>/dev/null; then + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev + if merge_out="$(git merge-tree --write-tree origin/dev origin/main 2>&1)"; then echo "conflict=false" >> "$GITHUB_OUTPUT" + echo "Merge-tree check: no conflicts between origin/dev and origin/main." else - echo "conflict=true" >> "$GITHUB_OUTPUT" + merge_rc=$? + if [ "${merge_rc}" -eq 1 ]; then + echo "conflict=true" >> "$GITHUB_OUTPUT" + echo "::warning::Merge conflicts detected between origin/dev and origin/main." + echo "${merge_out}" + else + echo "::error::git merge-tree failed with exit code ${merge_rc}" + echo "${merge_out}" + exit "${merge_rc}" + fi fi - git merge --abort 2>/dev/null || true - name: Create sync branch from main if: steps.existing-pr.outputs.count == '0' && steps.recheck.outputs.up_to_date != 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index d0dd75b1..e5087355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Sync-main-to-dev no longer dispatches CI via workflow_dispatch** ([#405](https://github.com/vig-os/devcontainer/issues/405)) - `workflow_dispatch` runs are omitted from the PR status check rollup, so they do not satisfy branch protection on the sync PR - Remove the post-PR `gh workflow run ci.yml` step and drop `actions: write` from the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` +- **Sync-main-to-dev conflict detection uses merge-tree** ([#410](https://github.com/vig-os/devcontainer/issues/410)) + - Replace working-tree trial merge with `git merge-tree --write-tree` so clean merges are not mislabeled as conflicts + - Enable auto-merge when dev merges cleanly with main; print merge-tree output on conflicts; fail the step on unexpected errors - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index d0dd75b1..e5087355 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -80,6 +80,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Sync-main-to-dev no longer dispatches CI via workflow_dispatch** ([#405](https://github.com/vig-os/devcontainer/issues/405)) - `workflow_dispatch` runs are omitted from the PR status check rollup, so they do not satisfy branch protection on the sync PR - Remove the post-PR `gh workflow run ci.yml` step and drop `actions: write` from the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` +- **Sync-main-to-dev conflict detection uses merge-tree** ([#410](https://github.com/vig-os/devcontainer/issues/410)) + - Replace working-tree trial merge with `git merge-tree --write-tree` so clean merges are not mislabeled as conflicts + - Enable auto-merge when dev merges cleanly with main; print merge-tree output on conflicts; fail the step on unexpected errors - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.github/workflows/sync-main-to-dev.yml b/assets/workspace/.github/workflows/sync-main-to-dev.yml index c38192fc..37aa5af9 100644 --- a/assets/workspace/.github/workflows/sync-main-to-dev.yml +++ b/assets/workspace/.github/workflows/sync-main-to-dev.yml @@ -7,7 +7,7 @@ # Pipeline: # check - (early exit if dev already contains all main commits) # sync - clean up stale sync branches -# - trial merge to detect conflicts +# - merge-tree (in-memory) merge to detect conflicts # - create chore/sync-main-to-dev-- branch via git push # - open PR; enable auto-merge when clean, or label "merge-conflict" with # resolution instructions when conflicts exist @@ -201,13 +201,22 @@ jobs: id: merge-check run: | set -euo pipefail - retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main - if git merge --no-commit --no-ff origin/main 2>/dev/null; then + retry --retries 3 --backoff 3 --max-backoff 20 -- git fetch origin main dev + if merge_out="$(git merge-tree --write-tree origin/dev origin/main 2>&1)"; then echo "conflict=false" >> "$GITHUB_OUTPUT" + echo "Merge-tree check: no conflicts between origin/dev and origin/main." else - echo "conflict=true" >> "$GITHUB_OUTPUT" + merge_rc=$? + if [ "${merge_rc}" -eq 1 ]; then + echo "conflict=true" >> "$GITHUB_OUTPUT" + echo "::warning::Merge conflicts detected between origin/dev and origin/main." + echo "${merge_out}" + else + echo "::error::git merge-tree failed with exit code ${merge_rc}" + echo "${merge_out}" + exit "${merge_rc}" + fi fi - git merge --abort 2>/dev/null || true - name: Create sync branch from main if: steps.existing-pr.outputs.count == '0' && steps.recheck.outputs.up_to_date != 'true' From fedb8634f7ee7cfcece8540472eb14a8a11007ab Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 12:22:27 +0100 Subject: [PATCH 144/215] fix(ci): smoke-test deploy uses workspace changelog scaffold Refs: #403 --- CHANGELOG.md | 4 +++ .../.github/workflows/repository-dispatch.yml | 28 +++++++++++++------ assets/workspace/.devcontainer/CHANGELOG.md | 4 +++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5087355..d763a59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **setup-env retries uv install on transient GitHub Releases download failures** ([#407](https://github.com/vig-os/devcontainer/issues/407)) - Add `continue-on-error` plus a delayed second attempt for `astral-sh/setup-uv` in `.github/actions/setup-env/action.yml` - Reduce flaky release publish failures when GitHub CDN returns transient HTTP errors for uv release assets +- **Smoke-test deploy keeps workspace scaffold as root CHANGELOG** ([#403](https://github.com/vig-os/devcontainer/issues/403)) + - Stop overwriting `CHANGELOG.md` with a minimal stub in `assets/smoke-test/.github/workflows/repository-dispatch.yml` + - Inject the deploy line into the `## Unreleased` scaffold from `init-workspace` so downstream `prepare-release` validation matches shipped workspace layout + - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` before injecting the deploy entry so downstream `prepare-release` can run ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 40265723..d0a8bd55 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -229,15 +229,25 @@ jobs: echo "ERROR: .devcontainer/CHANGELOG.md is not readable after ownership repair" exit 1 fi - cat > "CHANGELOG.md" < to ## Unreleased for prepare-release" + sed -i '0,/^## \[[^]]*\] - /s/^## \[[^]]*\] - .*/## Unreleased/' CHANGELOG.md + else + echo "ERROR: CHANGELOG.md first section is neither ## Unreleased nor ## [version] - : ${FIRST_CHANGELOG_SECTION:-}" + exit 1 + fi + sed -i '/^### Changed$/a\- Deploy devcontainer '"${TAG}" CHANGELOG.md - name: Prepare deploy branch and metadata id: prepare_branch diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index e5087355..d763a59d 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -147,6 +147,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **setup-env retries uv install on transient GitHub Releases download failures** ([#407](https://github.com/vig-os/devcontainer/issues/407)) - Add `continue-on-error` plus a delayed second attempt for `astral-sh/setup-uv` in `.github/actions/setup-env/action.yml` - Reduce flaky release publish failures when GitHub CDN returns transient HTTP errors for uv release assets +- **Smoke-test deploy keeps workspace scaffold as root CHANGELOG** ([#403](https://github.com/vig-os/devcontainer/issues/403)) + - Stop overwriting `CHANGELOG.md` with a minimal stub in `assets/smoke-test/.github/workflows/repository-dispatch.yml` + - Inject the deploy line into the `## Unreleased` scaffold from `init-workspace` so downstream `prepare-release` validation matches shipped workspace layout + - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` before injecting the deploy entry so downstream `prepare-release` can run ### Security From 71ee2260eee33cbb4f37a17fbb2c1d8ce611c96f Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 12:45:27 +0100 Subject: [PATCH 145/215] fix(ci): smoke-test deploy changelog chown and first Changed inject Extend NEEDS_CHOWN when root CHANGELOG.md exists but is unreadable. Inject deploy line only after the first ### Changed via GNU sed range. Update BATS regression for workspace changelog scaffold. Refs: #403 --- assets/smoke-test/.github/workflows/repository-dispatch.yml | 5 ++++- tests/bats/just.bats | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index d0a8bd55..e5bccbc4 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -211,6 +211,9 @@ jobs: if [ -e "CHANGELOG.md" ] && [ ! -w "CHANGELOG.md" ]; then NEEDS_CHOWN=true fi + if [ -e "CHANGELOG.md" ] && [ ! -r "CHANGELOG.md" ]; then + NEEDS_CHOWN=true + fi if [ "${NEEDS_CHOWN}" = "true" ]; then OWNER_UID_GID="$(id -u):$(id -g)" @@ -247,7 +250,7 @@ jobs: echo "ERROR: CHANGELOG.md first section is neither ## Unreleased nor ## [version] - : ${FIRST_CHANGELOG_SECTION:-}" exit 1 fi - sed -i '/^### Changed$/a\- Deploy devcontainer '"${TAG}" CHANGELOG.md + sed -i '0,/^### Changed$/s/^### Changed$/### Changed\n- Deploy devcontainer '"${TAG}"'/' CHANGELOG.md - name: Prepare deploy branch and metadata id: prepare_branch diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 5ebeeca5..ed5f1cc5 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -75,8 +75,8 @@ setup() { assert_success } -@test "smoke-test dispatch generates minimal changelog for prepare-release freeze" { - run bash -lc 'grep -Fq -- "cat > \"CHANGELOG.md\" < Date: Mon, 23 Mar 2026 12:48:27 +0100 Subject: [PATCH 146/215] fix(ci): stop injecting deploy line into smoke-test CHANGELOG Smoke-test deploy keeps the workspace scaffold only; remap top version header when needed for prepare-release. Sync changelog copy and BATS. Refs: #403 --- CHANGELOG.md | 4 ++-- assets/smoke-test/.github/workflows/repository-dispatch.yml | 1 - assets/workspace/.devcontainer/CHANGELOG.md | 4 ++-- tests/bats/just.bats | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d763a59d..e1153785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,8 +149,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reduce flaky release publish failures when GitHub CDN returns transient HTTP errors for uv release assets - **Smoke-test deploy keeps workspace scaffold as root CHANGELOG** ([#403](https://github.com/vig-os/devcontainer/issues/403)) - Stop overwriting `CHANGELOG.md` with a minimal stub in `assets/smoke-test/.github/workflows/repository-dispatch.yml` - - Inject the deploy line into the `## Unreleased` scaffold from `init-workspace` so downstream `prepare-release` validation matches shipped workspace layout - - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` before injecting the deploy entry so downstream `prepare-release` can run + - Require the workspace `CHANGELOG.md` from `init-workspace` so downstream `prepare-release` validation matches shipped layout + - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` so downstream `prepare-release` can run ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index e5bccbc4..90c94125 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -250,7 +250,6 @@ jobs: echo "ERROR: CHANGELOG.md first section is neither ## Unreleased nor ## [version] - : ${FIRST_CHANGELOG_SECTION:-}" exit 1 fi - sed -i '0,/^### Changed$/s/^### Changed$/### Changed\n- Deploy devcontainer '"${TAG}"'/' CHANGELOG.md - name: Prepare deploy branch and metadata id: prepare_branch diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index d763a59d..e1153785 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -149,8 +149,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reduce flaky release publish failures when GitHub CDN returns transient HTTP errors for uv release assets - **Smoke-test deploy keeps workspace scaffold as root CHANGELOG** ([#403](https://github.com/vig-os/devcontainer/issues/403)) - Stop overwriting `CHANGELOG.md` with a minimal stub in `assets/smoke-test/.github/workflows/repository-dispatch.yml` - - Inject the deploy line into the `## Unreleased` scaffold from `init-workspace` so downstream `prepare-release` validation matches shipped workspace layout - - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` before injecting the deploy entry so downstream `prepare-release` can run + - Require the workspace `CHANGELOG.md` from `init-workspace` so downstream `prepare-release` validation matches shipped layout + - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` so downstream `prepare-release` can run ### Security diff --git a/tests/bats/just.bats b/tests/bats/just.bats index ed5f1cc5..731fe1d8 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -76,7 +76,7 @@ setup() { } @test "smoke-test dispatch normalizes workspace changelog for prepare-release freeze" { - run bash -lc 'grep -Fq -- "expected CHANGELOG.md after install (workspace scaffold)" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "FIRST_CHANGELOG_SECTION" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "## Unreleased" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "Deploy devcontainer" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "${TAG}" assets/smoke-test/.github/workflows/repository-dispatch.yml' + run bash -lc 'grep -Fq -- "expected CHANGELOG.md after install (workspace scaffold)" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "FIRST_CHANGELOG_SECTION" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "## Unreleased" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } From 6b7e03948e7a6be78434375f332e237ff4234e4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:28:48 +0000 Subject: [PATCH 147/215] ci(ci): bump the actions-minor-patch group with 4 updates Refs: #414 --- .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/security-scan.yml | 2 +- .github/workflows/sync-issues.yml | 4 ++-- assets/workspace/.github/workflows/codeql.yml | 4 ++-- assets/workspace/.github/workflows/scorecard.yml | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9201bbf1..825acc6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,7 +313,7 @@ jobs: - name: Upload SARIF to GitHub Security if: always() - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: sarif_file: trivy-results.sarif category: 'container-image' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 71de891f..3ee84be2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,11 +48,11 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Initialize CodeQL - uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: languages: ${{ matrix.language }} - name: Run CodeQL analysis - uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e816f6e6..7484a041 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -990,7 +990,7 @@ jobs: - name: Generate SBOM (attempt 1) id: sbom_generate continue-on-error: true - uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: image: ghcr.io/vig-os/devcontainer:${{ needs.validate.outputs.publish_version }} artifact-name: sbom-${{ needs.validate.outputs.publish_version }}.spdx.json @@ -1003,7 +1003,7 @@ jobs: - name: Generate SBOM (retry) if: steps.sbom_generate.outcome == 'failure' - uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: image: ghcr.io/vig-os/devcontainer:${{ needs.validate.outputs.publish_version }} artifact-name: sbom-${{ needs.validate.outputs.publish_version }}.spdx.json diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 042bacde..f1524863 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -44,7 +44,7 @@ jobs: publish_results: true - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: sarif_file: results.sarif category: 'scorecard' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index b2bf39fd..11afb43e 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -129,7 +129,7 @@ jobs: retention-days: 90 - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: sarif_file: trivy-results.sarif category: 'container-image-scheduled' diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 80cb4547..2379242b 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -71,7 +71,7 @@ jobs: - name: Restore sync state (last synced timestamp) id: restore-state - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: .sync-state key: sync-issues-state-${{ github.repository }} @@ -126,7 +126,7 @@ jobs: - name: Save sync state if: always() - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: .sync-state key: sync-issues-state-${{ github.repository }} diff --git a/assets/workspace/.github/workflows/codeql.yml b/assets/workspace/.github/workflows/codeql.yml index 71de891f..3ee84be2 100644 --- a/assets/workspace/.github/workflows/codeql.yml +++ b/assets/workspace/.github/workflows/codeql.yml @@ -48,11 +48,11 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Initialize CodeQL - uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: languages: ${{ matrix.language }} - name: Run CodeQL analysis - uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: category: '/language:${{ matrix.language }}' diff --git a/assets/workspace/.github/workflows/scorecard.yml b/assets/workspace/.github/workflows/scorecard.yml index 042bacde..f1524863 100644 --- a/assets/workspace/.github/workflows/scorecard.yml +++ b/assets/workspace/.github/workflows/scorecard.yml @@ -44,7 +44,7 @@ jobs: publish_results: true - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 with: sarif_file: results.sarif category: 'scorecard' From 31dde788fed26893549812212a4fac2294790d76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:27:04 +0000 Subject: [PATCH 148/215] build(deps): bump @devcontainers/cli from 0.84.0 to 0.84.1 Bumps [@devcontainers/cli](https://github.com/devcontainers/cli) from 0.84.0 to 0.84.1. - [Changelog](https://github.com/devcontainers/cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/devcontainers/cli/compare/v0.84.0...v0.84.1) --- updated-dependencies: - dependency-name: "@devcontainers/cli" dependency-version: 0.84.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index a0587d87..cd533327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "devcontainer-ci-deps", "dependencies": { - "@devcontainers/cli": "0.84.0", + "@devcontainers/cli": "0.84.1", "bats": "1.13.0", "bats-assert": "github:bats-core/bats-assert#v2.2.4", "bats-file": "github:bats-core/bats-file#v0.4.0", @@ -14,9 +14,9 @@ } }, "node_modules/@devcontainers/cli": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@devcontainers/cli/-/cli-0.84.0.tgz", - "integrity": "sha512-zAG9Kvj8qH6bAvReYTO5ZtDUHNr6OEsUqXxK1L1856XZN6c2RVV7aSAp/qIADGqqe0poqPr+ighFlvui2CH2LQ==", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@devcontainers/cli/-/cli-0.84.1.tgz", + "integrity": "sha512-r+JR/4R8lznPQNwLyHPIzHJ1mj3p2l5lGyHeq2FetEfpe6s6BVLE9mFl7MxQI4wKNqfWCIO7DSokoCWRlzQSIg==", "license": "MIT", "bin": { "devcontainer": "devcontainer.js" diff --git a/package.json b/package.json index 2f443420..13a17178 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "description": "CI-only npm dependencies tracked by Dependabot", "dependencies": { - "@devcontainers/cli": "0.84.0", + "@devcontainers/cli": "0.84.1", "bats": "1.13.0", "bats-support": "github:bats-core/bats-support#v0.3.0", "bats-assert": "github:bats-core/bats-assert#v2.2.4", From 21e58769b2efcf8c76e199c366c5ff3b81482726 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 13:03:49 +0100 Subject: [PATCH 149/215] docs: record dependabot bumps for 0.3.1 Refs: #413, #414 --- CHANGELOG.md | 5 +++++ assets/workspace/.devcontainer/CHANGELOG.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1153785..f5be45f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Keep `repository-dispatch.yml` focused on deploy/prepare/release-PR readiness and move release dispatch to a dedicated merged-PR workflow (`on-release-pr-merge.yml`) - Add release-kind labeling and auto-merge enablement for release PRs, and keep upstream failure notifications in both phases - Remove release-branch upstream `CHANGELOG.md` sync from `repository-dispatch.yml` (previously added in [#358](https://github.com/vig-os/devcontainer/issues/358)) +- **Dependabot dependency update batch** ([#414](https://github.com/vig-os/devcontainer/pull/414)) + - Bump `github/codeql-action` from `4.32.6` to `4.34.1` and `anchore/sbom-action` from `0.23.1` to `0.24.0` + - Bump `actions/cache` restore/save pins from `5.0.3` to `5.0.4` in `sync-issues.yml` +- **Dependabot dependency update batch** ([#413](https://github.com/vig-os/devcontainer/pull/413)) + - Bump `@devcontainers/cli` from `0.84.0` to `0.84.1` ### Fixed diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index e1153785..f5be45f1 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -65,6 +65,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Keep `repository-dispatch.yml` focused on deploy/prepare/release-PR readiness and move release dispatch to a dedicated merged-PR workflow (`on-release-pr-merge.yml`) - Add release-kind labeling and auto-merge enablement for release PRs, and keep upstream failure notifications in both phases - Remove release-branch upstream `CHANGELOG.md` sync from `repository-dispatch.yml` (previously added in [#358](https://github.com/vig-os/devcontainer/issues/358)) +- **Dependabot dependency update batch** ([#414](https://github.com/vig-os/devcontainer/pull/414)) + - Bump `github/codeql-action` from `4.32.6` to `4.34.1` and `anchore/sbom-action` from `0.23.1` to `0.24.0` + - Bump `actions/cache` restore/save pins from `5.0.3` to `5.0.4` in `sync-issues.yml` +- **Dependabot dependency update batch** ([#413](https://github.com/vig-os/devcontainer/pull/413)) + - Bump `@devcontainers/cli` from `0.84.0` to `0.84.1` ### Fixed From 8645f3ae5b03809cf2b37550f1ff82808fb7fcb8 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 16:19:35 +0100 Subject: [PATCH 150/215] test(vigutils): add failing specs for prepare-changelog unprepare Wire CLI subcommand and stub implementation; tests fail until unprepare is implemented. Refs: #417 --- .../src/vig_utils/prepare_changelog.py | 29 ++++ .../vig-utils/tests/test_prepare_changelog.py | 143 ++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/packages/vig-utils/src/vig_utils/prepare_changelog.py b/packages/vig-utils/src/vig_utils/prepare_changelog.py index 403c6ee5..a6dcdec2 100644 --- a/packages/vig-utils/src/vig_utils/prepare_changelog.py +++ b/packages/vig-utils/src/vig_utils/prepare_changelog.py @@ -175,6 +175,11 @@ def reset_unreleased(filepath="CHANGELOG.md"): raise ValueError("Could not find appropriate location for Unreleased section") +def unprepare_changelog(filepath="CHANGELOG.md"): + """Rename top version section to ## Unreleased (implemented in next commit).""" + raise NotImplementedError + + def prepare_changelog(version, filepath="CHANGELOG.md"): """ Prepare CHANGELOG for release. @@ -262,6 +267,14 @@ def cmd_reset(args): print("✓ Created fresh empty section for next release") +def cmd_unprepare(args): + """Handle unprepare command.""" + if unprepare_changelog(args.file): + print(f"✓ Renamed top version section to ## Unreleased in {args.file}") + else: + print(f"✓ Top section already ## Unreleased in {args.file} (no changes)") + + def finalize_release_date(version, release_date, filepath="CHANGELOG.md"): """ Replace TBD date with actual release date for a version. @@ -332,6 +345,9 @@ def main(): # Reset Unreleased section after release merge %(prog)s reset + + # Rename top ## [version] - … to ## Unreleased (smoke-test deploy sync) + %(prog)s unprepare """, ) @@ -385,6 +401,19 @@ def main(): ) reset_parser.set_defaults(func=cmd_reset) + # unprepare command + unprepare_parser = subparsers.add_parser( + "unprepare", + help="Rename first ## [semver] - … heading to ## Unreleased", + ) + unprepare_parser.add_argument( + "file", + nargs="?", + default="CHANGELOG.md", + help="Path to CHANGELOG file (default: CHANGELOG.md)", + ) + unprepare_parser.set_defaults(func=cmd_unprepare) + # finalize command finalize_parser = subparsers.add_parser( "finalize", diff --git a/packages/vig-utils/tests/test_prepare_changelog.py b/packages/vig-utils/tests/test_prepare_changelog.py index 19b5ec85..3b2fda57 100644 --- a/packages/vig-utils/tests/test_prepare_changelog.py +++ b/packages/vig-utils/tests/test_prepare_changelog.py @@ -10,6 +10,7 @@ Tests are organized by function under test, from low-level helpers up to the CLI layer. """ +import re import shutil import subprocess from unittest.mock import patch @@ -20,6 +21,7 @@ cmd_finalize, cmd_prepare, cmd_reset, + cmd_unprepare, cmd_validate, create_new_changelog, extract_unreleased_content, @@ -27,6 +29,7 @@ main, prepare_changelog, reset_unreleased, + unprepare_changelog, validate_changelog, ) @@ -872,6 +875,115 @@ def test_raises_when_no_version_heading(self, tmp_path): reset_unreleased(str(f)) +# ═════════════════════════════════════════════════════════════════════════════ +# unprepare_changelog +# ═════════════════════════════════════════════════════════════════════════════ + +TOP_VERSION_TBD_THEN_OLDER = """\ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - TBD + +### Added + +- **Feature** ([#1](https://example.com/1)) + +## [0.9.0] - 2026-01-01 + +### Added + +- Prior +""" + + +class TestUnprepareChangelog: + """Unit tests for unprepare_changelog().""" + + def test_renames_tbd_header(self, tmp_path): + """Top ## [semver] - TBD becomes ## Unreleased; body preserved.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + assert unprepare_changelog(str(f)) is True + text = f.read_text() + assert text.startswith("# Changelog") + first_h2 = re.search(r"^## .+$", text, re.MULTILINE) + assert first_h2 is not None + assert first_h2.group(0) == "## Unreleased" + assert "## [1.0.0] - TBD" not in text + assert "**Feature**" in text + assert "## [0.9.0] - 2026-01-01" in text + + def test_renames_dated_header(self, tmp_path): + """Top ## [semver] - YYYY-MM-DD becomes ## Unreleased.""" + body = """\ +# Changelog + +## [2.0.0] - 2026-03-23 + +### Fixed + +- Bug + +""" + f = tmp_path / "CHANGELOG.md" + f.write_text(body) + assert unprepare_changelog(str(f)) is True + assert f.read_text().split("\n")[2] == "## Unreleased" + assert "- Bug" in f.read_text() + + def test_noop_when_already_unreleased(self, tmp_path): + """Returns False and leaves file unchanged.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(BASIC_CHANGELOG) + before = f.read_text() + assert unprepare_changelog(str(f)) is False + assert f.read_text() == before + + def test_raises_no_heading(self, tmp_path): + """No ## line raises ValueError.""" + f = tmp_path / "CHANGELOG.md" + f.write_text("# Title only\n\nNo section.\n") + with pytest.raises(ValueError, match="No top-level"): + unprepare_changelog(str(f)) + + def test_raises_unexpected_heading(self, tmp_path): + """Non-version first ## heading raises.""" + f = tmp_path / "CHANGELOG.md" + f.write_text("# C\n\n## Random\n\n- x\n") + with pytest.raises(ValueError, match="Unexpected first"): + unprepare_changelog(str(f)) + + def test_raises_missing_file(self, tmp_path): + with pytest.raises(FileNotFoundError, match="CHANGELOG not found"): + unprepare_changelog(str(tmp_path / "missing.md")) + + +class TestCmdUnprepare: + """Tests for cmd_unprepare handler.""" + + def _make_args(self, filepath): + from argparse import Namespace + + return Namespace(file=filepath) + + def test_output_when_modified(self, tmp_path, capsys): + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + cmd_unprepare(self._make_args(str(f))) + out = capsys.readouterr().out + assert "Renamed" in out + assert "## Unreleased" in f.read_text() + + def test_output_when_noop(self, tmp_path, capsys): + f = tmp_path / "CHANGELOG.md" + f.write_text(BASIC_CHANGELOG) + cmd_unprepare(self._make_args(str(f))) + out = capsys.readouterr().out + assert "no changes" in out.lower() or "already" in out.lower() + + # ═════════════════════════════════════════════════════════════════════════════ # finalize_release_date # ═════════════════════════════════════════════════════════════════════════════ @@ -1210,6 +1322,16 @@ def test_finalize_via_main(self, tmp_path): main() assert "## [1.0.0] - 2026-02-11" in f.read_text() + def test_unprepare_via_main(self, tmp_path): + """main() with 'unprepare' should rename top version heading.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + with patch("sys.argv", ["prog", "unprepare", str(f)]): + main() + first = re.search(r"^## .+$", f.read_text(), re.MULTILINE) + assert first is not None + assert first.group(0) == "## Unreleased" + def test_main_catches_exceptions(self, tmp_path): """main() should convert exceptions to stderr + exit(1).""" with ( @@ -1324,3 +1446,24 @@ def test_finalize_version_not_found_e2e(self, tmp_path): f.write_text(CHANGELOG_WITH_TBD) result = self._run("finalize", "9.9.9", "2026-02-11", str(f)) assert result.returncode != 0 + + # ── unprepare ───────────────────────────────────────────────────────── + + def test_unprepare_e2e(self, tmp_path): + """unprepare via subprocess renames top version heading.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(TOP_VERSION_TBD_THEN_OLDER) + result = self._run("unprepare", str(f)) + assert result.returncode == 0 + first = re.search(r"^## .+$", f.read_text(), re.MULTILINE) + assert first is not None + assert first.group(0) == "## Unreleased" + + def test_unprepare_noop_e2e(self, tmp_path): + """unprepare leaves Unreleased changelog unchanged.""" + f = tmp_path / "CHANGELOG.md" + f.write_text(BASIC_CHANGELOG) + before = f.read_text() + result = self._run("unprepare", str(f)) + assert result.returncode == 0 + assert f.read_text() == before From c0663720a4a95725a9adaa474c89e8010d579c94 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 16:19:56 +0100 Subject: [PATCH 151/215] feat(vigutils): implement prepare-changelog unprepare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename first ## [semver] - … heading to ## Unreleased for smoke-test sync. Refs: #417 --- .../src/vig_utils/prepare_changelog.py | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/vig-utils/src/vig_utils/prepare_changelog.py b/packages/vig-utils/src/vig_utils/prepare_changelog.py index a6dcdec2..ae4dd8b3 100644 --- a/packages/vig-utils/src/vig_utils/prepare_changelog.py +++ b/packages/vig-utils/src/vig_utils/prepare_changelog.py @@ -176,8 +176,49 @@ def reset_unreleased(filepath="CHANGELOG.md"): def unprepare_changelog(filepath="CHANGELOG.md"): - """Rename top version section to ## Unreleased (implemented in next commit).""" - raise NotImplementedError + """ + Rename the first top-level version section to ## Unreleased (inverse of prepare). + + Used when the workspace CHANGELOG was replaced by a scaffold but the canonical + entries live under ``## [X.Y.Z] - …`` (e.g. copied from ``.devcontainer/CHANGELOG.md``). + + - If the first ``## `` heading is already ``## Unreleased``, no-op. + - If it matches ``## [MAJOR.MINOR.PATCH] - …`` (semver + suffix), replace with + ``## Unreleased``. + - Otherwise raises ValueError. + + Args: + filepath: Path to CHANGELOG.md + + Returns: + True if the file was modified, False if already ``## Unreleased``. + """ + path = Path(filepath) + if not path.exists(): + raise FileNotFoundError(f"CHANGELOG not found: {filepath}") + + content = path.read_text() + match = re.search(r"^## .+$", content, re.MULTILINE) + if not match: + raise ValueError("No top-level ## heading found in CHANGELOG") + + line = match.group(0).rstrip("\r\n") + if line == "## Unreleased": + return False + + # Match ## [X.Y.Z] - TBD or ## [X.Y.Z] - YYYY-MM-DD (same semver rule as prepare) + version_heading = re.compile( + r"^## \[(\d+\.\d+\.\d+)\] - .+$", + ) + if not version_heading.match(line): + raise ValueError( + f"Unexpected first CHANGELOG section heading: {line!r} " + "(expected ## Unreleased or ## [semver] - …)" + ) + + new_content = content[: match.start()] + "## Unreleased" + content[match.end() :] + path.write_text(new_content) + return True def prepare_changelog(version, filepath="CHANGELOG.md"): From 0376023834c231526c3ce0293c1f492a3fae11bf Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 16:20:12 +0100 Subject: [PATCH 152/215] fix(ci): sync smoke-test workspace CHANGELOG via prepare-changelog unprepare Smoke-test install copies .devcontainer/CHANGELOG.md to workspace root and runs unprepare so prepare-release validation sees Unreleased entries. Refs: #417 --- assets/init-workspace.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/assets/init-workspace.sh b/assets/init-workspace.sh index a4d53092..f854566f 100755 --- a/assets/init-workspace.sh +++ b/assets/init-workspace.sh @@ -228,6 +228,18 @@ if [[ "$SMOKE_TEST" == "true" ]]; then else echo "Warning: Smoke-test directory not found at $SMOKE_TEST_DIR" >&2 fi + + # Workspace scaffold CHANGELOG is empty; copy devcontainer changelog and + # rename top ## [version] - … to ## Unreleased for downstream prepare-release. + if [[ -f "$WORKSPACE_DIR/.devcontainer/CHANGELOG.md" ]]; then + echo "Syncing workspace CHANGELOG from .devcontainer/CHANGELOG.md (smoke-test)..." + cp "$WORKSPACE_DIR/.devcontainer/CHANGELOG.md" "$WORKSPACE_DIR/CHANGELOG.md" + if ! command -v prepare-changelog >/dev/null 2>&1; then + echo "ERROR: prepare-changelog not found (required for smoke-test CHANGELOG sync)" >&2 + exit 1 + fi + prepare-changelog unprepare "$WORKSPACE_DIR/CHANGELOG.md" + fi else # Build exclude list for preserved files that already exist EXCLUDE_ARGS=() From d83108929d3a7d950ce9fa255c64e95dff200792 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 16:20:23 +0100 Subject: [PATCH 153/215] fix(ci): remove redundant changelog remap from smoke-test dispatch Remap is handled in init-workspace.sh during smoke-test install. Refs: #417 --- .../.github/workflows/repository-dispatch.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 90c94125..fe96f3ac 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -240,16 +240,6 @@ jobs: echo "ERROR: CHANGELOG.md is not readable after ownership repair" exit 1 fi - FIRST_CHANGELOG_SECTION="$(awk '/^## / { print; exit }' CHANGELOG.md)" - if [ "${FIRST_CHANGELOG_SECTION}" = "## Unreleased" ]; then - : - elif printf '%s\n' "${FIRST_CHANGELOG_SECTION}" | grep -Eq '^## \[[^]]+\] - '; then - echo "Remapping top ## [version] - to ## Unreleased for prepare-release" - sed -i '0,/^## \[[^]]*\] - /s/^## \[[^]]*\] - .*/## Unreleased/' CHANGELOG.md - else - echo "ERROR: CHANGELOG.md first section is neither ## Unreleased nor ## [version] - : ${FIRST_CHANGELOG_SECTION:-}" - exit 1 - fi - name: Prepare deploy branch and metadata id: prepare_branch From d81067bf161910171708eb6f23c5190a787ee4d0 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 16:22:02 +0100 Subject: [PATCH 154/215] docs: add CHANGELOG entry for smoke-test prepare-release fix Refs: #417 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5be45f1..34632589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Smoke-test deploy restores workspace CHANGELOG for prepare-release** ([#417](https://github.com/vig-os/devcontainer/issues/417)) + - Add `prepare-changelog unprepare` to rename the top `## [semver] - …` heading to `## Unreleased` + - `init-workspace.sh --smoke-test` copies `.devcontainer/CHANGELOG.md` into workspace `CHANGELOG.md` and runs unprepare; remove duplicate remap from smoke-test dispatch workflow - **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index f5be45f1..34632589 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -73,6 +73,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Smoke-test deploy restores workspace CHANGELOG for prepare-release** ([#417](https://github.com/vig-os/devcontainer/issues/417)) + - Add `prepare-changelog unprepare` to rename the top `## [semver] - …` heading to `## Unreleased` + - `init-workspace.sh --smoke-test` copies `.devcontainer/CHANGELOG.md` into workspace `CHANGELOG.md` and runs unprepare; remove duplicate remap from smoke-test dispatch workflow - **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` From 29ae816b750390900b8b9ddeed87d6ab00cc8beb Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 16:22:20 +0100 Subject: [PATCH 155/215] docs(vigutils): document prepare-changelog unprepare Refs: #417 --- packages/vig-utils/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vig-utils/README.md b/packages/vig-utils/README.md index 32ef6437..52928e1d 100644 --- a/packages/vig-utils/README.md +++ b/packages/vig-utils/README.md @@ -25,7 +25,7 @@ uv run check-action-pins --help |---|---|---| | `validate-commit-msg` | Python | Enforce commit message standard | | `check-action-pins` | Python | Ensure GitHub Actions are SHA pinned | -| `prepare-changelog` | Python | Validate/prepare/finalize/reset changelog | +| `prepare-changelog` | Python | Validate/prepare/finalize/reset/unprepare changelog | | `gh-issues` | Python | Rich issue/PR dashboard via `gh` | | `prepare-commit-msg-strip-trailers` | Python | Remove blocked trailers from commit messages | | `check-agent-identity` | Python | Block commits from agent fingerprints in author identity | @@ -87,6 +87,7 @@ prepare-changelog validate [FILE] prepare-changelog prepare [FILE] prepare-changelog finalize [FILE] prepare-changelog reset [FILE] +prepare-changelog unprepare [FILE] ``` Examples: @@ -96,6 +97,7 @@ prepare-changelog validate prepare-changelog prepare 0.3.0 prepare-changelog finalize 0.3.0 2026-03-04 prepare-changelog reset +prepare-changelog unprepare ``` ### `gh-issues` From 38f2e5b8874e421b568f0265cffc7686ed4f1679 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 17:46:27 +0100 Subject: [PATCH 156/215] fix(ci): align bats test with actual workflow changelog validation Refs: #417 --- tests/bats/just.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 731fe1d8..9d5d0410 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -75,8 +75,8 @@ setup() { assert_success } -@test "smoke-test dispatch normalizes workspace changelog for prepare-release freeze" { - run bash -lc 'grep -Fq -- "expected CHANGELOG.md after install (workspace scaffold)" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "FIRST_CHANGELOG_SECTION" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "## Unreleased" assets/smoke-test/.github/workflows/repository-dispatch.yml' +@test "smoke-test dispatch validates workspace changelog exists after install" { + run bash -lc 'grep -Fq -- "expected CHANGELOG.md after install (workspace scaffold)" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "CHANGELOG.md is not readable after ownership repair" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } From ea7d6ce52e743453f89ff669150de46a8b9e1fae Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 18:01:22 +0100 Subject: [PATCH 157/215] test: align smoke root CHANGELOG assertions with unprepare Refs: #417 --- tests/test_integration.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index f09d0c53..b9fa31fd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -908,13 +908,15 @@ def test_smoke_workspace_changelog_available_in_devcontainer_and_root( root_content = root_changelog.read_text(encoding="utf-8") devcontainer_content = devcontainer_changelog.read_text(encoding="utf-8") - # Root changelog is workspace-owned; .devcontainer changelog is the canonical - # upstream release history synced from the template manifest. - assert "## Unreleased" in root_content, ( - "Root changelog should expose workspace Unreleased section" - ) - assert "## [" not in root_content, ( - "Root changelog should remain a workspace stub without versioned releases" + # Root changelog is a copy of .devcontainer/CHANGELOG.md with the top semver + # heading renamed via prepare-changelog unprepare; older release sections stay. + first_h2 = re.search(r"^## .+$", root_content, re.MULTILINE) + assert first_h2 is not None, "Root changelog should have a top-level ## heading" + assert first_h2.group(0).rstrip("\r\n") == "## Unreleased", ( + "Root changelog top section should be ## Unreleased after smoke-test unprepare" + ) + assert re.search(r"^## \[\d+\.\d+\.\d+\]", root_content, re.MULTILINE), ( + "Root changelog should retain semver release sections below Unreleased" ) assert re.search( r"^## \[\d+\.\d+\.\d+\]", devcontainer_content, re.MULTILINE From 1b5ca5dd661855498d542fb53b6161908a515dda Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 19:27:53 +0100 Subject: [PATCH 158/215] fix(ci): merge smoke-test release phase 2 into repository dispatch Run release.yml while release/ still exists, then enable auto-merge and poll for merge. Remove on-release-pr-merge.yml. Refs: #419 --- CHANGELOG.md | 3 + .../.github/workflows/on-release-pr-merge.yml | 232 ------------------ .../.github/workflows/repository-dispatch.yml | 143 ++++++++++- assets/workspace/.devcontainer/CHANGELOG.md | 3 + tests/bats/just.bats | 43 ++-- 5 files changed, 162 insertions(+), 262 deletions(-) delete mode 100644 assets/smoke-test/.github/workflows/on-release-pr-merge.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 34632589..48a94abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Sync-main-to-dev conflict detection uses merge-tree** ([#410](https://github.com/vig-os/devcontainer/issues/410)) - Replace working-tree trial merge with `git merge-tree --write-tree` so clean merges are not mislabeled as conflicts - Enable auto-merge when dev merges cleanly with main; print merge-tree output on conflicts; fail the step on unexpected errors +- **Smoke-test release phase 2 branch-not-found failure** ([#419](https://github.com/vig-os/devcontainer/issues/419)) + - Merge phase 2 (`on-release-pr-merge.yml`) back into `repository-dispatch.yml` so the release runs while `release/` still exists, matching the normal release flow + - Remove `on-release-pr-merge.yml` from the smoke-test template - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/smoke-test/.github/workflows/on-release-pr-merge.yml b/assets/smoke-test/.github/workflows/on-release-pr-merge.yml deleted file mode 100644 index 764bb957..00000000 --- a/assets/smoke-test/.github/workflows/on-release-pr-merge.yml +++ /dev/null @@ -1,232 +0,0 @@ -name: Trigger release from merged release PR -# -# Purpose: -# - Continue smoke-test orchestration after the release PR is merged to main. -# - Dispatch release.yml and wait for completion. -# - Notify upstream if this second phase fails. -# -# NOTE: This workflow is part of the smoke-test template under assets/smoke-test -# and must be manually deployed/promoted in the downstream repo before use. - -on: # yamllint disable-line rule:truthy - pull_request: - types: [closed] - branches: [main] - -concurrency: - group: smoke-test-release-phase2-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: false - -permissions: - contents: read - -env: - WORKFLOW_REF: dev - -jobs: - validate: - name: Validate merged release PR event - if: >- - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release/') - runs-on: ubuntu-22.04 - timeout-minutes: 5 - outputs: - version: ${{ steps.extract.outputs.version }} - release_kind: ${{ steps.extract.outputs.release_kind }} - release_pr_url: ${{ steps.extract.outputs.release_pr_url }} - release_pr_number: ${{ steps.extract.outputs.release_pr_number }} - steps: - - name: Extract version and release kind - id: extract - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_URL: ${{ github.event.pull_request.html_url }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - VERSION="${HEAD_REF#release/}" - if [ "${VERSION}" = "${HEAD_REF}" ]; then - echo "ERROR: expected release/* head ref, got '${HEAD_REF}'" - exit 1 - fi - if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "ERROR: invalid release version parsed from head ref '${HEAD_REF}'" - exit 1 - fi - - RELEASE_KIND="" - if jq -e '.pull_request.labels[]? | select(.name=="release-kind:final")' "${GITHUB_EVENT_PATH}" >/dev/null; then - RELEASE_KIND="final" - elif jq -e '.pull_request.labels[]? | select(.name=="release-kind:candidate")' "${GITHUB_EVENT_PATH}" >/dev/null; then - RELEASE_KIND="candidate" - else - echo "ERROR: missing required release-kind label (expected 'release-kind:final' or 'release-kind:candidate')" - exit 1 - fi - - echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" - echo "release_kind=${RELEASE_KIND}" >> "${GITHUB_OUTPUT}" - echo "release_pr_url=${PR_URL}" >> "${GITHUB_OUTPUT}" - echo "release_pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" - - trigger-release: - name: Trigger and wait for release workflow - runs-on: ubuntu-22.04 - timeout-minutes: 35 - env: - GH_REPO: ${{ github.repository }} - needs: [validate] - outputs: - before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} - steps: - - name: Generate release app token for release workflow dispatch - id: generate_release_token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - - - name: Capture latest release run id - id: capture_release_before - env: - GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - run: | - set -euo pipefail - BEFORE_RUN_ID="$( - gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 - )" - echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" - - - name: Trigger release workflow - env: - GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - VERSION: ${{ needs.validate.outputs.version }} - RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} - run: | - set -euo pipefail - gh workflow run release.yml \ - --ref "${WORKFLOW_REF}" \ - -f version="${VERSION}" \ - -f release-kind="${RELEASE_KIND}" - - - name: Wait for release workflow completion - env: - GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - BEFORE_RUN_ID: ${{ steps.capture_release_before.outputs.before_run_id }} - run: | - set -euo pipefail - TIMEOUT=1800 - INTERVAL=30 - ELAPSED=0 - - while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do - RUN_ID="$(gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" - if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then - STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" - if [ "${STATUS}" = "completed" ]; then - CONCLUSION="$(gh run view "${RUN_ID}" --json conclusion --jq '.conclusion' 2>/dev/null || echo unknown)" - if [ "${CONCLUSION}" != "success" ]; then - echo "ERROR: release workflow concluded with '${CONCLUSION}'" - exit 1 - fi - echo "release workflow completed successfully" - exit 0 - fi - fi - - sleep "${INTERVAL}" - ELAPSED=$((ELAPSED + INTERVAL)) - echo "Waiting for release workflow... (${ELAPSED}s/${TIMEOUT}s)" - done - - echo "ERROR: timed out waiting for release workflow completion" - exit 1 - - summary: - name: Release phase 2 summary - runs-on: ubuntu-22.04 - timeout-minutes: 5 - needs: - - validate - - trigger-release - if: always() && needs.validate.result != 'skipped' - steps: - - name: Check release phase 2 results - run: | - echo "Release Phase 2 Results Summary" - echo "===============================" - echo "" - echo "Validate: ${{ needs.validate.result }}" - echo "Release: ${{ needs.trigger-release.result }}" - echo "Release PR: ${{ needs.validate.outputs.release_pr_url }}" - echo "" - - FAILED=false - if [ "${{ needs.validate.result }}" != "success" ]; then - echo "ERROR: validation failed" - FAILED=true - fi - if [ "${{ needs.trigger-release.result }}" != "success" ]; then - echo "ERROR: release workflow orchestration failed" - FAILED=true - fi - if [ "${FAILED}" = "true" ]; then - exit 1 - fi - - notify-failure: - name: Notify upstream on smoke-test release phase 2 failure - runs-on: ubuntu-22.04 - timeout-minutes: 5 - if: failure() && needs.validate.result != 'skipped' - needs: - - validate - - trigger-release - - summary - steps: - - name: Generate release app token for upstream issue creation - id: generate_release_token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: vig-os - repositories: devcontainer - - - name: Create upstream failure issue - env: - GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - VERSION: ${{ needs.validate.outputs.version }} - RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} - RELEASE_PR_URL: ${{ needs.validate.outputs.release_pr_url }} - run: | - set -euo pipefail - WORKFLOW_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - - ISSUE_BODY="$( - cat </dev/null 2>&1 || true gh pr edit "${PR_NUMBER}" --add-label "${LABEL}" + trigger-release: + name: Trigger and wait for release workflow + runs-on: ubuntu-22.04 + timeout-minutes: 35 + env: + GH_REPO: ${{ github.repository }} + needs: [validate, ready-release-pr] + outputs: + before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} + steps: + - name: Generate release app token for release workflow dispatch + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Capture latest release run id + id: capture_release_before + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + run: | + set -euo pipefail + BEFORE_RUN_ID="$( + gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // 0' 2>/dev/null || echo 0 + )" + echo "before_run_id=${BEFORE_RUN_ID}" >> "${GITHUB_OUTPUT}" + + - name: Trigger release workflow + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BASE_VERSION: ${{ needs.validate.outputs.base_version }} + RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} + run: | + set -euo pipefail + gh workflow run release.yml \ + --ref "${WORKFLOW_REF}" \ + -f version="${BASE_VERSION}" \ + -f release-kind="${RELEASE_KIND}" + + - name: Wait for release workflow completion + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + BEFORE_RUN_ID: ${{ steps.capture_release_before.outputs.before_run_id }} + run: | + set -euo pipefail + TIMEOUT=1800 + INTERVAL=30 + ELAPSED=0 + + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + RUN_ID="$(gh run list --workflow release.yml --branch "${WORKFLOW_REF}" --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)" + if [ -n "${RUN_ID}" ] && [ "${RUN_ID}" -gt "${BEFORE_RUN_ID}" ]; then + STATUS="$(gh run view "${RUN_ID}" --json status --jq '.status' 2>/dev/null || echo unknown)" + if [ "${STATUS}" = "completed" ]; then + CONCLUSION="$(gh run view "${RUN_ID}" --json conclusion --jq '.conclusion' 2>/dev/null || echo unknown)" + if [ "${CONCLUSION}" != "success" ]; then + echo "ERROR: release workflow concluded with '${CONCLUSION}'" + exit 1 + fi + echo "release workflow completed successfully" + exit 0 + fi + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + echo "Waiting for release workflow... (${ELAPSED}s/${TIMEOUT}s)" + done + + echo "ERROR: timed out waiting for release workflow completion" + exit 1 + + merge-release-pr: + name: Enable auto-merge and wait for release PR merge + runs-on: ubuntu-22.04 + timeout-minutes: 35 + needs: [ready-release-pr, trigger-release] + steps: + - name: Generate release app token for PR merge + id: generate_release_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + - name: Enable release PR auto-merge env: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} - PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} + PR_NUMBER: ${{ needs.ready-release-pr.outputs.release_pr }} run: | set -euo pipefail gh pr merge "${PR_NUMBER}" --auto --merge || \ echo "Warning: could not enable auto-merge yet" + - name: Poll release PR merge status + env: + GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} + PR_URL: ${{ needs.ready-release-pr.outputs.release_pr_url }} + run: | + set -euo pipefail + if [ -z "${PR_URL}" ]; then + echo "ERROR: missing release PR URL" + exit 1 + fi + + TIMEOUT=1800 + INTERVAL=30 + ELAPSED=0 + + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + STATE="$(gh pr view "${PR_URL}" --json state --jq '.state' 2>/dev/null || echo unknown)" + if [ "${STATE}" = "MERGED" ]; then + echo "Release PR merged: ${PR_URL}" + exit 0 + fi + if [ "${STATE}" = "CLOSED" ]; then + echo "ERROR: release PR closed without merge: ${PR_URL}" + exit 1 + fi + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + echo "Waiting for release PR merge... (${ELAPSED}s/${TIMEOUT}s)" + done + + echo "ERROR: timed out waiting for release PR merge" + exit 1 + summary: name: Dispatch summary runs-on: ubuntu-22.04 @@ -565,6 +688,8 @@ jobs: - cleanup-release - trigger-prepare-release - ready-release-pr + - trigger-release + - merge-release-pr if: always() steps: - name: Write source context summary @@ -600,6 +725,8 @@ jobs: echo "Cleanup: ${{ needs.cleanup-release.result }}" echo "Prepare: ${{ needs.trigger-prepare-release.result }}" echo "Release PR: ${{ needs.ready-release-pr.result }}" + echo "Release run: ${{ needs.trigger-release.result }}" + echo "Release merge: ${{ needs.merge-release-pr.result }}" echo "Deploy PR: ${{ needs.deploy.outputs.pr_url }}" echo "Release PR: ${{ needs.ready-release-pr.outputs.release_pr_url }}" echo "" @@ -636,6 +763,16 @@ jobs: FAILED=true fi + if [ "${{ needs.trigger-release.result }}" != "success" ]; then + echo "ERROR: Trigger-release job failed" + FAILED=true + fi + + if [ "${{ needs.merge-release-pr.result }}" != "success" ]; then + echo "ERROR: Merge-release-pr job failed" + FAILED=true + fi + if [ "${FAILED}" = "true" ]; then echo "" echo "Dispatch orchestration failed" @@ -657,6 +794,8 @@ jobs: - cleanup-release - trigger-prepare-release - ready-release-pr + - trigger-release + - merge-release-pr - summary steps: - name: Generate release app token for upstream issue creation @@ -711,6 +850,8 @@ jobs: - cleanup-release: \`${{ needs.cleanup-release.result }}\` - trigger-prepare-release: \`${{ needs.trigger-prepare-release.result }}\` - ready-release-pr: \`${{ needs.ready-release-pr.result }}\` + - trigger-release: \`${{ needs.trigger-release.result }}\` + - merge-release-pr: \`${{ needs.merge-release-pr.result }}\` - summary: \`${{ needs.summary.result }}\` ## Manual cleanup guidance diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 34632589..48a94abd 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -91,6 +91,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Sync-main-to-dev conflict detection uses merge-tree** ([#410](https://github.com/vig-os/devcontainer/issues/410)) - Replace working-tree trial merge with `git merge-tree --write-tree` so clean merges are not mislabeled as conflicts - Enable auto-merge when dev merges cleanly with main; print merge-tree output on conflicts; fail the step on unexpected errors +- **Smoke-test release phase 2 branch-not-found failure** ([#419](https://github.com/vig-os/devcontainer/issues/419)) + - Merge phase 2 (`on-release-pr-merge.yml`) back into `repository-dispatch.yml` so the release runs while `release/` still exists, matching the normal release flow + - Remove `on-release-pr-merge.yml` from the smoke-test template - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 9d5d0410..c05b71fe 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -115,53 +115,38 @@ setup() { assert_success } -@test "smoke-test phase 2 wait logic tracks release run after dispatch" { - run bash -lc 'grep -Fq -- "Capture latest release run id" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "gh run list --workflow release.yml --branch \"\${WORKFLOW_REF}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_release_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' +@test "smoke-test dispatch wait logic tracks release run after dispatch" { + run bash -lc 'grep -Fq -- "Capture latest release run id" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "gh run list --workflow release.yml --branch \"\${WORKFLOW_REF}\"" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "BEFORE_RUN_ID: \${{ steps.capture_release_before.outputs.before_run_id }}" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "[ \"\${RUN_ID}\" -gt \"\${BEFORE_RUN_ID}\" ]" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } -@test "smoke-test dispatch readies release PR with release kind label and auto-merge" { - run bash -lc 'grep -Fq -- "gh pr ready" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "release-kind:candidate" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "gh pr merge" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "--auto --merge" assets/smoke-test/.github/workflows/repository-dispatch.yml' +@test "smoke-test dispatch triggers release workflow with base version and release kind" { + run bash -lc 'grep -Fq -- "gh workflow run release.yml \\" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "-f version=\"\${BASE_VERSION}\"" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "-f release-kind=\"\${RELEASE_KIND}\"" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "needs: [validate, ready-release-pr]" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } -@test "smoke-test dispatch tolerates transient auto-merge enable failures" { - run bash -lc 'grep -Fq -- "Warning: could not enable auto-merge yet" assets/smoke-test/.github/workflows/repository-dispatch.yml' - assert_success -} - -@test "smoke-test dispatch notifies upstream on orchestration failure" { - run bash -lc "grep -Fq -- 'notify-failure:' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh issue create \\' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- '--repo vig-os/devcontainer' assets/smoke-test/.github/workflows/repository-dispatch.yml" +@test "smoke-test dispatch merges release PR after successful release workflow" { + run bash -lc 'grep -Fq -- "merge-release-pr:" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "Poll release PR merge status" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "Waiting for release PR merge" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "needs: [ready-release-pr, trigger-release]" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } -@test "smoke-test dispatch summary includes release-orchestration job results" { - run bash -lc "grep -Fq -- 'needs.wait-deploy-merge.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.cleanup-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-prepare-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.ready-release-pr.result' assets/smoke-test/.github/workflows/repository-dispatch.yml" +@test "smoke-test dispatch readies release PR with release kind label" { + run bash -lc 'grep -Fq -- "gh pr ready" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "release-kind:candidate" assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- "Label release PR with release kind" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } -@test "smoke-test phase 2 triggers on merged release PR to main" { - run bash -lc 'grep -Fq -- "types: [closed]" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "branches: [main]" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "github.event.pull_request.merged == true" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "startsWith(github.event.pull_request.head.ref, '\''release/'\'')" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' - assert_success -} - -@test "smoke-test phase 2 extracts semver version from release head ref" { - run bash -lc 'grep -Fq -- "VERSION=\"\${HEAD_REF#release/}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "invalid release version parsed from head ref" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' - assert_success -} - -@test "smoke-test phase 2 fails when release-kind label is missing" { - run bash -lc 'grep -Fq -- "ERROR: missing required release-kind label" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' +@test "smoke-test dispatch tolerates transient auto-merge enable failures" { + run bash -lc 'grep -Fq -- "Warning: could not enable auto-merge yet" assets/smoke-test/.github/workflows/repository-dispatch.yml' assert_success } -@test "smoke-test phase 2 dispatches release workflow with version inputs" { - run bash -lc 'grep -Fq -- "gh workflow run release.yml \\" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "-f version=\"\${VERSION}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "-f release-kind=\"\${RELEASE_KIND}\"" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' +@test "smoke-test dispatch notifies upstream on orchestration failure" { + run bash -lc "grep -Fq -- 'notify-failure:' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'gh issue create \\' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- '--repo vig-os/devcontainer' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success } -@test "smoke-test phase 2 notifies upstream on failure" { - run bash -lc 'grep -Fq -- "notify-failure:" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "gh issue create \\" assets/smoke-test/.github/workflows/on-release-pr-merge.yml && grep -Fq -- "--repo vig-os/devcontainer" assets/smoke-test/.github/workflows/on-release-pr-merge.yml' +@test "smoke-test dispatch summary includes release-orchestration job results" { + run bash -lc "grep -Fq -- 'needs.wait-deploy-merge.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.cleanup-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-prepare-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.ready-release-pr.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.trigger-release.result' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'needs.merge-release-pr.result' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success } From bc214ea5c19b9d8c8d7b429644359a06765ca757 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 20:04:38 +0100 Subject: [PATCH 159/215] fix(ci): align merge-release-pr gh env and drop unused trigger-release output Refs: #419 --- assets/smoke-test/.github/workflows/repository-dispatch.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index c9562033..3d5e38af 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -552,8 +552,6 @@ jobs: env: GH_REPO: ${{ github.repository }} needs: [validate, ready-release-pr] - outputs: - before_run_id: ${{ steps.capture_release_before.outputs.before_run_id }} steps: - name: Generate release app token for release workflow dispatch id: generate_release_token @@ -624,6 +622,8 @@ jobs: name: Enable auto-merge and wait for release PR merge runs-on: ubuntu-22.04 timeout-minutes: 35 + env: + GH_REPO: ${{ github.repository }} needs: [ready-release-pr, trigger-release] steps: - name: Generate release app token for PR merge From 2c73914b1f50d04f206fc6c2c0a9a02668194cf8 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Mon, 23 Mar 2026 22:53:03 +0100 Subject: [PATCH 160/215] fix(ci): harden downstream release validate and rollback context Refs: #421 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ assets/workspace/.github/workflows/release-core.yml | 13 ------------- assets/workspace/.github/workflows/release.yml | 1 + 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a94abd..07dbaf7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stop overwriting `CHANGELOG.md` with a minimal stub in `assets/smoke-test/.github/workflows/repository-dispatch.yml` - Require the workspace `CHANGELOG.md` from `init-workspace` so downstream `prepare-release` validation matches shipped layout - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` so downstream `prepare-release` can run +- **Smoke-test dispatch release validate no longer runs docker inside devcontainer** ([#421](https://github.com/vig-os/devcontainer/issues/421)) + - Remove redundant `docker manifest inspect` step from `release-core.yml` validate job (container image is already proof of accessibility; `resolve-image` validates on the runner) + - Set `GH_REPO` for rollback `gh issue create` in workspace `release.yml` when git checkout is skipped ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 48a94abd..07dbaf7e 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -162,6 +162,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stop overwriting `CHANGELOG.md` with a minimal stub in `assets/smoke-test/.github/workflows/repository-dispatch.yml` - Require the workspace `CHANGELOG.md` from `init-workspace` so downstream `prepare-release` validation matches shipped layout - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` so downstream `prepare-release` can run +- **Smoke-test dispatch release validate no longer runs docker inside devcontainer** ([#421](https://github.com/vig-os/devcontainer/issues/421)) + - Remove redundant `docker manifest inspect` step from `release-core.yml` validate job (container image is already proof of accessibility; `resolve-image` validates on the runner) + - Set `GH_REPO` for rollback `gh issue create` in workspace `release.yml` when git checkout is skipped ### Security diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 7642893d..0e8cd41a 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -162,19 +162,6 @@ jobs: - name: Fix git safe.directory run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Validate image accessibility - env: - IMAGE_TAG: ${{ needs.resolve-image.outputs.image-tag }} - run: | - set -euo pipefail - IMAGE="ghcr.io/vig-os/devcontainer:${IMAGE_TAG}" - echo "Validating image availability: $IMAGE" - if ! retry --retries 3 --backoff 5 --max-backoff 30 -- docker manifest inspect "$IMAGE" > /dev/null 2>&1; then - echo "ERROR: Cannot access image manifest: $IMAGE" - echo "Check whether the tag exists and whether this workflow has access to GHCR." - exit 1 - fi - - name: Record pre-finalization SHA id: pre_sha run: | diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index d2bb29a4..e54c182b 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -194,6 +194,7 @@ jobs: VERSION: ${{ needs.core.outputs.version }} PR_NUMBER: ${{ needs.core.outputs.pr_number }} GH_TOKEN: ${{ steps.release_app_token.outputs.token }} + GH_REPO: ${{ github.repository }} run: | set -euo pipefail WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" From d3d81bcf3b3214580f44a7b8ef3f41735a8a17fb Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 24 Mar 2026 07:34:05 +0100 Subject: [PATCH 161/215] test: update expected uv minor line in image tests Refs: #423 --- CHANGELOG.md | 2 ++ assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ tests/test_image.py | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07dbaf7e..779ecfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch release validate no longer runs docker inside devcontainer** ([#421](https://github.com/vig-os/devcontainer/issues/421)) - Remove redundant `docker manifest inspect` step from `release-core.yml` validate job (container image is already proof of accessibility; `resolve-image` validates on the runner) - Set `GH_REPO` for rollback `gh issue create` in workspace `release.yml` when git checkout is skipped +- **Container image tests expect current uv minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) + - Update `tests/test_image.py` `EXPECTED_VERSIONS["uv"]` to match uv 0.11.x from the latest release install path in the image build ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 07dbaf7e..779ecfa6 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -165,6 +165,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch release validate no longer runs docker inside devcontainer** ([#421](https://github.com/vig-os/devcontainer/issues/421)) - Remove redundant `docker manifest inspect` step from `release-core.yml` validate job (container image is already proof of accessibility; `resolve-image` validates on the runner) - Set `GH_REPO` for rollback `gh issue create` in workspace `release.yml` when git checkout is skipped +- **Container image tests expect current uv minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) + - Update `tests/test_image.py` `EXPECTED_VERSIONS["uv"]` to match uv 0.11.x from the latest release install path in the image build ### Security diff --git a/tests/test_image.py b/tests/test_image.py index 6f57a707..ee12f982 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -20,7 +20,7 @@ "git": "2.", # Major version check (from apt package) "curl": "8.", # Major version check (from apt package) "gh": "2.88.", # Minor version check (GitHub CLI (manually installed from latest release) - "uv": "0.10.", # Minor version check (manually installed from latest release) + "uv": "0.11.", # Minor version check (manually installed from latest release) "python": "3.12", # Python (from base image) "pre_commit": "4.5.", # Minor version check (installed via uv pip) "ruff": "0.15.", # Minor version check (installed via uv pip) From 42ce087bd6d05f7fa3484b9fd6d3a4920a56f118 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 24 Mar 2026 07:51:56 +0100 Subject: [PATCH 162/215] test(image): expect just 1.48.x in container tests Refs: #423 --- CHANGELOG.md | 2 ++ assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ tests/test_image.py | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 779ecfa6..a586b4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set `GH_REPO` for rollback `gh issue create` in workspace `release.yml` when git checkout is skipped - **Container image tests expect current uv minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) - Update `tests/test_image.py` `EXPECTED_VERSIONS["uv"]` to match uv 0.11.x from the latest release install path in the image build +- **Container image tests expect current just minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) + - Update `tests/test_image.py` `EXPECTED_VERSIONS["just"]` to match just 1.48.x from the latest release install path in the image build ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 779ecfa6..a586b4bf 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -167,6 +167,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set `GH_REPO` for rollback `gh issue create` in workspace `release.yml` when git checkout is skipped - **Container image tests expect current uv minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) - Update `tests/test_image.py` `EXPECTED_VERSIONS["uv"]` to match uv 0.11.x from the latest release install path in the image build +- **Container image tests expect current just minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) + - Update `tests/test_image.py` `EXPECTED_VERSIONS["just"]` to match just 1.48.x from the latest release install path in the image build ### Security diff --git a/tests/test_image.py b/tests/test_image.py index ee12f982..7ee113db 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -26,7 +26,7 @@ "ruff": "0.15.", # Minor version check (installed via uv pip) "bandit": "1.9.", # Minor version check (installed via uv pip) "pip_licenses": "5.", # Major version check (installed via uv pip) - "just": "1.47.", # Minor version check (manually installed from latest release) + "just": "1.48.", # Minor version check (manually installed from latest release) "hadolint": "2.14.", # Minor version check (manually installed from pinned release) "taplo": "0.10.", # Minor version check (manually installed from latest release) "cargo-binstall": "1.17.", # Minor version check (installed from latest release), From affc1543479ead34cf5020717fdb45f51fd4fb76 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 24 Mar 2026 09:11:21 +0100 Subject: [PATCH 163/215] fix(image): add jq to devcontainer image Enables release-core validate and downstream Release Core jobs that pipe gh JSON through jq. Refs: #425 --- CHANGELOG.md | 2 ++ Containerfile | 1 + assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ 3 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a586b4bf..f80aaa16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add project-owned `release-extension.yml` stub and preserve it during `init-workspace.sh --force` upgrades - Add `validate-contract` composite action for single-source contract version validation - Add downstream release contract documentation and GHCR extension example in `docs/DOWNSTREAM_RELEASE.md` +- **`jq` in devcontainer image** ([#425](https://github.com/vig-os/devcontainer/issues/425)) + - Install the `jq` CLI in the GHCR image so containerized workflows (e.g. `release-core` validate / downstream Release Core) can pipe JSON through `jq` ### Changed diff --git a/Containerfile b/Containerfile index 74eac1a4..d1b725de 100644 --- a/Containerfile +++ b/Containerfile @@ -53,6 +53,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ git \ + jq \ openssh-client \ locales \ ca-certificates \ diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index a586b4bf..f80aaa16 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add project-owned `release-extension.yml` stub and preserve it during `init-workspace.sh --force` upgrades - Add `validate-contract` composite action for single-source contract version validation - Add downstream release contract documentation and GHCR extension example in `docs/DOWNSTREAM_RELEASE.md` +- **`jq` in devcontainer image** ([#425](https://github.com/vig-os/devcontainer/issues/425)) + - Install the `jq` CLI in the GHCR image so containerized workflows (e.g. `release-core` validate / downstream Release Core) can pipe JSON through `jq` ### Changed From 0f84364796fb8697e107b16df4faa4b79a6b81f4 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 24 Mar 2026 15:20:55 +0100 Subject: [PATCH 164/215] chore(ci): bump commit-action to v0.2.0 Upstream v0.2.0: - Bounded retry with exponential backoff for transient GitHub API failures (configurable MAX_ATTEMPTS and delay bounds). - Multi-file commits via createTree inline content for text; binary blobs when needed; chunked trees for large change sets. - Library exports: isBinaryFile, getFileMode, TREE_ENTRY_CHUNK_SIZE; sequential binary blobs to reduce rate-limit bursts. Refs: #354 --- .github/workflows/prepare-release.yml | 6 +++--- .github/workflows/release.yml | 2 +- .github/workflows/sync-issues.yml | 2 +- CHANGELOG.md | 5 +++++ assets/smoke-test/.github/workflows/repository-dispatch.yml | 2 +- assets/workspace/.devcontainer/CHANGELOG.md | 5 +++++ assets/workspace/.github/workflows/prepare-release.yml | 6 +++--- assets/workspace/.github/workflows/release-core.yml | 2 +- assets/workspace/.github/workflows/sync-issues.yml | 2 +- 9 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a1d8bfe5..d92c21cf 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -219,7 +219,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Commit prepared CHANGELOG to dev via API - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -271,7 +271,7 @@ jobs: echo "✓ Synced workspace manifest after release changelog strip" - name: Commit stripped CHANGELOG to release branch via API - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -379,7 +379,7 @@ jobs: - name: Commit CHANGELOG rollback to dev via API if: ${{ failure() && steps.rollback-prepare.outputs.changelog_rollback_needed == 'true' }} - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7484a041..19cc6717 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -561,7 +561,7 @@ jobs: - name: Commit finalization changes via API if: needs.validate.outputs.release_kind == 'final' - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index 2379242b..fdc77ebd 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -115,7 +115,7 @@ jobs: - name: Commit and push changes via API id: commit if: steps.sync.outputs.modified-files != '' - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: # Use App token so push can bypass branch protection when App is in bypass list GH_TOKEN: ${{ steps.generate-token.outputs.token || github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f80aaa16..e1f6efaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test release phase 2 branch-not-found failure** ([#419](https://github.com/vig-os/devcontainer/issues/419)) - Merge phase 2 (`on-release-pr-merge.yml`) back into `repository-dispatch.yml` so the release runs while `release/` still exists, matching the normal release flow - Remove `on-release-pr-merge.yml` from the smoke-test template +- **Pinned commit-action to v0.2.0** ([#354](https://github.com/vig-os/devcontainer/issues/354)) + - Updated workflow pins from `vig-os/commit-action@c0024cb` (v0.1.5) to `1bc004353d08d9332a0cb54920b148256220c8e0` (v0.2.0) in release, sync-issues, prepare-release, and smoke-test workflows + - Upstream v0.2.0 adds bounded retry with exponential backoff for transient GitHub API failures (configurable `MAX_ATTEMPTS` and delay bounds) + - Efficient multi-file commits via `createTree` inline content for text files, binary blobs only when needed, and chunked tree creation for large change sets + - Exports `isBinaryFile`, `getFileMode`, and `TREE_ENTRY_CHUNK_SIZE` for library use; sequential binary blob creation to reduce secondary rate-limit bursts - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 3d5e38af..c186def8 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -263,7 +263,7 @@ jobs: fi - name: Commit and push deploy changes via signed commit-action - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.generate_commit_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index f80aaa16..e1f6efaf 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -96,6 +96,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test release phase 2 branch-not-found failure** ([#419](https://github.com/vig-os/devcontainer/issues/419)) - Merge phase 2 (`on-release-pr-merge.yml`) back into `repository-dispatch.yml` so the release runs while `release/` still exists, matching the normal release flow - Remove `on-release-pr-merge.yml` from the smoke-test template +- **Pinned commit-action to v0.2.0** ([#354](https://github.com/vig-os/devcontainer/issues/354)) + - Updated workflow pins from `vig-os/commit-action@c0024cb` (v0.1.5) to `1bc004353d08d9332a0cb54920b148256220c8e0` (v0.2.0) in release, sync-issues, prepare-release, and smoke-test workflows + - Upstream v0.2.0 adds bounded retry with exponential backoff for transient GitHub API failures (configurable `MAX_ATTEMPTS` and delay bounds) + - Efficient multi-file commits via `createTree` inline content for text files, binary blobs only when needed, and chunked tree creation for large change sets + - Exports `isBinaryFile`, `getFileMode`, and `TREE_ENTRY_CHUNK_SIZE` for library use; sequential binary blob creation to reduce secondary rate-limit bursts - **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs diff --git a/assets/workspace/.github/workflows/prepare-release.yml b/assets/workspace/.github/workflows/prepare-release.yml index b316f0f8..651051f4 100644 --- a/assets/workspace/.github/workflows/prepare-release.yml +++ b/assets/workspace/.github/workflows/prepare-release.yml @@ -213,7 +213,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Commit prepared CHANGELOG to dev via API - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -263,7 +263,7 @@ jobs: " - name: Commit stripped CHANGELOG to release branch via API - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} @@ -348,7 +348,7 @@ jobs: - name: Commit CHANGELOG rollback to dev via API if: ${{ failure() && steps.rollback_prepare.outputs.changelog_rollback_needed == 'true' }} - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 0e8cd41a..5ffea366 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -366,7 +366,7 @@ jobs: - name: Commit and push finalization changes via API if: ${{ inputs.release_kind == 'final' }} - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index f71e8fd0..9128ee4d 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -135,7 +135,7 @@ jobs: - name: Commit and push changes via API id: commit if: steps.sync.outputs.modified-files != '' - uses: vig-os/commit-action@c0024cbad0e501764127cccab732c6cd465b4646 # v0.1.5 + uses: vig-os/commit-action@1bc004353d08d9332a0cb54920b148256220c8e0 # v0.2.0 env: # Use App token so push can bypass branch protection when App is in bypass list GH_TOKEN: ${{ steps.generate-token.outputs.token }} From 0f8109e68b81e0726dafc8a99b25104a24395b9b Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 24 Mar 2026 16:42:26 +0100 Subject: [PATCH 165/215] fix(ci): approve release PR with GITHUB_TOKEN before release dispatch Refs: #430 --- CHANGELOG.md | 3 +++ .../.github/workflows/repository-dispatch.yml | 11 +++++++++++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ 3 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f6efaf..124d58fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,6 +176,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `tests/test_image.py` `EXPECTED_VERSIONS["uv"]` to match uv 0.11.x from the latest release install path in the image build - **Container image tests expect current just minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) - Update `tests/test_image.py` `EXPECTED_VERSIONS["just"]` to match just 1.48.x from the latest release install path in the image build +- **Smoke-test dispatch approves release PR before downstream release** ([#430](https://github.com/vig-os/devcontainer/issues/430)) + - Grant `pull-requests: write` on `ready-release-pr` and approve with `github.token` (`github-actions[bot]`) + - Satisfy `release-core.yml` approval gate without the release app self-approving its own PR ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index c186def8..59d48e52 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -487,6 +487,9 @@ jobs: name: Prepare release PR runs-on: ubuntu-22.04 timeout-minutes: 35 + permissions: + contents: read + pull-requests: write env: GH_REPO: ${{ github.repository }} needs: [validate, trigger-prepare-release] @@ -545,6 +548,14 @@ jobs: gh pr edit "${PR_NUMBER}" --remove-label "release-kind:final" >/dev/null 2>&1 || true gh pr edit "${PR_NUMBER}" --add-label "${LABEL}" + - name: Approve release PR for automated dispatch + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} + run: | + gh pr review "${PR_NUMBER}" --approve \ + --body "Automated approval by smoke-test dispatch orchestration." + trigger-release: name: Trigger and wait for release workflow runs-on: ubuntu-22.04 diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index e1f6efaf..124d58fa 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -176,6 +176,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `tests/test_image.py` `EXPECTED_VERSIONS["uv"]` to match uv 0.11.x from the latest release install path in the image build - **Container image tests expect current just minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) - Update `tests/test_image.py` `EXPECTED_VERSIONS["just"]` to match just 1.48.x from the latest release install path in the image build +- **Smoke-test dispatch approves release PR before downstream release** ([#430](https://github.com/vig-os/devcontainer/issues/430)) + - Grant `pull-requests: write` on `ready-release-pr` and approve with `github.token` (`github-actions[bot]`) + - Satisfy `release-core.yml` approval gate without the release app self-approving its own PR ### Security From b451a2eb792e54887ed97f41de4d892088f01878 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Tue, 24 Mar 2026 16:54:07 +0100 Subject: [PATCH 166/215] ci: strict shell and approval failure hint in dispatch approve step Refs: #430 --- assets/smoke-test/.github/workflows/repository-dispatch.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 59d48e52..b9daecd7 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -553,8 +553,12 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.locate_release_pr.outputs.release_pr }} run: | + set -euo pipefail gh pr review "${PR_NUMBER}" --approve \ - --body "Automated approval by smoke-test dispatch orchestration." + --body "Automated approval by smoke-test dispatch orchestration." || { + echo "::error::Auto-approve failed. If you see a permissions error, enable repository (or organization) setting 'Allow GitHub Actions to create and approve pull requests'. See this PR description for context." + exit 1 + } trigger-release: name: Trigger and wait for release workflow From 74244603ae9b754cdff73b6cc2da995bc2c33669 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 09:33:28 +0100 Subject: [PATCH 167/215] fix(image): make cursor-agent install resilient to CDN failures The official installer (curl https://cursor.com/install | bash) pulls a versioned tarball from downloads.cursor.com/lab/...; that object began returning HTTP 403 AccessDenied for the embedded build, which broke podman/docker image builds and release CI (issue #434). Community report: https://forum.cursor.com/t/cursor-cli-cannot-be-installed-installer-tried-to-download-asset-that-403s/155827 Mitigation: retry install three times with 10s backoff; if it still fails, finish the image without cursor-agent. test_image skips when agent is absent. Refs: #434 --- CHANGELOG.md | 3 +++ Containerfile | 12 ++++++++++-- assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ tests/test_image.py | 3 ++- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 124d58fa..1157e762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `actions/cache` restore/save pins from `5.0.3` to `5.0.4` in `sync-issues.yml` - **Dependabot dependency update batch** ([#413](https://github.com/vig-os/devcontainer/pull/413)) - Bump `@devcontainers/cli` from `0.84.0` to `0.84.1` +- **cursor-agent install is now resilient to CDN failures** ([#434](https://github.com/vig-os/devcontainer/issues/434)) + - Retries 3 times with backoff before giving up + - Build succeeds without cursor-agent when Cursor's CDN is unavailable ### Fixed diff --git a/Containerfile b/Containerfile index d1b725de..8a0cad8a 100644 --- a/Containerfile +++ b/Containerfile @@ -150,8 +150,16 @@ RUN set -eux; \ # Install cursor-agent CLI (installs to ~/.local/bin) ENV PATH="/root/.local/bin:${PATH}" RUN set -eux; \ - curl -fsSL https://cursor.com/install | bash; \ - agent --version; + for attempt in 1 2 3; do \ + if curl -fsSL https://cursor.com/install | bash; then \ + agent --version; \ + exit 0; \ + fi; \ + echo "cursor-agent install attempt ${attempt} failed, retrying in 10s..."; \ + sleep 10; \ + done; \ + echo "WARNING: cursor-agent install failed after 3 attempts (external CDN issue); skipping"; \ + echo "Install manually: curl https://cursor.com/install -fsSL | bash"; # Install latest cargo-binstall from release archive with minisign signature verification # cargo-binstall uses minisign for signing releases. Each release has an ephemeral key. diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 124d58fa..1157e762 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `actions/cache` restore/save pins from `5.0.3` to `5.0.4` in `sync-issues.yml` - **Dependabot dependency update batch** ([#413](https://github.com/vig-os/devcontainer/pull/413)) - Bump `@devcontainers/cli` from `0.84.0` to `0.84.1` +- **cursor-agent install is now resilient to CDN failures** ([#434](https://github.com/vig-os/devcontainer/issues/434)) + - Retries 3 times with backoff before giving up + - Build succeeds without cursor-agent when Cursor's CDN is unavailable ### Fixed diff --git a/tests/test_image.py b/tests/test_image.py index 7ee113db..02717ad7 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -179,7 +179,8 @@ def test_taplo_version(self, host): def test_cursor_agent_installed(self, host): """Test that cursor-agent CLI (agent) is installed.""" result = host.run("agent --version") - assert result.rc == 0, "agent --version failed" + if result.rc != 0: + pytest.skip("cursor-agent not available (external CDN issue)") def test_cargo_binstall(self, host): """Test that cargo-binstall is installed and right version.""" From ff34cef56555972b49f9cee2f8f188bea2b6a216 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 09:55:42 +0100 Subject: [PATCH 168/215] fix(image): harden cursor-agent install retry loop Download the installer to a temp file and run bash on it so curl failures are not masked by pipeline exit status. Chain install and agent --version in the if condition so set -e does not abort the layer before retries; align the manual-install hint with curl -fsSL. Refs: #434 --- Containerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Containerfile b/Containerfile index 8a0cad8a..0a883a1a 100644 --- a/Containerfile +++ b/Containerfile @@ -150,16 +150,20 @@ RUN set -eux; \ # Install cursor-agent CLI (installs to ~/.local/bin) ENV PATH="/root/.local/bin:${PATH}" RUN set -eux; \ + INSTALLER="/tmp/cursor-install.sh"; \ for attempt in 1 2 3; do \ - if curl -fsSL https://cursor.com/install | bash; then \ - agent --version; \ + if curl -fsSL https://cursor.com/install -o "${INSTALLER}" \ + && bash "${INSTALLER}" \ + && agent --version; then \ + rm -f "${INSTALLER}"; \ exit 0; \ fi; \ + rm -f "${INSTALLER}"; \ echo "cursor-agent install attempt ${attempt} failed, retrying in 10s..."; \ sleep 10; \ done; \ echo "WARNING: cursor-agent install failed after 3 attempts (external CDN issue); skipping"; \ - echo "Install manually: curl https://cursor.com/install -fsSL | bash"; + echo "Install manually: curl -fsSL https://cursor.com/install | bash"; # Install latest cargo-binstall from release archive with minisign signature verification # cargo-binstall uses minisign for signing releases. Each release has an ephemeral key. From 794076af2e15287d69486f5774c8b0aaac9ad77b Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 11:09:24 +0100 Subject: [PATCH 169/215] fix(ci): enable commit-action MAX_ATTEMPTS for transient ref failures Refs: #436 --- .github/workflows/prepare-release.yml | 3 +++ .github/workflows/release.yml | 1 + .github/workflows/sync-issues.yml | 1 + CHANGELOG.md | 3 +++ assets/smoke-test/.github/workflows/repository-dispatch.yml | 1 + assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ assets/workspace/.github/workflows/prepare-release.yml | 3 +++ assets/workspace/.github/workflows/release-core.yml | 1 + assets/workspace/.github/workflows/sync-issues.yml | 1 + 9 files changed, 17 insertions(+) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index d92c21cf..74a4ab88 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -224,6 +224,7 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/dev + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: freeze changelog for release ${{ needs.validate.outputs.version }} @@ -276,6 +277,7 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/${{ needs.validate.outputs.release_branch }} + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: prepare release ${{ needs.validate.outputs.version }} @@ -384,6 +386,7 @@ jobs: GH_TOKEN: ${{ steps.commit-app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/dev + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: rollback failed prepare-release ${{ needs.validate.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19cc6717..32e02e84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -566,6 +566,7 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/release/${{ needs.validate.outputs.version }} + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: finalize release ${{ needs.validate.outputs.version }} diff --git a/.github/workflows/sync-issues.yml b/.github/workflows/sync-issues.yml index fdc77ebd..a4dbeb20 100644 --- a/.github/workflows/sync-issues.yml +++ b/.github/workflows/sync-issues.yml @@ -121,6 +121,7 @@ jobs: GH_TOKEN: ${{ steps.generate-token.outputs.token || github.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/${{ github.event.inputs.target-branch || 'dev' }} + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: "${{ github.event.inputs.commit-msg || 'chore: sync issues and PRs' }}" FILE_PATHS: ${{ steps.sync.outputs.modified-files }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1157e762..a0fa01f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,6 +182,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch approves release PR before downstream release** ([#430](https://github.com/vig-os/devcontainer/issues/430)) - Grant `pull-requests: write` on `ready-release-pr` and approve with `github.token` (`github-actions[bot]`) - Satisfy `release-core.yml` approval gate without the release app self-approving its own PR +- **commit-action retries enabled for transient git ref API failures** ([#436](https://github.com/vig-os/devcontainer/issues/436)) + - Set `MAX_ATTEMPTS: "3"` on every `vig-os/commit-action` step so v0.2.0 bounded retry actually runs (default was 1) + - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates ### Security diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index b9daecd7..141df913 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -268,6 +268,7 @@ jobs: GH_TOKEN: ${{ steps.generate_commit_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/${{ steps.prepare_branch.outputs.branch_name }} + MAX_ATTEMPTS: "3" ALLOW_EMPTY: "true" COMMIT_MESSAGE: |- chore: deploy ${{ needs.validate.outputs.tag }} diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 1157e762..a0fa01f2 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -182,6 +182,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smoke-test dispatch approves release PR before downstream release** ([#430](https://github.com/vig-os/devcontainer/issues/430)) - Grant `pull-requests: write` on `ready-release-pr` and approve with `github.token` (`github-actions[bot]`) - Satisfy `release-core.yml` approval gate without the release app self-approving its own PR +- **commit-action retries enabled for transient git ref API failures** ([#436](https://github.com/vig-os/devcontainer/issues/436)) + - Set `MAX_ATTEMPTS: "3"` on every `vig-os/commit-action` step so v0.2.0 bounded retry actually runs (default was 1) + - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates ### Security diff --git a/assets/workspace/.github/workflows/prepare-release.yml b/assets/workspace/.github/workflows/prepare-release.yml index 651051f4..cc4b6379 100644 --- a/assets/workspace/.github/workflows/prepare-release.yml +++ b/assets/workspace/.github/workflows/prepare-release.yml @@ -218,6 +218,7 @@ jobs: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/dev + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: freeze changelog for release ${{ needs.validate.outputs.version }} @@ -268,6 +269,7 @@ jobs: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/${{ needs.validate.outputs.release_branch }} + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: prepare release ${{ needs.validate.outputs.version }} @@ -353,6 +355,7 @@ jobs: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/dev + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: rollback failed prepare-release ${{ needs.validate.outputs.version }} diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 5ffea366..bcdee2ed 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -371,6 +371,7 @@ jobs: GH_TOKEN: ${{ steps.commit_app_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/release/${{ needs.validate.outputs.version }} + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: |- chore: finalize release ${{ needs.validate.outputs.version }} diff --git a/assets/workspace/.github/workflows/sync-issues.yml b/assets/workspace/.github/workflows/sync-issues.yml index 9128ee4d..9243099b 100644 --- a/assets/workspace/.github/workflows/sync-issues.yml +++ b/assets/workspace/.github/workflows/sync-issues.yml @@ -141,6 +141,7 @@ jobs: GH_TOKEN: ${{ steps.generate-token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} TARGET_BRANCH: refs/heads/${{ github.event.inputs.target-branch || 'dev' }} + MAX_ATTEMPTS: "3" COMMIT_MESSAGE: "${{ github.event.inputs.commit-msg || 'chore: sync issues and PRs' }}" FILE_PATHS: ${{ steps.sync.outputs.modified-files }} From 8ef2bcec5882c8eb3c5e83c9f97ba12ac60d9373 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 12:07:14 +0100 Subject: [PATCH 170/215] fix(ci): accept bot PR approval when reviewDecision is empty Fallback to REST reviews API when aggregated reviewDecision is not APPROVED so smoke-test orchestration approvals count. Refs: #438 --- .github/workflows/release.yml | 10 ++++++++-- CHANGELOG.md | 2 ++ assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ assets/workspace/.github/workflows/release-core.yml | 10 ++++++++-- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32e02e84..fa2f5661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -371,8 +371,14 @@ jobs: fi if [ "$REVIEW_DECISION" != "APPROVED" ]; then - echo "ERROR: PR #$PR_NUMBER does not have approvals (status: $REVIEW_DECISION)" - exit 1 + APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + --jq '[.[] | select(.state == "APPROVED")] | length') + if [ "$APPROVED_COUNT" -eq 0 ]; then + echo "ERROR: PR #$PR_NUMBER does not have approvals (reviewDecision: $REVIEW_DECISION, approved reviews: 0)" + exit 1 + fi + echo "reviewDecision='$REVIEW_DECISION' but $APPROVED_COUNT approved review(s) found" fi # Check CI status diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fa01f2..4801313f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **commit-action retries enabled for transient git ref API failures** ([#436](https://github.com/vig-os/devcontainer/issues/436)) - Set `MAX_ATTEMPTS: "3"` on every `vig-os/commit-action` step so v0.2.0 bounded retry actually runs (default was 1) - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates +- **Release validation fails when bot approves PR** ([#438](https://github.com/vig-os/devcontainer/issues/438)) + - Add fallback to individual PR review check when `reviewDecision` is empty (bot approvals not counted by branch protection) ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index a0fa01f2..4801313f 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -185,6 +185,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **commit-action retries enabled for transient git ref API failures** ([#436](https://github.com/vig-os/devcontainer/issues/436)) - Set `MAX_ATTEMPTS: "3"` on every `vig-os/commit-action` step so v0.2.0 bounded retry actually runs (default was 1) - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates +- **Release validation fails when bot approves PR** ([#438](https://github.com/vig-os/devcontainer/issues/438)) + - Add fallback to individual PR review check when `reviewDecision` is empty (bot approvals not counted by branch protection) ### Security diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index bcdee2ed..5e4f37af 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -262,8 +262,14 @@ jobs: exit 1 fi if [ "$REVIEW_DECISION" != "APPROVED" ]; then - echo "ERROR: PR #$PR_NUMBER is not approved (status: $REVIEW_DECISION)" - exit 1 + APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + --jq '[.[] | select(.state == "APPROVED")] | length') + if [ "$APPROVED_COUNT" -eq 0 ]; then + echo "ERROR: PR #$PR_NUMBER is not approved (reviewDecision: $REVIEW_DECISION, approved reviews: 0)" + exit 1 + fi + echo "reviewDecision='$REVIEW_DECISION' but $APPROVED_COUNT approved review(s) found" fi STATUS_ROLLUP=$(echo "$PR_JSON" | jq -r '.[0].statusCheckRollup // []') From 50abef941be5ad292473a00e3693812734980857 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 12:22:32 +0100 Subject: [PATCH 171/215] fix(ci): tighten bot-approval fallback per review feedback - Add --paginate to gh api reviews call (both workflows) - Scope fallback to empty/null reviewDecision only; explicit rejections (CHANGES_REQUESTED, REVIEW_REQUIRED) still fail - Add pull-requests: read to validate job in release.yml Refs: #438 --- .github/workflows/release.yml | 12 +++++++++++- assets/workspace/.github/workflows/release-core.yml | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa2f5661..79676895 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,9 @@ jobs: name: Validate Release runs-on: ubuntu-22.04 timeout-minutes: 10 + permissions: + contents: read + pull-requests: read outputs: version: ${{ steps.vars.outputs.version }} pr_number: ${{ steps.pr.outputs.pr_number }} @@ -370,8 +373,12 @@ jobs: exit 1 fi - if [ "$REVIEW_DECISION" != "APPROVED" ]; then + if [ "$REVIEW_DECISION" = "APPROVED" ]; then + echo "PR #$PR_NUMBER is approved" + elif [ "$REVIEW_DECISION" = "null" ] || [ -z "$REVIEW_DECISION" ]; then + # Bot approval fallback when gh pr list leaves reviewDecision empty (#438) APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ + --paginate \ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ --jq '[.[] | select(.state == "APPROVED")] | length') if [ "$APPROVED_COUNT" -eq 0 ]; then @@ -379,6 +386,9 @@ jobs: exit 1 fi echo "reviewDecision='$REVIEW_DECISION' but $APPROVED_COUNT approved review(s) found" + else + echo "ERROR: PR #$PR_NUMBER does not have approvals (status: $REVIEW_DECISION)" + exit 1 fi # Check CI status diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 5e4f37af..b659ec29 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -261,8 +261,12 @@ jobs: echo "ERROR: PR #$PR_NUMBER is still in draft" exit 1 fi - if [ "$REVIEW_DECISION" != "APPROVED" ]; then + if [ "$REVIEW_DECISION" = "APPROVED" ]; then + echo "PR #$PR_NUMBER is approved" + elif [ "$REVIEW_DECISION" = "null" ] || [ -z "$REVIEW_DECISION" ]; then + # Bot approval fallback when gh pr list leaves reviewDecision empty (#438) APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ + --paginate \ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ --jq '[.[] | select(.state == "APPROVED")] | length') if [ "$APPROVED_COUNT" -eq 0 ]; then @@ -270,6 +274,9 @@ jobs: exit 1 fi echo "reviewDecision='$REVIEW_DECISION' but $APPROVED_COUNT approved review(s) found" + else + echo "ERROR: PR #$PR_NUMBER is not approved (status: $REVIEW_DECISION)" + exit 1 fi STATUS_ROLLUP=$(echo "$PR_JSON" | jq -r '.[0].statusCheckRollup // []') From 41258db8da3b9c5ba3718032c424c2acbe57a76c Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 13:00:10 +0100 Subject: [PATCH 172/215] fix(ci): slurp paginated PR reviews before jq count Avoid gh api --paginate with --jq (per-page output). Use --slurp and a single jq pass. Refs: #438 --- .github/workflows/release.yml | 4 ++-- assets/workspace/.github/workflows/release-core.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79676895..d142f013 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -378,9 +378,9 @@ jobs: elif [ "$REVIEW_DECISION" = "null" ] || [ -z "$REVIEW_DECISION" ]; then # Bot approval fallback when gh pr list leaves reviewDecision empty (#438) APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ - --paginate \ + --paginate --slurp \ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ - --jq '[.[] | select(.state == "APPROVED")] | length') + | jq 'add | map(select(.state == "APPROVED")) | length') if [ "$APPROVED_COUNT" -eq 0 ]; then echo "ERROR: PR #$PR_NUMBER does not have approvals (reviewDecision: $REVIEW_DECISION, approved reviews: 0)" exit 1 diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index b659ec29..61021e6a 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -266,9 +266,9 @@ jobs: elif [ "$REVIEW_DECISION" = "null" ] || [ -z "$REVIEW_DECISION" ]; then # Bot approval fallback when gh pr list leaves reviewDecision empty (#438) APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ - --paginate \ + --paginate --slurp \ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ - --jq '[.[] | select(.state == "APPROVED")] | length') + | jq 'add | map(select(.state == "APPROVED")) | length') if [ "$APPROVED_COUNT" -eq 0 ]; then echo "ERROR: PR #$PR_NUMBER is not approved (reviewDecision: $REVIEW_DECISION, approved reviews: 0)" exit 1 From 2aedc0e6524d0dccd47a6874c6bc0ed534f0d249 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 14:53:14 +0100 Subject: [PATCH 173/215] test: add smoke test for dispatch RC number output wiring Refs: #441 --- tests/bats/just.bats | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bats/just.bats b/tests/bats/just.bats index c05b71fe..86f31dec 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -164,3 +164,8 @@ setup() { run bash -lc "awk '/^ core:/{flag=1} /^ extension:/{flag=0} flag {print}' assets/workspace/.github/workflows/release.yml | grep -Fq -- 'actions: write' && awk '/^ core:/{flag=1} /^ extension:/{flag=0} flag {print}' assets/workspace/.github/workflows/release.yml | grep -Fq -- 'pull-requests: read' && awk '/^ publish:/{flag=1} /^ rollback:/{flag=0} flag {print}' assets/workspace/.github/workflows/release.yml | grep -Fq -- 'contents: write' && awk '/^ validate:/{flag=1} /^ finalize:/{flag=0} flag {print}' assets/workspace/.github/workflows/release-core.yml | grep -Fq -- 'pull-requests: read' && awk '/^ finalize:/{flag=1} /^ test:/{flag=0} flag {print}' assets/workspace/.github/workflows/release-core.yml | grep -Fq -- 'actions: write'" assert_success } + +@test "smoke-test dispatch exposes base_version and rc_number for cross-repo RC alignment" { + run bash -lc "grep -Fq -- 'base_version=' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'rc_number=' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'steps.extract.outputs.base_version' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'steps.extract.outputs.rc_number' assets/smoke-test/.github/workflows/repository-dispatch.yml" + assert_success +} From 0e7b874d1fc85e10a76f3add5d298d698e2ffadb Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 14:55:51 +0100 Subject: [PATCH 174/215] test: add smoke test for workspace release rc-number input Refs: #441 --- tests/bats/just.bats | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bats/just.bats b/tests/bats/just.bats index 86f31dec..0ccae4fe 100644 --- a/tests/bats/just.bats +++ b/tests/bats/just.bats @@ -169,3 +169,8 @@ setup() { run bash -lc "grep -Fq -- 'base_version=' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'rc_number=' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'steps.extract.outputs.base_version' assets/smoke-test/.github/workflows/repository-dispatch.yml && grep -Fq -- 'steps.extract.outputs.rc_number' assets/smoke-test/.github/workflows/repository-dispatch.yml" assert_success } + +@test "workspace release workflows accept rc-number for pinned candidate RC" { + run bash -lc "grep -Fq -- 'rc-number:' assets/workspace/.github/workflows/release.yml && grep -Fq -- 'rc_number:' assets/workspace/.github/workflows/release.yml && grep -Fq -- 'rc_number:' assets/workspace/.github/workflows/release-core.yml" + assert_success +} From 632d22b3f09018b337ea2d8fbb951def4d750aa4 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 14:56:21 +0100 Subject: [PATCH 175/215] fix(ci): pass RC number from dispatch to downstream release Refs: #441 --- .../.github/workflows/repository-dispatch.yml | 23 +++++- .../.github/workflows/release-core.yml | 70 ++++++++++++------- .../workspace/.github/workflows/release.yml | 6 ++ 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 141df913..85a69dda 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -17,6 +17,10 @@ name: Repository Dispatch Listener # # NOTE: Changes to this template may require manual redeploy to # vig-os/devcontainer-smoke-test and promotion through PRs until merged to main. +# +# If this repo (or a fork) orchestrates workspace release.yml for candidates, +# pass workflow_dispatch input rc-number= where N is validate.outputs.rc_number +# so downstream candidate tags match the upstream dispatch tag (cross-repo gate). on: # yamllint disable-line rule:truthy repository_dispatch: @@ -41,6 +45,7 @@ jobs: outputs: tag: ${{ steps.extract.outputs.tag }} base_version: ${{ steps.extract.outputs.base_version }} + rc_number: ${{ steps.extract.outputs.rc_number }} release_kind: ${{ steps.extract.outputs.release_kind }} source_repo: ${{ steps.extract.outputs.source_repo }} source_workflow: ${{ steps.extract.outputs.source_workflow }} @@ -111,6 +116,11 @@ jobs: fi BASE_VERSION="$(printf '%s' "${TAG}" | sed 's/-rc[0-9]*$//')" + RC_NUMBER="" + if printf '%s' "${TAG}" | grep -Eq -- '-rc[0-9]+$'; then + RC_NUMBER="$(printf '%s' "${TAG}" | sed 's/^.*-rc//')" + fi + EFFECTIVE_SOURCE_RUN_URL="${SOURCE_RUN_URL}" if [ -z "${EFFECTIVE_SOURCE_RUN_URL}" ] && [ -n "${SOURCE_REPO}" ] && [ -n "${SOURCE_RUN_ID}" ]; then EFFECTIVE_SOURCE_RUN_URL="${GITHUB_SERVER_URL}/${SOURCE_REPO}/actions/runs/${SOURCE_RUN_ID}" @@ -118,6 +128,7 @@ jobs: echo "tag=${TAG}" >> "${GITHUB_OUTPUT}" echo "base_version=${BASE_VERSION}" >> "${GITHUB_OUTPUT}" + echo "rc_number=${RC_NUMBER}" >> "${GITHUB_OUTPUT}" echo "release_kind=${EFFECTIVE_RELEASE_KIND}" >> "${GITHUB_OUTPUT}" echo "source_repo=${SOURCE_REPO}" >> "${GITHUB_OUTPUT}" echo "source_workflow=${SOURCE_WORKFLOW}" >> "${GITHUB_OUTPUT}" @@ -593,13 +604,19 @@ jobs: env: GH_TOKEN: ${{ steps.generate_release_token.outputs.token }} BASE_VERSION: ${{ needs.validate.outputs.base_version }} + RC_NUMBER: ${{ needs.validate.outputs.rc_number }} RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} run: | set -euo pipefail + EXTRA=() + if [ "${RELEASE_KIND}" = "candidate" ] && [ -n "${RC_NUMBER}" ]; then + EXTRA=( -f "rc-number=${RC_NUMBER}" ) + fi gh workflow run release.yml \ --ref "${WORKFLOW_REF}" \ -f version="${BASE_VERSION}" \ - -f release-kind="${RELEASE_KIND}" + -f release-kind="${RELEASE_KIND}" \ + "${EXTRA[@]}" - name: Wait for release workflow completion env: @@ -711,6 +728,8 @@ jobs: - name: Write source context summary env: TAG: ${{ needs.validate.outputs.tag }} + BASE_VERSION: ${{ needs.validate.outputs.base_version }} + RC_NUMBER: ${{ needs.validate.outputs.rc_number }} SOURCE_REPO: ${{ needs.validate.outputs.source_repo }} SOURCE_WORKFLOW: ${{ needs.validate.outputs.source_workflow }} SOURCE_RUN_ID: ${{ needs.validate.outputs.source_run_id }} @@ -722,6 +741,8 @@ jobs: echo "## Source Dispatch Context" echo "" echo "- Tag: ${TAG:-n/a}" + echo "- Base version: ${BASE_VERSION:-n/a}" + echo "- RC number (suffix): ${RC_NUMBER:-n/a}" echo "- Source Repo: ${SOURCE_REPO:-n/a}" echo "- Source Workflow: ${SOURCE_WORKFLOW:-n/a}" echo "- Source Run ID: ${SOURCE_RUN_ID:-n/a}" diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 61021e6a..8b89c65c 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -27,6 +27,11 @@ on: # yamllint disable-line rule:truthy required: false default: "41898282+github-actions[bot]@users.noreply.github.com" type: string + rc_number: + description: "Candidate RC index (e.g. 21 for X.Y.Z-rc21). Omit to auto-increment from existing tags." + required: false + default: "" + type: string secrets: token: required: false @@ -183,36 +188,51 @@ jobs: env: VERSION: ${{ steps.vars.outputs.version }} RELEASE_KIND: ${{ steps.vars.outputs.release_kind }} + INPUT_RC_NUMBER: ${{ inputs.rc_number }} run: | set -euo pipefail NEXT_RC="" if [ "$RELEASE_KIND" = "candidate" ]; then - TAG_PATTERN="${VERSION}-rc*" - EXISTING_TAGS=$(git ls-remote --tags --refs origin "$TAG_PATTERN" | awk '{print $2}' | sed 's#refs/tags/##') - - MAX_RC=0 - if [ -n "$EXISTING_TAGS" ]; then - while IFS= read -r tag; do - [ -z "$tag" ] && continue - if [ "${tag#${VERSION}-rc}" = "$tag" ]; then - echo "ERROR: Malformed candidate tag detected: $tag" - echo "Expected format: ${VERSION}-rcN" - exit 1 - fi - rc_num="${tag#${VERSION}-rc}" - if ! echo "$rc_num" | grep -qE '^[0-9]+$'; then - echo "ERROR: Malformed candidate tag detected: $tag" - echo "Expected format: ${VERSION}-rcN" - exit 1 - fi - if [ "$rc_num" -gt "$MAX_RC" ]; then - MAX_RC="$rc_num" - fi - done <<< "$EXISTING_TAGS" + if [ -n "${INPUT_RC_NUMBER}" ]; then + if ! printf '%s' "${INPUT_RC_NUMBER}" | grep -qE '^[0-9]+$'; then + echo "ERROR: rc_number must be a non-negative integer (got '${INPUT_RC_NUMBER}')" + exit 1 + fi + if [ "${INPUT_RC_NUMBER}" -lt 1 ]; then + echo "ERROR: rc_number must be >= 1 (got '${INPUT_RC_NUMBER}')" + exit 1 + fi + NEXT_RC="${INPUT_RC_NUMBER}" + PUBLISH_VERSION="${VERSION}-rc${NEXT_RC}" + echo "Using explicit rc_number=$NEXT_RC -> publish_version=$PUBLISH_VERSION" + else + TAG_PATTERN="${VERSION}-rc*" + EXISTING_TAGS=$(git ls-remote --tags --refs origin "$TAG_PATTERN" | awk '{print $2}' | sed 's#refs/tags/##') + + MAX_RC=0 + if [ -n "$EXISTING_TAGS" ]; then + while IFS= read -r tag; do + [ -z "$tag" ] && continue + if [ "${tag#${VERSION}-rc}" = "$tag" ]; then + echo "ERROR: Malformed candidate tag detected: $tag" + echo "Expected format: ${VERSION}-rcN" + exit 1 + fi + rc_num="${tag#${VERSION}-rc}" + if ! echo "$rc_num" | grep -qE '^[0-9]+$'; then + echo "ERROR: Malformed candidate tag detected: $tag" + echo "Expected format: ${VERSION}-rcN" + exit 1 + fi + if [ "$rc_num" -gt "$MAX_RC" ]; then + MAX_RC="$rc_num" + fi + done <<< "$EXISTING_TAGS" + fi + + NEXT_RC=$((MAX_RC + 1)) + PUBLISH_VERSION="${VERSION}-rc${NEXT_RC}" fi - - NEXT_RC=$((MAX_RC + 1)) - PUBLISH_VERSION="${VERSION}-rc${NEXT_RC}" else PUBLISH_VERSION="$VERSION" fi diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index e54c182b..81b097ee 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -27,6 +27,11 @@ on: # yamllint disable-line rule:truthy required: false default: "41898282+github-actions[bot]@users.noreply.github.com" type: string + rc-number: + description: "Candidate RC index (e.g. 21 for X.Y.Z-rc21). Omit to auto-increment from existing tags." + required: false + default: "" + type: string concurrency: group: release @@ -68,6 +73,7 @@ jobs: dry_run: ${{ inputs.dry-run }} git_user_name: ${{ inputs.git-user-name }} git_user_email: ${{ inputs.git-user-email }} + rc_number: ${{ inputs.rc-number || '' }} secrets: inherit extension: From 5478b95e2aff751670b7a37ce139393f76005f59 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 14:58:02 +0100 Subject: [PATCH 176/215] docs: document RC number passthrough for cross-repo gate Refs: #441 --- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ docs/CROSS_REPO_RELEASE_GATE.md | 2 ++ docs/DOWNSTREAM_RELEASE.md | 2 +- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4801313f..c86e2c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,6 +187,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates - **Release validation fails when bot approves PR** ([#438](https://github.com/vig-os/devcontainer/issues/438)) - Add fallback to individual PR review check when `reviewDecision` is empty (bot approvals not counted by branch protection) +- **Downstream candidate RC tag can match upstream dispatch** ([#441](https://github.com/vig-os/devcontainer/issues/441)) + - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only + - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 4801313f..c86e2c9c 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -187,6 +187,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates - **Release validation fails when bot approves PR** ([#438](https://github.com/vig-os/devcontainer/issues/438)) - Add fallback to individual PR review check when `reviewDecision` is empty (bot approvals not counted by branch protection) +- **Downstream candidate RC tag can match upstream dispatch** ([#441](https://github.com/vig-os/devcontainer/issues/441)) + - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only + - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` ### Security diff --git a/docs/CROSS_REPO_RELEASE_GATE.md b/docs/CROSS_REPO_RELEASE_GATE.md index c5fc2484..cf529e0e 100644 --- a/docs/CROSS_REPO_RELEASE_GATE.md +++ b/docs/CROSS_REPO_RELEASE_GATE.md @@ -57,6 +57,8 @@ The receiver workflow (`assets/smoke-test/.github/workflows/repository-dispatch. 4. idempotency checks when a release object already exists 5. preflight validation that required downstream workflow IDs are resolvable on the dispatch ref before orchestration starts +If the validation repository also runs the shipped workspace `release.yml` workflow for a **candidate** (separate from publishing a release for the dispatched tag), pass workflow input `rc-number` set to the numeric RC suffix of `client_payload.tag` (for example `21` for `0.3.1-rc21`). That keeps the downstream candidate tag aligned with the upstream publish tag and satisfies the orchestrator’s latest-RC gate. The smoke-test template exposes this value as job output `needs.validate.outputs.rc_number`. + ### Gate Checks in the Orchestrator The orchestrator validates: diff --git a/docs/DOWNSTREAM_RELEASE.md b/docs/DOWNSTREAM_RELEASE.md index 9c08b930..f4a437ee 100644 --- a/docs/DOWNSTREAM_RELEASE.md +++ b/docs/DOWNSTREAM_RELEASE.md @@ -20,7 +20,7 @@ On failure, the orchestrator runs a single consolidated rollback that resets the `release.yml` supports two release modes via `release_kind`: -- `candidate` (default): computes and publishes the next `X.Y.Z-rcN` tag as a GitHub pre-release +- `candidate` (default): computes and publishes the next `X.Y.Z-rcN` tag as a GitHub pre-release (or use optional `rc-number` to pin `N` when orchestrating from an upstream dispatch; see `docs/CROSS_REPO_RELEASE_GATE.md`) - `final`: publishes `X.Y.Z`, finalizes `CHANGELOG.md` release date, and runs `sync-issues` Candidate mode keeps release branch content unchanged (no CHANGELOG date finalization). Final mode performs changelog finalization before publish. From 2cb7957874dabc67a2c3c2ca71e2949eedd2c5aa Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 15:27:32 +0100 Subject: [PATCH 177/215] fix(ci): align error message and comment wording per review Refs: #441 --- assets/smoke-test/.github/workflows/repository-dispatch.yml | 2 +- assets/workspace/.github/workflows/release-core.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index 85a69dda..bc12e5c7 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -19,7 +19,7 @@ name: Repository Dispatch Listener # vig-os/devcontainer-smoke-test and promotion through PRs until merged to main. # # If this repo (or a fork) orchestrates workspace release.yml for candidates, -# pass workflow_dispatch input rc-number= where N is validate.outputs.rc_number +# pass workflow_dispatch input rc-number= where N is needs.validate.outputs.rc_number # so downstream candidate tags match the upstream dispatch tag (cross-repo gate). on: # yamllint disable-line rule:truthy diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 8b89c65c..1bd1338b 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -195,7 +195,7 @@ jobs: if [ "$RELEASE_KIND" = "candidate" ]; then if [ -n "${INPUT_RC_NUMBER}" ]; then if ! printf '%s' "${INPUT_RC_NUMBER}" | grep -qE '^[0-9]+$'; then - echo "ERROR: rc_number must be a non-negative integer (got '${INPUT_RC_NUMBER}')" + echo "ERROR: rc_number must be a positive integer (got '${INPUT_RC_NUMBER}')" exit 1 fi if [ "${INPUT_RC_NUMBER}" -lt 1 ]; then From f9c931b224f945721bbb1fae156ec929bcf8a9a9 Mon Sep 17 00:00:00 2001 From: "commit-action-bot[bot]" <248498966+commit-action-bot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:01:54 +0000 Subject: [PATCH 178/215] chore: sync issues and PRs --- docs/issues/issue-430.md | 93 +++++++++++ docs/issues/issue-432.md | 90 +++++++++++ docs/issues/issue-433.md | 57 +++++++ docs/issues/issue-434.md | 77 +++++++++ docs/issues/issue-436.md | 52 +++++++ docs/issues/issue-438.md | 89 +++++++++++ docs/issues/issue-440.md | 60 +++++++ docs/issues/issue-441.md | 51 ++++++ docs/pull-requests/pr-342.md | 140 +++++++++++++++++ docs/pull-requests/pr-435.md | 173 +++++++++++++++++++++ docs/pull-requests/pr-437.md | 99 ++++++++++++ docs/pull-requests/pr-439.md | 293 +++++++++++++++++++++++++++++++++++ docs/pull-requests/pr-442.md | 209 +++++++++++++++++++++++++ 13 files changed, 1483 insertions(+) create mode 100644 docs/issues/issue-430.md create mode 100644 docs/issues/issue-432.md create mode 100644 docs/issues/issue-433.md create mode 100644 docs/issues/issue-434.md create mode 100644 docs/issues/issue-436.md create mode 100644 docs/issues/issue-438.md create mode 100644 docs/issues/issue-440.md create mode 100644 docs/issues/issue-441.md create mode 100644 docs/pull-requests/pr-342.md create mode 100644 docs/pull-requests/pr-435.md create mode 100644 docs/pull-requests/pr-437.md create mode 100644 docs/pull-requests/pr-439.md create mode 100644 docs/pull-requests/pr-442.md diff --git a/docs/issues/issue-430.md b/docs/issues/issue-430.md new file mode 100644 index 00000000..b48c2119 --- /dev/null +++ b/docs/issues/issue-430.md @@ -0,0 +1,93 @@ +--- +type: issue +state: closed +created: 2026-03-24T15:08:59Z +updated: 2026-03-25T06:49:19Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/430 +comments: 1 +labels: bug +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:36.673Z +--- + +# [Issue 430]: [Smoke-test dispatch failed for 0.3.1-rc17](https://github.com/vig-os/devcontainer/issues/430) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc17` +- release_kind: `candidate` +- source_repo: `unknown` +- source_workflow: `unknown` +- source_run_id: `unknown` +- source_run_url: n/a +- source_sha: `unknown` +- correlation_id: `unknown` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23496299850 +- deploy PR: https://github.com/vig-os/devcontainer-smoke-test/pull/79 +- release PR: https://github.com/vig-os/devcontainer-smoke-test/pull/80 + +## Job results +- validate: `success` +- deploy: `success` +- wait-deploy-merge: `success` +- cleanup-release: `success` +- trigger-prepare-release: `success` +- ready-release-pr: `success` +- trigger-release: `failure` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Re-dispatch using a new RC tag/version once root cause is fixed. +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 24, 2026 at 03:31 PM_ + +## RCA: smoke-test dispatch failed at downstream `release.yml` + +### Summary + +The upstream release for `0.3.1-rc17` succeeded. The failure was in **downstream** orchestration: `repository-dispatch` run [#29](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23496299850) failed in **`Trigger and wait for release workflow`** because the **`Release`** workflow run [#4](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23496632796) failed in **`Release Core / Validate Release Core`** on step **Find and verify PR** ([job log](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23496632796/job/68379524129)). + +### Root cause + +`release-core.yml` (workspace template) **requires** the release PR to have `reviewDecision == APPROVED` before validation passes: + +- Source: [`release-core.yml` — “Find and verify PR”](https://github.com/vig-os/devcontainer/blob/main/assets/workspace/.github/workflows/release-core.yml) (`gh pr list` → reject draft, reject non-`APPROVED`, reject failed CI rollup). + +The smoke-test **`repository-dispatch.yml`** flow **never approves** the release PR: it locates the PR, marks it ready, applies `release-kind:*`, then immediately dispatches `release.yml` on `dev`. There is no `gh pr review --approve` (or equivalent) step. + +So when **Release** ran, PR [#80](https://github.com/vig-os/devcontainer-smoke-test/pull/80) was open and ready but had **no reviews** → `reviewDecision` was not `APPROVED` → **Find and verify PR** exited non-zero. + +### Failure chain + +1. Dispatch [#29](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23496299850): through **`Prepare release PR`** = success (PR #80 found, ready, labeled). +2. **`Trigger and wait for release workflow`**: dispatches `release.yml` on `dev`. +3. Release [#4](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23496632796): **`Validate Release Core`** → **Find and verify PR** → exit **1** (not approved). +4. **`merge-release-pr`** skipped; **`summary`** / notify path opened this issue. + +### Suggested fix (automation) + +Add a step in [`repository-dispatch.yml`](https://github.com/vig-os/devcontainer/blob/main/assets/smoke-test/.github/workflows/repository-dispatch.yml) (smoke-test template) **after** marking the release PR ready and **before** triggering `release.yml`: approve the PR with the same release app token used for other PR operations (`gh pr review --approve --body "..."`), so behavior matches the human approval gate assumed by `release-core.yml`. + +### Immediate remediation + +PR [#80](https://github.com/vig-os/devcontainer-smoke-test/pull/80) is in a good state to proceed manually: + +1. **Approve** PR #80 (or use `gh pr review 80 --approve` with appropriate permissions), then **re-run** the failed **`Release`** workflow on `dev` with inputs `version=0.3.1` and `release-kind=candidate` (same as the dispatch), **or** +2. Land the automation fix above and **re-dispatch** with a new RC tag once the smoke-test repo has the updated workflow. + + diff --git a/docs/issues/issue-432.md b/docs/issues/issue-432.md new file mode 100644 index 00000000..7c6a4450 --- /dev/null +++ b/docs/issues/issue-432.md @@ -0,0 +1,90 @@ +--- +type: issue +state: closed +created: 2026-03-24T16:28:40Z +updated: 2026-03-25T09:09:04Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/432 +comments: 2 +labels: bug +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:36.217Z +--- + +# [Issue 432]: [Smoke-test dispatch failed for 0.3.1-rc18](https://github.com/vig-os/devcontainer/issues/432) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc18` +- release_kind: `candidate` +- source_repo: `vig-os/devcontainer` +- source_workflow: `Release` +- source_run_id: `23499741825` +- source_run_url: https://github.com/vig-os/devcontainer/actions/runs/23499741825 +- source_sha: `27b128975c1a7591e658796ba8ca023e9dd156cb` +- correlation_id: `vig-os/devcontainer:23499741825:0.3.1-rc18` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23500334272 +- deploy PR: https://github.com/vig-os/devcontainer-smoke-test/pull/84 +- release PR: https://github.com/vig-os/devcontainer-smoke-test/pull/85 + +## Job results +- validate: `success` +- deploy: `success` +- wait-deploy-merge: `success` +- cleanup-release: `success` +- trigger-prepare-release: `success` +- ready-release-pr: `failure` +- trigger-release: `skipped` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Re-dispatch using a new RC tag/version once root cause is fixed. +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 25, 2026 at 06:48 AM_ + +## Root cause analysis + +### What failed +The downstream run failed in job **`ready-release-pr`** ("Prepare release PR") on step **Approve release PR for automated dispatch** — [job log](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23500334272/job/68394016524). Earlier jobs (`validate`, `deploy`, `wait-deploy-merge`, `cleanup-release`, `trigger-prepare-release`) succeeded; `trigger-release` and `merge-release-pr` were skipped because this job failed. + +### Why +That step runs `gh pr review --approve` using the default `GITHUB_TOKEN` (`github.token`). GitHub does not allow that token to approve pull requests unless the repository (or organization) has **Allow GitHub Actions to create and approve pull requests** enabled under Actions workflow permissions. + +Other steps in the same job use the Release App token for PR discovery/edits; only the approve step uses `github.token`, which matches "everything worked until approve." + +### Culprit +Configuration/policy on **`vig-os/devcontainer-smoke-test`**: the setting above is off (or not inherited), so automated approval is rejected and the step exits 1. + +### Not the cause +- RC tag / dispatch payload / upstream `vig-os/devcontainer` publish path — dispatch and prior downstream steps completed successfully. + +### Fix options +1. **Repo/org setting:** Enable *Allow GitHub Actions to create and approve pull requests* for the smoke-test repo (or org). +2. **Workflow change:** Approve with a token that branch protection allows (e.g. redesign the step in `assets/smoke-test/.github/workflows/repository-dispatch.yml` and redeploy the template to the smoke-test repo), if you prefer not to enable that setting. + +Template reference: `assets/smoke-test/.github/workflows/repository-dispatch.yml` (`ready-release-pr` → Approve release PR for automated dispatch). + +--- + +# [Comment #2]() by [c-vigo]() + +_Posted on March 25, 2026 at 09:09 AM_ + +> * **Repo/org setting:** Enable _Allow GitHub Actions to create and approve pull requests_ for the smoke-test repo (or org). + +implemented this. + diff --git a/docs/issues/issue-433.md b/docs/issues/issue-433.md new file mode 100644 index 00000000..4bebae7a --- /dev/null +++ b/docs/issues/issue-433.md @@ -0,0 +1,57 @@ +--- +type: issue +state: closed +created: 2026-03-25T06:57:08Z +updated: 2026-03-25T06:59:26Z +author: github-actions[bot] +author_url: https://github.com/github-actions[bot] +url: https://github.com/vig-os/devcontainer/issues/433 +comments: 1 +labels: bug, area:ci +assignees: none +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:35.704Z +--- + +# [Issue 433]: [Release 0.3.1-rc19 failed -- automatic rollback](https://github.com/vig-os/devcontainer/issues/433) + + +Release 0.3.1-rc19 encountered an error during the automated release workflow. + +**Failed Jobs:** build-and-test, publish + +**Workflow Run:** [View logs](https://github.com/vig-os/devcontainer/actions/runs/23528803329) + +**Release PR:** #342 + +**Rollback Results:** +- Branch rollback: success +- Tag deletion: success + +**Actions Taken:** +- Release branch rolled back to pre-finalization state +- Release tag deleted (if created) +- This issue created for investigation + +**Manual Cleanup May Be Needed:** +- If images were pushed to GHCR before the failure, they are **not** automatically deleted. Check `ghcr.io/vig-os/devcontainer:0.3.1-rc19-*` and remove any orphaned images manually. + +**Next Steps:** +1. Review the workflow logs to identify the root cause +2. Check rollback results above; fix any partial rollback manually +3. Fix the issue on the release branch +4. Re-run the workflow when ready + +For details, check the workflow run linked above. + +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 25, 2026 at 06:59 AM_ + +Network error during build job + diff --git a/docs/issues/issue-434.md b/docs/issues/issue-434.md new file mode 100644 index 00000000..8ac0c837 --- /dev/null +++ b/docs/issues/issue-434.md @@ -0,0 +1,77 @@ +--- +type: issue +state: closed +created: 2026-03-25T07:01:08Z +updated: 2026-03-25T09:07:58Z +author: github-actions[bot] +author_url: https://github.com/github-actions[bot] +url: https://github.com/vig-os/devcontainer/issues/434 +comments: 1 +labels: bug, area:ci +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:35.297Z +--- + +# [Issue 434]: [Release 0.3.1-rc19 failed -- automatic rollback](https://github.com/vig-os/devcontainer/issues/434) + + +Release 0.3.1-rc19 encountered an error during the automated release workflow. + +**Failed Jobs:** build-and-test, publish + +**Workflow Run:** [View logs](https://github.com/vig-os/devcontainer/actions/runs/23528803329) + +**Release PR:** #342 + +**Rollback Results:** +- Branch rollback: success +- Tag deletion: success + +**Actions Taken:** +- Release branch rolled back to pre-finalization state +- Release tag deleted (if created) +- This issue created for investigation + +**Manual Cleanup May Be Needed:** +- If images were pushed to GHCR before the failure, they are **not** automatically deleted. Check `ghcr.io/vig-os/devcontainer:0.3.1-rc19-*` and remove any orphaned images manually. + +**Next Steps:** +1. Review the workflow logs to identify the root cause +2. Check rollback results above; fix any partial rollback manually +3. Fix the issue on the release branch +4. Re-run the workflow when ready + +For details, check the workflow run linked above. + +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 25, 2026 at 07:50 AM_ + +## Root cause analysis (local repro) + +**Symptom:** `build-and-test` / image build fails during the Containerfile `RUN` that installs Cursor Agent. + +**Repro (local):** Full `podman build` (or equivalent) reaches **STEP 32/57** and exits non-zero. + +**Failure chain** + +1. The Dockerfile runs `curl -fsSL https://cursor.com/install | bash` then `agent --version`. +2. The installer detects `linux/x64` and tries to download the agent package from a versioned URL under `downloads.cursor.com` (in this repro: `.../lab/2026.03.24-933d5a6/linux/x64/agent-cli-package.tar.gz`). +3. **`curl` gets HTTP 403** on that artifact URL (`The requested URL returned error: 403`). +4. The stream is not a valid tarball → `gzip` / `tar` error → installer reports download failure → **RUN fails** → entire image build fails. + +**Root cause** + +The **Cursor Agent CLI install path used in the image is not reliably fetchable in CI/unauthenticated contexts**: the resolved download URL returns **403 Forbidden**. That is an external availability/access policy issue for the artifact host (or the `/lab/` path), not a transient flake in our repo scripts alone. + +**Implication for fix direction** + +- Pin or replace how Cursor Agent is installed for release builds (e.g. a documented public artifact, vendored tarball, optional skip in CI, or an install method that does not depend on URLs that return 403 without credentials). +- Confirm with Cursor whether `downloads.cursor.com/.../lab/...` is intended to be publicly accessible for automated Docker builds. + diff --git a/docs/issues/issue-436.md b/docs/issues/issue-436.md new file mode 100644 index 00000000..4a05c840 --- /dev/null +++ b/docs/issues/issue-436.md @@ -0,0 +1,52 @@ +--- +type: issue +state: closed +created: 2026-03-25T09:44:40Z +updated: 2026-03-25T10:19:23Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/436 +comments: 0 +labels: bug +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:34.822Z +--- + +# [Issue 436]: [Smoke-test dispatch failed for 0.3.1-rc19](https://github.com/vig-os/devcontainer/issues/436) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc19` +- release_kind: `candidate` +- source_repo: `vig-os/devcontainer` +- source_workflow: `Release` +- source_run_id: `23534073351` +- source_run_url: https://github.com/vig-os/devcontainer/actions/runs/23534073351 +- source_sha: `1aafb813dbb6a83927e6d1c5dc2aea52ec3b7f2d` +- correlation_id: `vig-os/devcontainer:23534073351:0.3.1-rc19` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23534621212 +- deploy PR: not created +- release PR: not created + +## Job results +- validate: `success` +- deploy: `failure` +- wait-deploy-merge: `skipped` +- cleanup-release: `skipped` +- trigger-prepare-release: `skipped` +- ready-release-pr: `skipped` +- trigger-release: `skipped` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Re-dispatch using a new RC tag/version once root cause is fixed. diff --git a/docs/issues/issue-438.md b/docs/issues/issue-438.md new file mode 100644 index 00000000..67d85b8c --- /dev/null +++ b/docs/issues/issue-438.md @@ -0,0 +1,89 @@ +--- +type: issue +state: closed +created: 2026-03-25T10:53:15Z +updated: 2026-03-25T12:36:37Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/438 +comments: 1 +labels: bug +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:34.398Z +--- + +# [Issue 438]: [Smoke-test dispatch failed for 0.3.1-rc20](https://github.com/vig-os/devcontainer/issues/438) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc20` +- release_kind: `candidate` +- source_repo: `vig-os/devcontainer` +- source_workflow: `Release` +- source_run_id: `23536456528` +- source_run_url: https://github.com/vig-os/devcontainer/actions/runs/23536456528 +- source_sha: `51ad6c63e06622e218a7193edf7c4285d687cedb` +- correlation_id: `vig-os/devcontainer:23536456528:0.3.1-rc20` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23537124050 +- deploy PR: https://github.com/vig-os/devcontainer-smoke-test/pull/86 +- release PR: https://github.com/vig-os/devcontainer-smoke-test/pull/87 + +## Job results +- validate: `success` +- deploy: `success` +- wait-deploy-merge: `success` +- cleanup-release: `success` +- trigger-prepare-release: `success` +- ready-release-pr: `success` +- trigger-release: `failure` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Re-dispatch using a new RC tag/version once root cause is fixed. +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 25, 2026 at 10:58 AM_ + +## RCA — Smoke-test dispatch for `0.3.1-rc20` + +### Summary + +The failure was **not** in the devcontainer source Release workflow or in the RC image publish for this tag. Smoke-test orchestration progressed through deploy, merge wait, cleanup, `prepare-release`, and "ready release PR", then failed while **waiting for the downstream `Release` workflow** on `dev`. + +### Failure chain + +1. [Repository Dispatch Listener — run 23537124050](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23537124050): job **Trigger and wait for release workflow** → step **Wait for release workflow completion** → `release workflow concluded with 'failure'`. +2. That run is [Release — 23537271118](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23537271118). +3. Failing job: **Release Core / Validate Release Core** → step **Find and verify PR**. + +### Root cause + +The validate step failed with: + +`ERROR: PR #87 is not approved (status: )` + +So the gate requires an **approved** PR in the sense of whatever it reads from the GitHub API (`reviewDecision`), but for [PR #87](https://github.com/vig-os/devcontainer-smoke-test/pull/87) the aggregated **`reviewDecision` was empty** even though **`github-actions` had already submitted an `APPROVED` review** as part of the same orchestration. That usually means a **mismatch between the workflow's check and GitHub's `reviewDecision` semantics** (e.g. protection rules / who counts as an approver), not that the RC tag failed to deploy. + +Rollback / tracking: [devcontainer-smoke-test#88](https://github.com/vig-os/devcontainer-smoke-test/issues/88). + +### Likely fix (downstream repo) + +Adjust **`vig-os/devcontainer-smoke-test`** release validation (or branch protection / bot approval rules) so the **Find and verify PR** step accepts the same approval signal the orchestration actually produces—either by fixing how approval is verified (e.g. not relying solely on `reviewDecision` when rules don't populate it) or by aligning protection rules with the automated approver. + +### Not the root cause + +- [Source Release run 23536456528](https://github.com/vig-os/devcontainer/actions/runs/23536456528) / tag `0.3.1-rc20` as the **primary** failure point for this incident — the break is in **smoke-test's Release validation** after a successful-looking prepare path. + + diff --git a/docs/issues/issue-440.md b/docs/issues/issue-440.md new file mode 100644 index 00000000..a714ca9d --- /dev/null +++ b/docs/issues/issue-440.md @@ -0,0 +1,60 @@ +--- +type: issue +state: closed +created: 2026-03-25T12:40:53Z +updated: 2026-03-25T13:01:47Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/440 +comments: 1 +labels: bug +assignees: none +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:33.929Z +--- + +# [Issue 440]: [Smoke-test dispatch failed for 0.3.1-rc21](https://github.com/vig-os/devcontainer/issues/440) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc21` +- release_kind: `candidate` +- source_repo: `vig-os/devcontainer` +- source_workflow: `Release` +- source_run_id: `23540969720` +- source_run_url: https://github.com/vig-os/devcontainer/actions/runs/23540969720 +- source_sha: `a1bcd94c162e2ba9c5f04fb0f95470a223a85004` +- correlation_id: `vig-os/devcontainer:23540969720:0.3.1-rc21` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23541441516 +- deploy PR: not created +- release PR: not created + +## Job results +- validate: `success` +- deploy: `failure` +- wait-deploy-merge: `skipped` +- cleanup-release: `skipped` +- trigger-prepare-release: `skipped` +- ready-release-pr: `skipped` +- trigger-release: `skipped` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Re-dispatch using a new RC tag/version once root cause is fixed. +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 25, 2026 at 01:01 PM_ + +Missing manual deployment of latest workflows, fixed [here](https://github.com/vig-os/devcontainer-smoke-test/pull/89) + diff --git a/docs/issues/issue-441.md b/docs/issues/issue-441.md new file mode 100644 index 00000000..6ea7c7ca --- /dev/null +++ b/docs/issues/issue-441.md @@ -0,0 +1,51 @@ +--- +type: issue +state: closed +created: 2026-03-25T13:26:15Z +updated: 2026-03-25T14:48:47Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/issues/441 +comments: 0 +labels: bug, priority:high, area:ci, effort:small, semver:patch +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-25T17:01:33.498Z +--- + +# [Issue 441]: [[BUG] Downstream smoke-test RC tag number does not match upstream](https://github.com/vig-os/devcontainer/issues/441) + +## Description + +When upstream `vig-os/devcontainer` publishes a candidate (e.g. `0.3.1-rc21`) and dispatches to `devcontainer-smoke-test`, the downstream release workflow creates `0.3.1-rc1` instead of matching the upstream tag. The final release gate in upstream `release.yml` expects a downstream pre-release at the same RC tag and will 404 otherwise. + +## Steps to Reproduce + +1. Publish an upstream RC after several prior RCs (e.g. `0.3.1-rc21`). +2. Let smoke-test `repository_dispatch` orchestration run `release.yml` on the smoke-test repo. +3. Inspect tags/releases on `vig-os/devcontainer-smoke-test`. + +## Expected Behavior + +Downstream tag and GitHub Release should use the same RC number as the upstream dispatch (`0.3.1-rc21`). + +## Actual Behavior + +Downstream auto-increments from local tags only, producing `0.3.1-rc1` when no prior RC tags exist there. + +## Environment + +- GitHub Actions, `repository-dispatch.yml` + workspace `release.yml` / `release-core.yml` + +## Changelog Category + +Fixed + +## Possible Solution + +Pass optional `rc-number` from dispatch payload through `repository-dispatch.yml` into downstream `release.yml` / `release-core.yml`; when set, use it instead of scanning tags for the next RC. + +- [ ] TDD compliance (see .cursor/rules/tdd.mdc) diff --git a/docs/pull-requests/pr-342.md b/docs/pull-requests/pr-342.md new file mode 100644 index 00000000..e9dbcb12 --- /dev/null +++ b/docs/pull-requests/pr-342.md @@ -0,0 +1,140 @@ +--- +type: pull_request +state: open +branch: release/0.3.1 → main +created: 2026-03-17T14:13:31Z +updated: 2026-03-25T14:48:16Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/pull/342 +comments: 2 +labels: none +assignees: none +milestone: none +projects: none +synced: 2026-03-25T17:01:39.447Z +--- + +# [PR 342](https://github.com/vig-os/devcontainer/pull/342) chore: release 0.3.1 + +## Release 0.3.1 + +This PR prepares release 0.3.1 for merge to main. + +### Release Content + +## [0.3.1] - TBD + +### Added + +- **Split downstream release workflow with project-owned extension hook** ([#326](https://github.com/vig-os/devcontainer/issues/326)) + - Add local `workflow_call` release phases (`release-core.yml`, `release-publish.yml`) and a lightweight `release.yml` orchestrator in `assets/workspace/.github/workflows/` + - Add `release_kind` support with candidate mode (`X.Y.Z-rcN`) and final mode (`X.Y.Z`) in downstream release workflows + - Candidate mode now auto-computes the next RC tag, skips CHANGELOG finalization/sync-issues, and publishes a GitHub pre-release + - Add project-owned `release-extension.yml` stub and preserve it during `init-workspace.sh --force` upgrades + - Add `validate-contract` composite action for single-source contract version validation + - Add downstream release contract documentation and GHCR extension example in `docs/DOWNSTREAM_RELEASE.md` + +### Changed + +- **Dependabot dependency update batch** ([#302](https://github.com/vig-os/devcontainer/pull/302), [#303](https://github.com/vig-os/devcontainer/pull/303), [#305](https://github.com/vig-os/devcontainer/pull/305), [#306](https://github.com/vig-os/devcontainer/pull/306), [#307](https://github.com/vig-os/devcontainer/pull/307), [#308](https://github.com/vig-os/devcontainer/pull/308), [#309](https://github.com/vig-os/devcontainer/pull/309)) + - Bump `@devcontainers/cli` from `0.81.1` to `0.84.0` and `bats-assert` from `v2.2.0` to `v2.2.4` + - Bump GitHub Actions: `actions/download-artifact` (`4.3.0` -> `8.0.1`), `actions/github-script` (`7.1.0` -> `8.0.0`), `actions/attest-build-provenance` (`3.0.0` -> `4.1.0`), `actions/checkout` (`4.3.1` -> `6.0.2`) + - Bump release workflow action pins: `sigstore/cosign-installer` (`4.0.0` -> `4.1.0`) and `anchore/sbom-action` (`0.22.2` -> `0.23.1`) +- **Dependabot dependency update batch** ([#314](https://github.com/vig-os/devcontainer/pull/314), [#315](https://github.com/vig-os/devcontainer/pull/315), [#316](https://github.com/vig-os/devcontainer/pull/316), [#317](https://github.com/vig-os/devcontainer/pull/317)) + - Bump GitHub Actions: `actions/attest-sbom` (`3.0.0` -> `4.0.0`), `actions/upload-artifact` (`4.6.2` -> `7.0.0`), `actions/create-github-app-token` (`2.2.1` -> `3.0.0`) + - Bump `docker/login-action` from `3.7.0` to `4.0.0` + - Bump `just` minor version from `1.46` to `1.47` +- **Node24-ready GitHub Actions pin refresh for shared composite actions** ([#321](https://github.com/vig-os/devcontainer/issues/321)) + - Update Docker build path pins in `build-image` (`docker/setup-buildx-action`, `docker/metadata-action`, `docker/build-push-action`) to Node24-compatible releases + - Set `setup-env` default Node runtime to `24` and upgrade `actions/setup-node` + - Align test composite actions with newer pins (`actions/checkout`, `actions/cache`, `actions/upload-artifact`) +- **Smoke-test dispatch payload now carries source run traceability metadata** ([#289](https://github.com/vig-os/devcontainer/issues/289)) + - Candidate release dispatches now include source repo/workflow/run/SHA metadata plus a deterministic `correlation_id` + - Smoke-test dispatch receiver logs normalized source context, derives source run URL when possible, and writes it to workflow summary output + - Release-cycle docs now define required vs optional dispatch payload keys and the future callback contract path for `publish-candidate` +- **Smoke-test repository dispatch now runs for final releases too** ([#173](https://github.com/vig-os/devcontainer/issues/173)) + - `release.yml` now triggers the existing smoke-test dispatch contract for both `candidate` and `final` release kinds + - Final release summaries and release-cycle documentation now reflect dispatch behavior for both release modes +- **Workspace CI templates now use a single container-based workflow** ([#327](https://github.com/vig-os/devcontainer/issues/327)) + - Consolidate `assets/workspace/.github/workflows/ci.yml` as the canonical CI workflow and remove the obsolete `ci-container.yml` template + - Extract reusable `assets/workspace/.github/actions/resolve-image` and run workspace release tests in the same containerized workflow model + - Update smoke-test and release-cycle documentation to reference the single CI workflow contract +- **Final release now requires downstream RC pre-release gate** ([#331](https://github.com/vig-os/devcontainer/issues/331)) + - Add upstream final-release validation that requires a downstream GitHub pre-release for the latest published RC tag + - Move smoke-test dispatch to a dedicated release job and include `release_kind` in the dispatch payload + - Add downstream `repository-dispatch.yml` template that runs smoke tests and creates pre-release/final release artifacts +- **Ship changelog into workspace payload and smoke-test deploy root** ([#333](https://github.com/vig-os/devcontainer/issues/333)) + - Sync canonical `CHANGELOG.md` into both workspace root and `.devcontainer/` template paths + - Smoke-test dispatch now copies `.devcontainer/CHANGELOG.md` to repository root so deploy output keeps a root changelog +- **Final release now publishes a GitHub Release with finalized notes** ([#310](https://github.com/vig-os/devcontainer/issues/310)) + - Add a final-only publish step in `.github/workflows/release.yml` that creates a GitHub Release for `X.Y.Z` + - Source GitHub Release notes from the finalized `CHANGELOG.md` section and fail the run if notes extraction or release publishing fails +- **Release dispatch and publish ordering hardened for 0.3.1** ([#336](https://github.com/vig-os/devcontainer/issues/336)) + - Make smoke-test dispatch fire-and-forget in `.github/workflows/release.yml` and decouple rollback from downstream completion timing + - Add bounded retries to the final-release downstream RC pre-release gate API check + - Move final GitHub Release creation to the end of publish so artifact publication/signing completes before release object creation + - Add concurrency control to `assets/smoke-test/.github/workflows/repository-dispatch.yml` to prevent overlapping dispatch races + - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released + +### Fixed + +- **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) + - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs + - Release PR body is refreshed from finalized `CHANGELOG.md` + +### Security + +- **Smoke-test dispatch workflow permissions now follow least privilege** ([#340](https://github.com/vig-os/devcontainer/issues/340)) + - Reduce `assets/smoke-test/.github/workflows/repository-dispatch.yml` workflow token permissions from write to read by default + - Grant `contents: write` only to `publish-release`, the single job that creates or edits GitHub Releases + + + +--- +--- + +## Review Threads (1) + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 17, 2026 at 03:12 PM_ + +_File: [`tests/bats/just.bats (line 60 RIGHT)`](https://github.com/vig-os/devcontainer/pull/342#discussion_r2947535831)_ + +```diff +@@ -39,3 +39,38 @@ setup() { + run bash -lc "grep -Fq -- 'git/refs/heads/$RELEASE_BRANCH' .github/workflows/prepare-release.yml" + assert_success + } ++ ++@test "release workflow regenerates docs during finalization" { ++ run bash -lc "grep -Fq -- 'name: Regenerate docs for finalized release' .github/workflows/release.yml" ++ assert_success ++} ++ ++@test "release workflow commits dynamic finalization file paths" { ++ run bash -lc "grep -Fq -- 'id: finalize-files' .github/workflows/release.yml && grep -Fq -- 'steps.finalize-files.outputs.file_paths' .github/workflows/release.yml" ++ assert_success ++} ++ ++@test "prepare-release PR body omits persistent checklist and related sections" { ++ run bash -lc "! awk '/^ - name: Create draft PR to main/{flag=1} /^ - name: Roll back prepare-release side effects on failure/{flag=0} flag {print}' .github/workflows/prepare-release.yml | grep -Fq -- '### Testing Checklist' && ! awk '/^ - name: Create draft PR to main/{flag=1} /^ - name: Roll back prepare-release side effects on failure/{flag=0} flag {print}' .github/workflows/prepare-release.yml | grep -Fq -- '### When Ready to Release' && ! awk '/^ - name: Create draft PR to main/{flag=1} /^ - name: Roll back prepare-release side effects on failure/{flag=0} flag {print}' .github/workflows/prepare-release.yml | grep -Fq -- '### Related'" ++ assert_success ++} ++ ++@test "release workflow refreshes release PR body from changelog" { ++ run bash -lc 'grep -Fq -- "name: Refresh release PR body from finalized changelog" .github/workflows/release.yml && grep -Fq -- "CHANGELOG_CONTENT=\$(sed -n" .github/workflows/release.yml && grep -Fq -- "gh pr edit \"\$PR_NUMBER\" --body-file /tmp/release-pr-body.md" .github/workflows/release.yml' ++ assert_success +``` + +The grep pattern is looking for the literal string `CHANGELOG_CONTENT=\$(sed -n`, but `.github/workflows/release.yml` contains `CHANGELOG_CONTENT=$(sed -n ...)` (no backslash). This makes the test fail even when the workflow step exists. Update the grep needle to match the actual workflow content (or use a regex grep if you intentionally want to match both forms). + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 17, 2026 at 03:15 PM — [link](https://github.com/vig-os/devcontainer/pull/342#discussion_r2947555928) + + Thanks for flagging this. In this test command, the grep needle is inside double quotes passed to `bash -lc`, so `\$` is interpreted as a literal `$` at runtime. That means the actual searched string is `CHANGELOG_CONTENT=$(sed -n`, which matches `.github/workflows/release.yml`. I verified this locally with the same grep invocation (`exit 0`). Marking this as no-change. + +--- + diff --git a/docs/pull-requests/pr-435.md b/docs/pull-requests/pr-435.md new file mode 100644 index 00000000..e1805ce7 --- /dev/null +++ b/docs/pull-requests/pr-435.md @@ -0,0 +1,173 @@ +--- +type: pull_request +state: closed (merged) +branch: bugfix/434-release-031-rc19-failure → release/0.3.1 +created: 2026-03-25T08:45:11Z +updated: 2026-03-25T09:07:41Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/435 +comments: 2 +labels: none +assignees: c-vigo +milestone: none +projects: none +merged: 2026-03-25T09:07:40Z +synced: 2026-03-25T17:01:45.091Z +--- + +# [PR 435](https://github.com/vig-os/devcontainer/pull/435) fix(build): make cursor-agent install resilient to Cursor CDN failures + +## Description + +Release builds failed during image build when the Cursor Agent CLI installer downloaded a versioned tarball from `downloads.cursor.com/lab/...` that returned HTTP 403 (`AccessDenied`), breaking `podman`/`docker` builds and CI (see [#434](https://github.com/vig-os/devcontainer/issues/434)). + +This PR retries the official install three times with backoff, then continues the image build without `agent` if the CDN still fails. `test_cursor_agent_installed` skips when `agent` is missing so the test suite stays green. + +## Type of Change + +- [ ] `feat` -- New feature +- [x] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [ ] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [ ] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- **`Containerfile`**: Replace single-shot `curl … | bash` + `agent --version` with a 3-attempt loop (10s sleep between attempts); on failure, log warning and exit the layer successfully so later build steps run. +- **`tests/test_image.py`**: If `agent --version` fails, `pytest.skip` with a short reason (optional tool when CDN is down). +- **`CHANGELOG.md`** and **`assets/workspace/.devcontainer/CHANGELOG.md`**: Document the behavior under `### Changed` in the `[0.3.1] - TBD` section (synced copy). + +## Changelog Entry + +```markdown +### Changed + +- **cursor-agent install is now resilient to CDN failures** ([#434](https://github.com/vig-os/devcontainer/issues/434)) + - Retries 3 times with backoff before giving up + - Build succeeds without cursor-agent when Cursor's CDN is unavailable +``` + +(Placed under `## [0.3.1] - TBD` → `### Changed` in both changelog files.) + +## Testing + +- [ ] Tests pass locally (`just test`) +- [ ] Manual testing performed (describe below) + +### Manual Testing Details + +N/A (not run for this submission). Recommend `just build` / CI image build to confirm the layer completes when Cursor returns 403. + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [x] I have updated `CHANGELOG.md` under `## [0.3.1] - TBD` (and pasted the entry above) +- [ ] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Additional Notes + +- Upstream / community context: https://forum.cursor.com/t/cursor-cli-cannot-be-installed-installer-tried-to-download-asset-that-403s/155827 +- **Base branch:** `release/0.3.1` (hotfix for the failed RC19 release pipeline). + +Refs: #434 + + + +--- +--- + +## Review Threads (2) + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 08:50 AM_ + +_File: [`Containerfile`](https://github.com/vig-os/devcontainer/pull/435#discussion_r2986732487)_ + +```diff +@@ -150,8 +150,16 @@ RUN set -eux; \ + # Install cursor-agent CLI (installs to ~/.local/bin) + ENV PATH="/root/.local/bin:${PATH}" + RUN set -eux; \ +- curl -fsSL https://cursor.com/install | bash; \ +- agent --version; ++ for attempt in 1 2 3; do \ ++ if curl -fsSL https://cursor.com/install | bash; then \ ++ agent --version; \ ++ exit 0; \ ++ fi; \ +``` + +The retry loop likely won’t behave as intended under `set -e`: if `agent --version` fails (e.g., installer ran but didn’t put `agent` on PATH), the shell will exit immediately and you won’t get to attempts 2/3. Also, because this is a pipeline and the Dockerfile shell is `/bin/sh` (no `pipefail`), a failed `curl` can still result in a successful pipeline exit status, sending you into `agent --version` and then aborting. Consider avoiding the pipe (download to a temp file and run `bash` on it), and make the success condition explicitly require `agent --version` to succeed before exiting/breaking; otherwise continue to the next attempt. +```suggestion + tmp_script="$(mktemp)"; \ + if curl -fsSL https://cursor.com/install -o "${tmp_script}"; then \ + if bash "${tmp_script}"; then \ + if command -v agent >/dev/null 2>&1 && agent --version; then \ + rm -f "${tmp_script}"; \ + exit 0; \ + fi; \ + fi; \ + fi; \ + rm -f "${tmp_script}"; \ +``` + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 08:50 AM_ + +_File: [`Containerfile`](https://github.com/vig-os/devcontainer/pull/435#discussion_r2986732523)_ + +```diff +@@ -150,8 +150,16 @@ RUN set -eux; \ + # Install cursor-agent CLI (installs to ~/.local/bin) + ENV PATH="/root/.local/bin:${PATH}" + RUN set -eux; \ +- curl -fsSL https://cursor.com/install | bash; \ +- agent --version; ++ for attempt in 1 2 3; do \ ++ if curl -fsSL https://cursor.com/install | bash; then \ ++ agent --version; \ ++ exit 0; \ ++ fi; \ ++ echo "cursor-agent install attempt ${attempt} failed, retrying in 10s..."; \ ++ sleep 10; \ ++ done; \ ++ echo "WARNING: cursor-agent install failed after 3 attempts (external CDN issue); skipping"; \ ++ echo "Install manually: curl https://cursor.com/install -fsSL | bash"; +``` + +The manual install command in the log line is not copy/paste-safe as written (`curl https://cursor.com/install -fsSL | bash` mixes option ordering). Consider printing the same command used above, e.g. `curl -fsSL https://cursor.com/install | bash`, so users can reliably run it. +```suggestion + echo "Install manually: curl -fsSL https://cursor.com/install | bash"; +``` + + +--- +--- + +## Commits + +### Commit 1: [7424460](https://github.com/vig-os/devcontainer/commit/74244603ae9b754cdff73b6cc2da995bc2c33669) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 08:33 AM +fix(image): make cursor-agent install resilient to CDN failures, 21 files modified (CHANGELOG.md, Containerfile, assets/workspace/.devcontainer/CHANGELOG.md, tests/test_image.py) + +### Commit 2: [ff34cef](https://github.com/vig-os/devcontainer/commit/ff34cef56555972b49f9cee2f8f188bea2b6a216) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 08:55 AM +fix(image): harden cursor-agent install retry loop, 10 files modified (Containerfile) diff --git a/docs/pull-requests/pr-437.md b/docs/pull-requests/pr-437.md new file mode 100644 index 00000000..832e3ff6 --- /dev/null +++ b/docs/pull-requests/pr-437.md @@ -0,0 +1,99 @@ +--- +type: pull_request +state: closed (merged) +branch: bugfix/436-smoke-test-dispatch-failure → release/0.3.1 +created: 2026-03-25T10:11:35Z +updated: 2026-03-25T10:19:08Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/437 +comments: 0 +labels: none +assignees: none +milestone: none +projects: none +merged: 2026-03-25T10:19:06Z +synced: 2026-03-25T17:01:43.579Z +--- + +# [PR 437](https://github.com/vig-os/devcontainer/pull/437) fix(ci): enable commit-action MAX_ATTEMPTS for transient ref failures + +## Description + +Smoke-test dispatch for `0.3.1-rc19` failed in the deploy job with `Not Found` on `git/refs#get-a-reference` when `commit-action` looked up the freshly created `chore/deploy-*` branch. `commit-action` v0.2.0 already ships bounded retries, but they only run when `MAX_ATTEMPTS` is set; the default is 1 (no retries). This PR sets `MAX_ATTEMPTS: "3"` on every `vig-os/commit-action` step in upstream and workspace templates so transient API propagation errors are retried. + +## Type of Change + +- [ ] `feat` -- New feature +- [x] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [ ] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [x] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- `.github/workflows/prepare-release.yml` — `MAX_ATTEMPTS: "3"` on three `commit-action` steps +- `.github/workflows/release.yml` — `MAX_ATTEMPTS: "3"` on finalization `commit-action` step +- `.github/workflows/sync-issues.yml` — `MAX_ATTEMPTS: "3"` on sync commit step +- `assets/smoke-test/.github/workflows/repository-dispatch.yml` — `MAX_ATTEMPTS: "3"` on deploy `commit-action` step +- `assets/workspace/.github/workflows/prepare-release.yml` — same for three steps (template) +- `assets/workspace/.github/workflows/release-core.yml` — same for finalization step +- `assets/workspace/.github/workflows/sync-issues.yml` — same for sync commit step +- `CHANGELOG.md` — Fixed entry for [#436](https://github.com/vig-os/devcontainer/issues/436) under `## [0.3.1] - TBD` +- `assets/workspace/.devcontainer/CHANGELOG.md` — mirrored changelog entry (manifest copy) + +## Changelog Entry + +Target branch uses `## [0.3.1] - TBD` (not `## Unreleased`). Added under **Fixed**: + +### Fixed + +- **commit-action retries enabled for transient git ref API failures** ([#436](https://github.com/vig-os/devcontainer/issues/436)) + - Set `MAX_ATTEMPTS: "3"` on every `vig-os/commit-action` step so v0.2.0 bounded retry actually runs (default was 1) + - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates + +## Testing + +- [ ] Tests pass locally (`just test`) +- [ ] Manual testing performed (describe below) + +### Manual Testing Details + +N/A — workflow env-only change; CI will exercise workflows after merge. + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [x] I have updated `CHANGELOG.md` under `## [0.3.1] - TBD` (release-branch convention; entry pasted above) +- [x] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [x] Any dependent changes have been merged and published + +## Additional Notes + +Changelog entry lives under `## [0.3.1] - TBD` because the PR targets `release/0.3.1`, not `dev`. + +Refs: #436 + + + +--- +--- + +## Commits + +### Commit 1: [794076a](https://github.com/vig-os/devcontainer/commit/794076af2e15287d69486f5774c8b0aaac9ad77b) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 10:09 AM +fix(ci): enable commit-action MAX_ATTEMPTS for transient ref failures, 17 files modified diff --git a/docs/pull-requests/pr-439.md b/docs/pull-requests/pr-439.md new file mode 100644 index 00000000..98a86345 --- /dev/null +++ b/docs/pull-requests/pr-439.md @@ -0,0 +1,293 @@ +--- +type: pull_request +state: closed (merged) +branch: bugfix/438-smoke-test-dispatch-rc20 → release/0.3.1 +created: 2026-03-25T11:13:54Z +updated: 2026-03-25T12:08:33Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/439 +comments: 12 +labels: none +assignees: c-vigo +milestone: none +projects: none +merged: 2026-03-25T12:08:32Z +synced: 2026-03-25T17:01:42.381Z +--- + +# [PR 439](https://github.com/vig-os/devcontainer/pull/439) fix(ci): accept bot PR approval when reviewDecision is empty + +## Description + +Release validation used only `reviewDecision` from `gh pr list`. When smoke-test orchestration approves the release PR with `git*ub-actions[bot]`, branch protection can leave `reviewDecision` empty while individual reviews still show `APPROVED`. This change falls back to the REST reviews API when `reviewDecision` is not `APPROVED`, matching the RCA on #438. + +## Type of Change + +- [ ] `feat` -- New feature +- [x] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [ ] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [ ] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- `.github/workflows/release.yml` — extend **Find and verify PR** with retry-wrapped `gh api …/pulls/{n}/reviews` fallback when `reviewDecision != APPROVED`. +- `assets/workspace/.github/workflows/release-core.yml` — same logic for the workspace/downstream template. +- `CHANGELOG.md` and `assets/workspace/.devcontainer/CHANGELOG.md` — document the fix under `## [0.3.1] - TBD` / **Fixed**. + +## Changelog Entry + +Under `## [0.3.1] - TBD` / `### Fixed`: + +- **Release validation fails when bot approves PR** ([#438](https://github.com/vig-os/devcontainer/issues/438)) + - Add fallback to individual PR review check when `reviewDecision` is empty (bot approvals not counted by branch protection) + +## Testing + +- [ ] Tests pass locally (`just test`) +- [ ] Manual testing performed (describe below) + +### Manual Testing Details + +N/A — workflow-only change; pre-commit hooks passed on commit. + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [x] I have updated `CHANGELOG.md` under `## [0.3.1] - TBD` (and pasted the entry above) +- [x] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Additional Notes + +Targets `release/0.3.1` per release-line fix. Downstream `devcontainer-smoke-test` still needs template sync after merge. + +Refs: #438 + + + +--- +--- + +## Review Threads (6) + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 11:18 AM_ + +_File: [`.github/workflows/release.yml (line 382 RIGHT)`](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987517343)_ + +```diff +@@ -371,8 +371,14 @@ jobs: + fi + + if [ "$REVIEW_DECISION" != "APPROVED" ]; then +- echo "ERROR: PR #$PR_NUMBER does not have approvals (status: $REVIEW_DECISION)" +- exit 1 ++ APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ ++ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ +``` + +`gh api repos/.../pulls/.../reviews` is paginated (default page size is limited). Without `--paginate`, `APPROVED_COUNT` can be 0 even when approvals exist beyond the first page. Add pagination (or otherwise ensure all review pages are included) so the fallback doesn’t produce false negatives on PRs with many reviews. +```suggestion + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + --paginate \ +``` + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 11:24 AM — [link](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987551166) + + Good catch -- adding `--paginate` to both files. + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 11:18 AM_ + +_File: [`.github/workflows/release.yml`](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987517376)_ + +```diff +@@ -371,8 +371,14 @@ jobs: + fi + + if [ "$REVIEW_DECISION" != "APPROVED" ]; then +- echo "ERROR: PR #$PR_NUMBER does not have approvals (status: $REVIEW_DECISION)" +- exit 1 ++ APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ ++ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ ++ --jq '[.[] | select(.state == "APPROVED")] | length') +``` + +This workflow calls PR review endpoints via `gh api .../pulls/.../reviews`, but the workflow-level token permissions only grant `contents: read` (no `pull-requests: read`). To avoid 403 "Resource not accessible by integration" failures, add `pull-requests: read` at the workflow or at least the `validate` job permissions. + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 11:24 AM — [link](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987551262) + + Valid. The `validate` job in `release.yml` inherits only `contents: read` from the workflow level. Adding `pull-requests: read` at job level. (`release-core.yml` already has it at line 92.) + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 11:18 AM_ + +_File: [`assets/workspace/.github/workflows/release-core.yml`](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987517407)_ + +```diff +@@ -262,8 +262,14 @@ jobs: + exit 1 + fi + if [ "$REVIEW_DECISION" != "APPROVED" ]; then +- echo "ERROR: PR #$PR_NUMBER is not approved (status: $REVIEW_DECISION)" +- exit 1 ++ APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ ++ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ ++ --jq '[.[] | select(.state == "APPROVED")] | length') ++ if [ "$APPROVED_COUNT" -eq 0 ]; then ++ echo "ERROR: PR #$PR_NUMBER is not approved (reviewDecision: $REVIEW_DECISION, approved reviews: 0)" ++ exit 1 ++ fi ++ echo "reviewDecision='$REVIEW_DECISION' but $APPROVED_COUNT approved review(s) found" + fi +``` + +The fallback check runs for any `reviewDecision != APPROVED` (including `CHANGES_REQUESTED` / `REVIEW_REQUIRED`). Because it only checks whether *any* APPROVED review exists, it can incorrectly pass validation even when there are outstanding change requests or when required approvals are not met. Limit the fallback to the specific “empty/null reviewDecision” case, and consider evaluating the latest review state per reviewer so an earlier approval doesn’t override a later `CHANGES_REQUESTED`. + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 11:24 AM — [link](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987551385) + + Agreed -- narrowing the fallback to trigger only when `reviewDecision` is empty/null. Explicit rejections (`CHANGES_REQUESTED`, `REVIEW_REQUIRED`) will fail immediately as before. Re per-reviewer evaluation: noted but out of scope for this fix (release PRs have a single bot reviewer). Will file a follow-up if needed. + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 11:18 AM_ + +_File: [`assets/workspace/.github/workflows/release-core.yml (line 268 RIGHT)`](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987517425)_ + +```diff +@@ -262,8 +262,14 @@ jobs: + exit 1 + fi + if [ "$REVIEW_DECISION" != "APPROVED" ]; then +- echo "ERROR: PR #$PR_NUMBER is not approved (status: $REVIEW_DECISION)" +- exit 1 ++ APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ +``` + +`gh api repos/.../pulls/.../reviews` is paginated. Without `--paginate`, `APPROVED_COUNT` can be 0 even when approvals exist beyond the first page. Add pagination (or otherwise ensure all review pages are included) to prevent false negatives. +```suggestion + APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ + --paginate \ +``` + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 11:24 AM — [link](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987551533) + + Same fix as above -- adding `--paginate`. + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 11:18 AM_ + +_File: [`.github/workflows/release.yml`](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987517447)_ + +```diff +@@ -371,8 +371,14 @@ jobs: + fi + + if [ "$REVIEW_DECISION" != "APPROVED" ]; then +- echo "ERROR: PR #$PR_NUMBER does not have approvals (status: $REVIEW_DECISION)" +- exit 1 ++ APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ ++ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ ++ --jq '[.[] | select(.state == "APPROVED")] | length') ++ if [ "$APPROVED_COUNT" -eq 0 ]; then ++ echo "ERROR: PR #$PR_NUMBER does not have approvals (reviewDecision: $REVIEW_DECISION, approved reviews: 0)" ++ exit 1 ++ fi ++ echo "reviewDecision='$REVIEW_DECISION' but $APPROVED_COUNT approved review(s) found" + fi +``` + +The fallback check runs for any `reviewDecision != APPROVED` (including `CHANGES_REQUESTED` / `REVIEW_REQUIRED`). Because it only checks whether *any* APPROVED review exists, it can incorrectly pass validation even when there are outstanding change requests or when required approvals are not met. Limit the fallback to the specific “empty/null reviewDecision” case (the bot-approval scenario), and otherwise keep failing; additionally, consider evaluating the latest review state per reviewer so an earlier approval doesn’t override a later `CHANGES_REQUESTED`. + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 11:24 AM — [link](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987551651) + + Same fix applied to both files -- narrowing fallback condition to empty/null `reviewDecision` only. + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 11:26 AM_ + +_File: [`.github/workflows/release.yml`](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987562861)_ + +```diff +@@ -370,7 +373,20 @@ jobs: + exit 1 + fi + +- if [ "$REVIEW_DECISION" != "APPROVED" ]; then ++ if [ "$REVIEW_DECISION" = "APPROVED" ]; then ++ echo "PR #$PR_NUMBER is approved" ++ elif [ "$REVIEW_DECISION" = "null" ] || [ -z "$REVIEW_DECISION" ]; then ++ # Bot approval fallback when gh pr list leaves reviewDecision empty (#438) ++ APPROVED_COUNT=$(retry --retries 3 --backoff 5 --max-backoff 30 -- gh api \ ++ --paginate \ ++ "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ ++ --jq '[.[] | select(.state == "APPROVED")] | length') +``` + +`gh api --paginate` combined with `--jq` will apply the jq filter per page, producing multiple outputs (one per page). That can make `APPROVED_COUNT` non-numeric (e.g., multiple lines) and break the `-eq` comparison under `set -e`. Consider using `--paginate --slurp` with a jq expression that flattens pages (or pipe to `jq -s` and sum) so the result is a single integer count across all pages. +```suggestion + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" | \ + jq -s '[.[][] | select(.state == "APPROVED")] | length') +``` + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 12:00 PM — [link](https://github.com/vig-os/devcontainer/pull/439#discussion_r2987731108) + + Agreed. Replaced `--paginate` + `--jq` on `gh api` with `--paginate --slurp` and a single `jq 'add | map(select(.state == "APPROVED")) | length'` so `APPROVED_COUNT` is always one number. Applied in `release.yml` and `release-core.yml`. + + +--- +--- + +## Commits + +### Commit 1: [8ef2bce](https://github.com/vig-os/devcontainer/commit/8ef2bcec5882c8eb3c5e83c9f97ba12ac60d9373) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 11:07 AM +fix(ci): accept bot PR approval when reviewDecision is empty, 24 files modified (.github/workflows/release.yml, CHANGELOG.md, assets/workspace/.devcontainer/CHANGELOG.md, assets/workspace/.github/workflows/release-core.yml) + +### Commit 2: [50abef9](https://github.com/vig-os/devcontainer/commit/50abef941be5ad292473a00e3693812734980857) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 11:22 AM +fix(ci): tighten bot-approval fallback per review feedback, 21 files modified (.github/workflows/release.yml, assets/workspace/.github/workflows/release-core.yml) + +### Commit 3: [41258db](https://github.com/vig-os/devcontainer/commit/41258db8da3b9c5ba3718032c424c2acbe57a76c) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 12:00 PM +fix(ci): slurp paginated PR reviews before jq count, 8 files modified (.github/workflows/release.yml, assets/workspace/.github/workflows/release-core.yml) diff --git a/docs/pull-requests/pr-442.md b/docs/pull-requests/pr-442.md new file mode 100644 index 00000000..d53e478b --- /dev/null +++ b/docs/pull-requests/pr-442.md @@ -0,0 +1,209 @@ +--- +type: pull_request +state: closed (merged) +branch: bugfix/441-fix-downstream-rc-tag-mismatch → release/0.3.1 +created: 2026-03-25T14:11:55Z +updated: 2026-03-25T14:48:15Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/442 +comments: 4 +labels: none +assignees: c-vigo +milestone: none +projects: none +merged: 2026-03-25T14:48:12Z +synced: 2026-03-25T17:01:40.930Z +--- + +# [PR 442](https://github.com/vig-os/devcontainer/pull/442) fix(ci): align downstream candidate RC with upstream dispatch tag + +## Description + +When upstream publishes a candidate such as `0.3.1-rc21` and dispatches to `devcontainer-smoke-test`, the downstream workspace `release.yml` used to auto-increment the RC from local tags only, producing `0.3.1-rc1` and breaking the upstream final-release gate that expects a downstream pre-release at the same RC tag. + +This change passes the numeric RC suffix from the dispatch tag through smoke-test orchestration into workspace `release.yml` / `release-core.yml` via optional `rc-number`, and documents the contract for cross-repo alignment. + +## Type of Change + +- [ ] `feat` -- New feature +- [x] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [ ] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [ ] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- **`assets/smoke-test/.github/workflows/repository-dispatch.yml`** + - Derive `rc_number` from `client_payload.tag` when it matches `X.Y.Z-rcN` + - Expose `rc_number` on `validate` job outputs; pass `-f rc-number=…` when triggering downstream `release.yml` for candidates + - Extend dispatch summary with base version and RC suffix +- **`assets/workspace/.github/workflows/release.yml`** + - Add optional `workflow_dispatch` input `rc-number`; forward to `release-core` as `rc_number` +- **`assets/workspace/.github/workflows/release-core.yml`** + - Add optional `workflow_call` input `rc_number`; when set for candidate mode, use it instead of scanning remote tags for the next RC (with validation `>= 1`) +- **`tests/bats/just.bats`** + - Smoke tests for dispatch `rc_number` wiring and workspace `rc-number` / `rc_number` inputs +- **`docs/CROSS_REPO_RELEASE_GATE.md`**, **`docs/DOWNSTREAM_RELEASE.md`** + - Document when and how to pass `rc-number` for cross-repo RC alignment +- **`CHANGELOG.md`**, **`assets/workspace/.devcontainer/CHANGELOG.md`** + - Fixed entry for #441 under the `0.3.1` TBD section + +## Changelog Entry + +Under `## [0.3.1] - TBD` → `### Fixed`: + +```markdown +- **Downstream candidate RC tag can match upstream dispatch** ([#441](https://github.com/vig-os/devcontainer/issues/441)) + - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only + - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` +``` + +## Testing + +- [x] Tests pass locally (`just test`) +- [ ] Manual testing performed (describe below) + +### Manual Testing Details + +N/A — workflow and template changes; BATS coverage exercises template invariants. + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [x] I have commented my code, particularly in hard-to-understand areas +- [x] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [x] I have updated `CHANGELOG.md` in the active release section (`## [0.3.1] - TBD`) (and pasted the entry above) +- [x] My changes generate no new warnings or errors +- [x] I have added tests that prove my fix is effective or that my feature works +- [x] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Additional Notes + +Branch was created from `release/0.3.1`; this PR should target **`release/0.3.1`** (patch line), not `dev`. + +Refs: #441 + + + +--- +--- + +## Review Threads (2) + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 02:21 PM_ + +_File: [`assets/workspace/.github/workflows/release-core.yml (line 203 RIGHT)`](https://github.com/vig-os/devcontainer/pull/442#discussion_r2988585000)_ + +```diff +@@ -183,36 +188,51 @@ jobs: + env: + VERSION: ${{ steps.vars.outputs.version }} + RELEASE_KIND: ${{ steps.vars.outputs.release_kind }} ++ INPUT_RC_NUMBER: ${{ inputs.rc_number }} + run: | + set -euo pipefail + NEXT_RC="" + if [ "$RELEASE_KIND" = "candidate" ]; then +- TAG_PATTERN="${VERSION}-rc*" +- EXISTING_TAGS=$(git ls-remote --tags --refs origin "$TAG_PATTERN" | awk '{print $2}' | sed 's#refs/tags/##') +- +- MAX_RC=0 +- if [ -n "$EXISTING_TAGS" ]; then +- while IFS= read -r tag; do +- [ -z "$tag" ] && continue +- if [ "${tag#${VERSION}-rc}" = "$tag" ]; then +- echo "ERROR: Malformed candidate tag detected: $tag" +- echo "Expected format: ${VERSION}-rcN" +- exit 1 +- fi +- rc_num="${tag#${VERSION}-rc}" +- if ! echo "$rc_num" | grep -qE '^[0-9]+$'; then +- echo "ERROR: Malformed candidate tag detected: $tag" +- echo "Expected format: ${VERSION}-rcN" +- exit 1 +- fi +- if [ "$rc_num" -gt "$MAX_RC" ]; then +- MAX_RC="$rc_num" +- fi +- done <<< "$EXISTING_TAGS" ++ if [ -n "${INPUT_RC_NUMBER}" ]; then ++ if ! printf '%s' "${INPUT_RC_NUMBER}" | grep -qE '^[0-9]+$'; then ++ echo "ERROR: rc_number must be a non-negative integer (got '${INPUT_RC_NUMBER}')" ++ exit 1 ++ fi ++ if [ "${INPUT_RC_NUMBER}" -lt 1 ]; then ++ echo "ERROR: rc_number must be >= 1 (got '${INPUT_RC_NUMBER}')" ++ exit 1 +``` + +The validation message says rc_number must be a “non-negative integer”, but the next check requires it to be >= 1. Please align the wording with the actual constraint (e.g., “positive integer” / “>= 1”) so users aren’t misled. + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 02:30 PM — [link](https://github.com/vig-os/devcontainer/pull/442#discussion_r2988641466) + + Good catch — `non-negative` is wrong since we enforce `>= 1`. Changed to `positive integer`. + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 02:21 PM_ + +_File: [`assets/smoke-test/.github/workflows/repository-dispatch.yml`](https://github.com/vig-os/devcontainer/pull/442#discussion_r2988585040)_ + +```diff +@@ -17,6 +17,10 @@ name: Repository Dispatch Listener + # + # NOTE: Changes to this template may require manual redeploy to + # vig-os/devcontainer-smoke-test and promotion through PRs until merged to main. ++# ++# If this repo (or a fork) orchestrates workspace release.yml for candidates, ++# pass workflow_dispatch input rc-number= where N is validate.outputs.rc_number +``` + +The header comment refers to `validate.outputs.rc_number`, but the actual Actions context to consume this job output is `needs.validate.outputs.rc_number` (as also documented in docs/CROSS_REPO_RELEASE_GATE.md). Updating the comment will avoid confusion for anyone wiring this into another job/workflow. +```suggestion +# pass workflow_dispatch input rc-number= where N is needs.validate.outputs.rc_number +``` + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 02:30 PM — [link](https://github.com/vig-os/devcontainer/pull/442#discussion_r2988641553) + + Agreed, the Actions context requires the `needs.` prefix. Fixed. + + +--- +--- + +## Commits + +### Commit 1: [2aedc0e](https://github.com/vig-os/devcontainer/commit/2aedc0e6524d0dccd47a6874c6bc0ed534f0d249) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 01:53 PM +test: add smoke test for dispatch RC number output wiring, 5 files modified (tests/bats/just.bats) + +### Commit 2: [0e7b874](https://github.com/vig-os/devcontainer/commit/0e7b874d1fc85e10a76f3add5d298d698e2ffadb) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 01:55 PM +test: add smoke test for workspace release rc-number input, 5 files modified (tests/bats/just.bats) + +### Commit 3: [632d22b](https://github.com/vig-os/devcontainer/commit/632d22b3f09018b337ea2d8fbb951def4d750aa4) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 01:56 PM +fix(ci): pass RC number from dispatch to downstream release, 99 files modified (assets/smoke-test/.github/workflows/repository-dispatch.yml, assets/workspace/.github/workflows/release-core.yml, assets/workspace/.github/workflows/release.yml) + +### Commit 4: [5478b95](https://github.com/vig-os/devcontainer/commit/5478b95e2aff751670b7a37ce139393f76005f59) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 01:58 PM +docs: document RC number passthrough for cross-repo gate, 10 files modified (CHANGELOG.md, assets/workspace/.devcontainer/CHANGELOG.md, docs/CROSS_REPO_RELEASE_GATE.md, docs/DOWNSTREAM_RELEASE.md) + +### Commit 5: [2cb7957](https://github.com/vig-os/devcontainer/commit/2cb7957874dabc67a2c3c2ca71e2949eedd2c5aa) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 02:27 PM +fix(ci): align error message and comment wording per review, 4 files modified (assets/smoke-test/.github/workflows/repository-dispatch.yml, assets/workspace/.github/workflows/release-core.yml) From 567a0b89ce1bef159be966d17c0a250ddccbf214 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 19:33:29 +0100 Subject: [PATCH 179/215] fix(ci): fail validate when GitHub Release already exists Refs: #443 --- .github/workflows/release.yml | 11 +++++++++++ CHANGELOG.md | 2 ++ assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ 3 files changed, 15 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d142f013..b29bd011 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -327,6 +327,17 @@ jobs: exit 1 fi + - name: Verify GitHub Release does not exist + env: + PUBLISH_VERSION: ${{ steps.publish_meta.outputs.publish_version }} + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + echo "ERROR: GitHub Release already exists for $PUBLISH_VERSION" + echo "Delete it manually before retrying: gh release delete $PUBLISH_VERSION --yes" + exit 1 + fi + - name: Set up GitHub CLI run: | gh version diff --git a/CHANGELOG.md b/CHANGELOG.md index c86e2c9c..4c3c14b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -190,6 +190,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Downstream candidate RC tag can match upstream dispatch** ([#441](https://github.com/vig-os/devcontainer/issues/441)) - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` +- **Release validate fails early when GitHub Release already exists** ([#443](https://github.com/vig-os/devcontainer/issues/443)) + - Validate job in `.github/workflows/release.yml` now checks `gh release view` for `PUBLISH_VERSION` so an existing release is rejected before build/sign/publish ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index c86e2c9c..4c3c14b1 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -190,6 +190,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Downstream candidate RC tag can match upstream dispatch** ([#441](https://github.com/vig-os/devcontainer/issues/441)) - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` +- **Release validate fails early when GitHub Release already exists** ([#443](https://github.com/vig-os/devcontainer/issues/443)) + - Validate job in `.github/workflows/release.yml` now checks `gh release view` for `PUBLISH_VERSION` so an existing release is rejected before build/sign/publish ### Security From 22b74cf40a41ee514e3b4850f17124e2dcbd8ed6 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 19:50:11 +0100 Subject: [PATCH 180/215] fix(ci): fail closed when release existence check hits API errors Refs: #443 --- .github/workflows/release.yml | 126 +++++++++++++++++++- CHANGELOG.md | 3 +- assets/workspace/.devcontainer/CHANGELOG.md | 3 +- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b29bd011..98caceaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -332,11 +332,50 @@ jobs: PUBLISH_VERSION: ${{ steps.publish_meta.outputs.publish_version }} GH_TOKEN: ${{ github.token }} run: | - if gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then - echo "ERROR: GitHub Release already exists for $PUBLISH_VERSION" - echo "Delete it manually before retrying: gh release delete $PUBLISH_VERSION --yes" + set -euo pipefail + # Same error-classification pattern as "Verify downstream pre-release for latest RC" above. + RETRIES=5 + LAST_ERROR="" + for i in $(seq 1 "$RETRIES"); do + API_OUTPUT="" + if API_OUTPUT=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${PUBLISH_VERSION}" 2>&1); then + echo "ERROR: GitHub Release already exists for $PUBLISH_VERSION" + echo "Delete it manually before retrying: gh release delete $PUBLISH_VERSION --yes" + exit 1 + fi + + LAST_ERROR="$API_OUTPUT" + if printf '%s' "$API_OUTPUT" | grep -Eqi "401|403|forbidden|bad credentials|authentication|authorization|insufficient scopes"; then + echo "ERROR: Non-retryable authentication/authorization failure while checking for existing GitHub Release for $PUBLISH_VERSION" + echo "$API_OUTPUT" + exit 1 + fi + + if printf '%s' "$API_OUTPUT" | grep -Eqi "not found|HTTP 404"; then + echo "No existing GitHub Release for $PUBLISH_VERSION (API reports not found)." + exit 0 + fi + + if printf '%s' "$API_OUTPUT" | grep -Eqi "429|5[0-9]{2}|timed out|timeout|temporary|connection reset|connection refused|network|tls"; then + if [ "$i" -lt "$RETRIES" ]; then + BACKOFF=$((i * 5)) + echo "Release existence check failed transiently, retrying in ${BACKOFF}s ($i/$RETRIES)..." + sleep "$BACKOFF" + fi + continue + fi + + echo "ERROR: Unexpected response while checking for existing GitHub Release for $PUBLISH_VERSION" + echo "$API_OUTPUT" exit 1 + done + + echo "ERROR: Unable to determine whether a GitHub Release exists for $PUBLISH_VERSION after $RETRIES attempts (fail closed)." + if [ -n "$LAST_ERROR" ]; then + echo "Last API error:" + echo "$LAST_ERROR" fi + exit 1 - name: Set up GitHub CLI run: | @@ -1125,8 +1164,47 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then - echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" + # Fail if release already exists; only treat API "not found" as absent (same semantics as Validate job). + RETRIES=5 + LAST_ERROR="" + for i in $(seq 1 "$RETRIES"); do + API_OUTPUT="" + if API_OUTPUT=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${PUBLISH_VERSION}" 2>&1); then + echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" + exit 1 + fi + + LAST_ERROR="$API_OUTPUT" + if printf '%s' "$API_OUTPUT" | grep -Eqi "401|403|forbidden|bad credentials|authentication|authorization|insufficient scopes"; then + echo "ERROR: Non-retryable authentication/authorization failure while checking for existing GitHub Release for $PUBLISH_VERSION" + echo "$API_OUTPUT" + exit 1 + fi + + if printf '%s' "$API_OUTPUT" | grep -Eqi "not found|HTTP 404"; then + break + fi + + if printf '%s' "$API_OUTPUT" | grep -Eqi "429|5[0-9]{2}|timed out|timeout|temporary|connection reset|connection refused|network|tls"; then + if [ "$i" -lt "$RETRIES" ]; then + BACKOFF=$((i * 5)) + echo "Release existence check failed transiently, retrying in ${BACKOFF}s ($i/$RETRIES)..." + sleep "$BACKOFF" + fi + continue + fi + + echo "ERROR: Unexpected response while checking for existing GitHub Release for $PUBLISH_VERSION" + echo "$API_OUTPUT" + exit 1 + done + + if ! printf '%s' "$LAST_ERROR" | grep -Eqi "not found|HTTP 404"; then + echo "ERROR: Unable to determine whether a GitHub Release exists for $PUBLISH_VERSION after $RETRIES attempts (fail closed)." + if [ -n "$LAST_ERROR" ]; then + echo "Last API error:" + echo "$LAST_ERROR" + fi exit 1 fi @@ -1134,9 +1212,45 @@ jobs: --title "$PUBLISH_VERSION" \ --notes-file /tmp/github-release-notes.md \ --verify-tag || { - if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then + RELEASE_PRESENT=0 + rb_retries=5 + rb_last="" + for j in $(seq 1 "$rb_retries"); do + rb_out="" + if rb_out=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${PUBLISH_VERSION}" 2>&1); then + RELEASE_PRESENT=1 + break + fi + rb_last="$rb_out" + if printf '%s' "$rb_out" | grep -Eqi "401|403|forbidden|bad credentials|authentication|authorization|insufficient scopes"; then + echo "ERROR: Non-retryable authentication/authorization failure while verifying GitHub Release after create for $PUBLISH_VERSION" + echo "$rb_out" + exit 1 + fi + if printf '%s' "$rb_out" | grep -Eqi "not found|HTTP 404"; then + RELEASE_PRESENT=0 + break + fi + if printf '%s' "$rb_out" | grep -Eqi "429|5[0-9]{2}|timed out|timeout|temporary|connection reset|connection refused|network|tls"; then + if [ "$j" -lt "$rb_retries" ]; then + rb_backoff=$((j * 5)) + echo "Post-create release check failed transiently, retrying in ${rb_backoff}s ($j/$rb_retries)..." + sleep "$rb_backoff" + fi + continue + fi + echo "ERROR: Unexpected response while verifying GitHub Release after create for $PUBLISH_VERSION" + echo "$rb_out" + exit 1 + done + if [ "$RELEASE_PRESENT" -eq 1 ]; then echo "GitHub Release already present after create attempt: $PUBLISH_VERSION" else + if [ "$j" -eq "$rb_retries" ] && ! printf '%s' "$rb_last" | grep -Eqi "not found|HTTP 404"; then + echo "ERROR: Unable to verify GitHub Release state for $PUBLISH_VERSION after create failure (fail closed)." + echo "$rb_last" + exit 1 + fi exit 1 fi } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3c14b1..48e42012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,7 +191,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` - **Release validate fails early when GitHub Release already exists** ([#443](https://github.com/vig-os/devcontainer/issues/443)) - - Validate job in `.github/workflows/release.yml` now checks `gh release view` for `PUBLISH_VERSION` so an existing release is rejected before build/sign/publish + - Validate job in `.github/workflows/release.yml` queries `GET /repos/.../releases/tags/` with retries and classifies errors like the downstream RC gate; only a documented not-found response is treated as “no release,” and ambiguous API failures fail closed before build/sign/publish + - Publish job uses the same existence checks before and after `gh release create` instead of `gh release view` with discarded stderr ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 4c3c14b1..48e42012 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -191,7 +191,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` - **Release validate fails early when GitHub Release already exists** ([#443](https://github.com/vig-os/devcontainer/issues/443)) - - Validate job in `.github/workflows/release.yml` now checks `gh release view` for `PUBLISH_VERSION` so an existing release is rejected before build/sign/publish + - Validate job in `.github/workflows/release.yml` queries `GET /repos/.../releases/tags/` with retries and classifies errors like the downstream RC gate; only a documented not-found response is treated as “no release,” and ambiguous API failures fail closed before build/sign/publish + - Publish job uses the same existence checks before and after `gh release create` instead of `gh release view` with discarded stderr ### Security From 8784c10ebe0e420e0d30570e6789af77ee63616c Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 20:13:01 +0100 Subject: [PATCH 181/215] chore(ci): remove PR Title Check workflow Refs: #444 --- .github/agent-blocklist.toml | 2 +- .github/workflows/pr-title-check.yml | 46 ------------------- CHANGELOG.md | 5 ++ assets/workspace/.devcontainer/CHANGELOG.md | 5 ++ assets/workspace/.github/agent-blocklist.toml | 2 +- packages/vig-utils/tests/test_gh_issues.py | 6 +-- 6 files changed, 15 insertions(+), 51 deletions(-) delete mode 100644 .github/workflows/pr-title-check.yml diff --git a/.github/agent-blocklist.toml b/.github/agent-blocklist.toml index bf88a2b7..94c28bfd 100644 --- a/.github/agent-blocklist.toml +++ b/.github/agent-blocklist.toml @@ -1,5 +1,5 @@ # Canonical blocklist for AI agent identity fingerprints. -# Referenced by: validate-commit-msg, pre-commit hooks, pr-title-check CI. +# Referenced by: validate-commit-msg, pre-commit hooks. # Refs: #163 [patterns] diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml deleted file mode 100644 index 428c3225..00000000 --- a/.github/workflows/pr-title-check.yml +++ /dev/null @@ -1,46 +0,0 @@ -# PR Title Check -# -# Validates that PR titles follow the commit message standard (type(scope): description). -# Merge commits use PR_TITLE as the commit subject, so enforcing the standard here -# ensures compliant merge commit messages. -# -# Triggers: pull_request opened, edited, synchronize (title may change on edit) - -name: PR Title Check - -on: # yamllint disable-line rule:truthy - pull_request: - types: [opened, edited, synchronize] - -permissions: - contents: read - -jobs: - check-title: - name: Validate PR Title - runs-on: ubuntu-22.04 - timeout-minutes: 5 - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up environment - uses: ./.github/actions/setup-env - with: - sync-dependencies: 'true' - - - name: Validate PR title - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: | - TITLE_FILE=$(mktemp) - printf '%s' "$PR_TITLE" > "$TITLE_FILE" - uv run validate-commit-msg "$TITLE_FILE" --subject-only \ - --blocked-patterns .github/agent-blocklist.toml - - - name: Check PR for agent fingerprints - env: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} - run: uv run check-pr-agent-fingerprints diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e42012..167a7708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Retries 3 times with backoff before giving up - Build succeeds without cursor-agent when Cursor's CDN is unavailable +### Removed + +- **PR Title Check GitHub Actions workflow** ([#444](https://github.com/vig-os/devcontainer/issues/444)) + - Remove `.github/workflows/pr-title-check.yml`; commit message rules remain enforced via local hooks and `validate-commit-msg` + ### Fixed - **Smoke-test deploy restores workspace CHANGELOG for prepare-release** ([#417](https://github.com/vig-os/devcontainer/issues/417)) diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 48e42012..167a7708 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -76,6 +76,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Retries 3 times with backoff before giving up - Build succeeds without cursor-agent when Cursor's CDN is unavailable +### Removed + +- **PR Title Check GitHub Actions workflow** ([#444](https://github.com/vig-os/devcontainer/issues/444)) + - Remove `.github/workflows/pr-title-check.yml`; commit message rules remain enforced via local hooks and `validate-commit-msg` + ### Fixed - **Smoke-test deploy restores workspace CHANGELOG for prepare-release** ([#417](https://github.com/vig-os/devcontainer/issues/417)) diff --git a/assets/workspace/.github/agent-blocklist.toml b/assets/workspace/.github/agent-blocklist.toml index bf88a2b7..94c28bfd 100644 --- a/assets/workspace/.github/agent-blocklist.toml +++ b/assets/workspace/.github/agent-blocklist.toml @@ -1,5 +1,5 @@ # Canonical blocklist for AI agent identity fingerprints. -# Referenced by: validate-commit-msg, pre-commit hooks, pr-title-check CI. +# Referenced by: validate-commit-msg, pre-commit hooks. # Refs: #163 [patterns] diff --git a/packages/vig-utils/tests/test_gh_issues.py b/packages/vig-utils/tests/test_gh_issues.py index f9d3f6e0..b02b9509 100644 --- a/packages/vig-utils/tests/test_gh_issues.py +++ b/packages/vig-utils/tests/test_gh_issues.py @@ -94,17 +94,17 @@ def test_dedup_by_name_latest_completed_at_wins(self): "number": 1, "statusCheckRollup": [ { - "name": "Validate PR Title", + "name": "Status Gate", "conclusion": "FAILURE", "completedAt": "2026-02-24T12:52:49Z", }, { - "name": "Validate PR Title", + "name": "Status Gate", "conclusion": "FAILURE", "completedAt": "2026-02-24T12:53:39Z", }, { - "name": "Validate PR Title", + "name": "Status Gate", "conclusion": "SUCCESS", "completedAt": "2026-02-24T12:53:52Z", }, From 3b23b36b5606ee0e705235bbc172de54dae75a6c Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 20:16:50 +0100 Subject: [PATCH 182/215] chore(vigutils): remove validate-commit-msg --subject-only Refs: #444 --- CHANGELOG.md | 1 + assets/workspace/.devcontainer/CHANGELOG.md | 1 + packages/vig-utils/README.md | 2 - .../src/vig_utils/validate_commit_msg.py | 16 +-- .../tests/test_validate_commit_msg.py | 103 +----------------- 5 files changed, 5 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167a7708..a0e13575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **PR Title Check GitHub Actions workflow** ([#444](https://github.com/vig-os/devcontainer/issues/444)) - Remove `.github/workflows/pr-title-check.yml`; commit message rules remain enforced via local hooks and `validate-commit-msg` + - Remove `--subject-only` from `validate-commit-msg` (it existed only for PR title CI) ### Fixed diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 167a7708..a0e13575 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -80,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **PR Title Check GitHub Actions workflow** ([#444](https://github.com/vig-os/devcontainer/issues/444)) - Remove `.github/workflows/pr-title-check.yml`; commit message rules remain enforced via local hooks and `validate-commit-msg` + - Remove `--subject-only` from `validate-commit-msg` (it existed only for PR title CI) ### Fixed diff --git a/packages/vig-utils/README.md b/packages/vig-utils/README.md index 52928e1d..b292464d 100644 --- a/packages/vig-utils/README.md +++ b/packages/vig-utils/README.md @@ -50,7 +50,6 @@ validate-commit-msg \ [--scopes SCOPE,...] \ [--refs-optional-types TYPE,...] \ [--require-scope] \ - [--subject-only] \ [--blocked-patterns PATH] ``` @@ -59,7 +58,6 @@ Examples: ```bash validate-commit-msg .git/COMMIT_EDITMSG validate-commit-msg .git/COMMIT_EDITMSG --scopes setup,ci,vigutils --require-scope -validate-commit-msg .git/COMMIT_EDITMSG --subject-only ``` ### `check-action-pins` diff --git a/packages/vig-utils/src/vig_utils/validate_commit_msg.py b/packages/vig-utils/src/vig_utils/validate_commit_msg.py index 07da5e6f..11f52389 100644 --- a/packages/vig-utils/src/vig_utils/validate_commit_msg.py +++ b/packages/vig-utils/src/vig_utils/validate_commit_msg.py @@ -7,7 +7,7 @@ See docs/COMMIT_MESSAGE_STANDARD.md for the full standard. Usage: - validate-commit-msg [--types TYPE,TYPE,...] [--scopes SCOPE,SCOPE,...] [--refs-optional-types TYPE,TYPE,...] [--require-scope] [--subject-only] + validate-commit-msg [--types TYPE,TYPE,...] [--scopes SCOPE,SCOPE,...] [--refs-optional-types TYPE,TYPE,...] [--require-scope] Examples: validate-commit-msg .git/COMMIT_EDITMSG @@ -108,7 +108,6 @@ def validate_commit_message( approved_scopes: frozenset[str] | None = None, refs_optional_types: frozenset[str] | None = None, require_scope: bool = False, - subject_only: bool = False, blocked_patterns: dict | None = None, ) -> tuple[bool, str | None]: """Validate a commit message string. @@ -119,8 +118,6 @@ def validate_commit_message( approved_scopes: Set of allowed scopes. If None or empty, scopes are not enforced. refs_optional_types: Set of commit types where Refs line is optional. Defaults to DEFAULT_REFS_OPTIONAL_TYPES. require_scope: If True, at least one scope is mandatory. Defaults to False. - subject_only: If True, validate only the subject line (type, scope, description). - Skips blank-line, body, and Refs validation. Useful for PR title checks. blocked_patterns: Blocklist dict from agent_blocklist.load_blocklist(). If None, uses hardcoded fallback. Returns: @@ -205,11 +202,8 @@ def validate_commit_message( f"Allowed scopes: {', '.join(sorted(approved_scopes))}", ) - if subject_only: - return True, None - # Require at least one blank line between subject and body/Refs - # For types with optional Refs, a subject-only message is valid + # For types with optional Refs, subject + blank line only is valid if len(lines) < 2: if type_part in refs_optional_types: return True, None @@ -296,11 +290,6 @@ def main() -> int: action="store_true", help="Require at least one scope in the commit message (default: false)", ) - parser.add_argument( - "--subject-only", - action="store_true", - help="Validate only the subject line (skip body and Refs). Useful for PR title validation.", - ) parser.add_argument( "--blocked-patterns", type=Path, @@ -347,7 +336,6 @@ def main() -> int: approved_scopes=approved_scopes, refs_optional_types=refs_optional_types, require_scope=args.require_scope, - subject_only=args.subject_only, blocked_patterns=blocked_patterns, ) if valid: diff --git a/packages/vig-utils/tests/test_validate_commit_msg.py b/packages/vig-utils/tests/test_validate_commit_msg.py index 48bc356b..e93d5cf1 100644 --- a/packages/vig-utils/tests/test_validate_commit_msg.py +++ b/packages/vig-utils/tests/test_validate_commit_msg.py @@ -212,7 +212,7 @@ def test_non_chore_types_still_require_refs(self): assert valid is False, f"Type {ctype} should require Refs but passed" assert "Refs" in err - def test_chore_subject_only_with_blank_line(self): + def test_chore_minimal_with_blank_line_only(self): """Minimal chore commit: subject + blank line only.""" msg = "chore: update dependencies\n\n" valid, err = validate_commit_message(msg) @@ -1098,12 +1098,6 @@ def test_valid_message_without_agent_fingerprints(self): assert valid is True assert err is None - def test_subject_only_still_checks_agent_fingerprints(self): - """Subject-only mode still rejects Co-authored-by in subject (edge case).""" - msg = "feat: add feature\n\nCo-authored-by: Claude " - valid, err = validate_commit_message(msg, subject_only=True) - assert valid is False - def test_rejects_made_with_cursor_link(self): """Reject 'Made with [Cursor](https://cursor.com)' branding in body.""" msg = "feat: add feature\n\nMade with [Cursor](https://cursor.com)\n\nRefs: #163\n" @@ -1128,98 +1122,3 @@ def test_blocked_patterns_from_toml_rejects_openai(self): valid, err = validate_commit_message(msg, blocked_patterns=blocklist) assert valid is False assert "openai" in err.lower() or "blocked" in err.lower() - - -class TestSubjectOnly: - """Test subject_only mode for PR title validation.""" - - def test_subject_only_valid_feat(self): - valid, err = validate_commit_message("feat: add new feature", subject_only=True) - assert valid is True - assert err is None - - def test_subject_only_valid_with_scope(self): - valid, err = validate_commit_message( - "fix(api): correct endpoint", subject_only=True - ) - assert valid is True - assert err is None - - def test_subject_only_valid_breaking(self): - valid, err = validate_commit_message( - "feat!: breaking change", subject_only=True - ) - assert valid is True - assert err is None - - def test_subject_only_valid_scope_and_breaking(self): - valid, err = validate_commit_message( - "feat(cli)!: remove flag", subject_only=True - ) - assert valid is True - assert err is None - - def test_subject_only_rejects_invalid_type(self): - valid, err = validate_commit_message("invalid: bad type", subject_only=True) - assert valid is False - assert "Unknown commit type" in err - - def test_subject_only_rejects_missing_description(self): - valid, err = validate_commit_message("feat:", subject_only=True) - assert valid is False - - def test_subject_only_rejects_empty(self): - valid, err = validate_commit_message("", subject_only=True) - assert valid is False - - def test_subject_only_ignores_body_and_refs(self): - """Subject-only mode validates only the first line, ignoring body/Refs.""" - valid, err = validate_commit_message( - "feat: add feature\n\nSome body\n\nRefs: #1\n", subject_only=True - ) - assert valid is True - assert err is None - - def test_subject_only_does_not_require_refs(self): - """Subject-only mode never requires a Refs line, even for non-chore types.""" - valid, err = validate_commit_message("feat: add feature", subject_only=True) - assert valid is True - - def test_subject_only_validates_scopes_when_configured(self): - valid, err = validate_commit_message( - "feat(unknown): add feature", - subject_only=True, - approved_scopes=frozenset({"api", "cli"}), - ) - assert valid is False - assert "Unknown scope" in err - - def test_main_subject_only_flag(self, tmp_path): - """Test main() with --subject-only flag.""" - msg_file = tmp_path / "msg" - msg_file.write_text("feat: add feature") - orig_argv = sys.argv - try: - sys.argv = [ - "validate_commit_msg.py", - str(msg_file), - "--subject-only", - ] - assert main() == 0 - finally: - sys.argv = orig_argv - - def test_main_subject_only_rejects_invalid(self, tmp_path): - """Test main() with --subject-only rejects invalid type.""" - msg_file = tmp_path / "msg" - msg_file.write_text("invalid: bad type") - orig_argv = sys.argv - try: - sys.argv = [ - "validate_commit_msg.py", - str(msg_file), - "--subject-only", - ] - assert main() == 1 - finally: - sys.argv = orig_argv From 4a9b2fbfecac07300184d6d6c858a6d999e5feb8 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Wed, 25 Mar 2026 20:32:34 +0100 Subject: [PATCH 183/215] test(vigutils): align minimal chore test with rstrip parsing Refs: #444 --- packages/vig-utils/src/vig_utils/validate_commit_msg.py | 5 +++-- packages/vig-utils/tests/test_validate_commit_msg.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/vig-utils/src/vig_utils/validate_commit_msg.py b/packages/vig-utils/src/vig_utils/validate_commit_msg.py index 11f52389..adf62252 100644 --- a/packages/vig-utils/src/vig_utils/validate_commit_msg.py +++ b/packages/vig-utils/src/vig_utils/validate_commit_msg.py @@ -202,8 +202,9 @@ def validate_commit_message( f"Allowed scopes: {', '.join(sorted(approved_scopes))}", ) - # Require at least one blank line between subject and body/Refs - # For types with optional Refs, subject + blank line only is valid + # Require at least one blank line between subject and body/Refs. + # Content is rstrip()'d before splitlines(), so trailing newlines do not count as a + # second line. For refs-optional types, a lone subject line is valid (no Refs/body). if len(lines) < 2: if type_part in refs_optional_types: return True, None diff --git a/packages/vig-utils/tests/test_validate_commit_msg.py b/packages/vig-utils/tests/test_validate_commit_msg.py index e93d5cf1..c4130d3f 100644 --- a/packages/vig-utils/tests/test_validate_commit_msg.py +++ b/packages/vig-utils/tests/test_validate_commit_msg.py @@ -212,8 +212,8 @@ def test_non_chore_types_still_require_refs(self): assert valid is False, f"Type {ctype} should require Refs but passed" assert "Refs" in err - def test_chore_minimal_with_blank_line_only(self): - """Minimal chore commit: subject + blank line only.""" + def test_chore_minimal_subject_only(self): + """Minimal chore commit: subject line only (trailing newlines are stripped).""" msg = "chore: update dependencies\n\n" valid, err = validate_commit_message(msg) assert valid is True From 1bb5f783e6c0f50f7132f0745fd422bc85cc88c6 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 26 Mar 2026 11:04:54 +0100 Subject: [PATCH 184/215] feat(ci): draft final releases, idempotent tags, and forward-fix rollback Final release automation creates a draft GitHub Release for human review and publishing from the UI, matching immutable-releases draft-first guidance. Validate and publish treat an existing draft for the same tag as the retry path and fail closed on a published (non-draft) final release for that tag. After finalization, detect when the remote publish tag already peels to the finalize SHA; skip redundant tag create and push. Remove the pre-publish "tag must not exist" gate in favor of this SHA-aware check. Rollback resets the release branch (best-effort) but no longer deletes remote tags; failure issues and operator copy explain forward-fix recovery, draft release handling, and how immutable releases differ from tag rulesets. Align workspace release-core, release-publish, and orchestrator rollback with the same semantics; extend release-publish inputs for tag_already_exists and draft/published release checks (including candidate pre-release retry paths). Update smoke-test repository-dispatch failure guidance for published releases and immutable tags. Document policy and recovery in RELEASE_CYCLE (new immutable-releases section, diagram and procedure updates), DOWNSTREAM_RELEASE (consumer-only scope, downstream draft final and RC pre-release notes), and CROSS_REPO_RELEASE_GATE. CHANGELOG Unreleased records user-visible impact. Refs: #446 --- .github/workflows/release.yml | 128 +++++++++++------- CHANGELOG.md | 4 + .../.github/workflows/repository-dispatch.yml | 3 +- assets/workspace/.devcontainer/CHANGELOG.md | 4 + .../.github/workflows/release-core.yml | 42 ++++-- .../.github/workflows/release-publish.yml | 27 +++- .../workspace/.github/workflows/release.yml | 20 +-- docs/CROSS_REPO_RELEASE_GATE.md | 2 + docs/DOWNSTREAM_RELEASE.md | 12 +- docs/RELEASE_CYCLE.md | 108 +++++++-------- 10 files changed, 218 insertions(+), 132 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98caceaf..072f8569 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,12 +4,12 @@ # 1. Validate: Check all prerequisites (PR status, CI passed, CHANGELOG ready) # 2. Finalize: For final releases only, set release date in CHANGELOG # 3. Build & Test: Build and test images for all architectures -# 4. Publish: Create tag, publish images, and publish final GitHub Release -# 5. Rollback (on failure): Revert changes and create issue +# 4. Publish: Create tag, publish images, and create draft final GitHub Release +# 5. Rollback (on failure): Reset release branch, create issue (tags are not deleted—forward-fix policy; GitHub immutability applies only after a published release locks the tag when that setting is enabled) # # Release kinds: # candidate: Publishes X.Y.Z-rcN. No CHANGELOG changes, no sync-issues. -# final: Publishes X.Y.Z. Sets release date in CHANGELOG, triggers sync-issues, publishes GitHub Release. +# final: Publishes X.Y.Z. Sets release date in CHANGELOG, triggers sync-issues, creates draft GitHub Release. # # Design: Everything happens in one workflow dispatch before creating the tag. # This ensures no broken releases are tagged. @@ -318,18 +318,10 @@ jobs: exit 1 fi - - name: Verify tag does not exist - env: - PUBLISH_VERSION: ${{ steps.publish_meta.outputs.publish_version }} - run: | - if git tag -l | grep -q "^${PUBLISH_VERSION}$"; then - echo "ERROR: Tag $PUBLISH_VERSION already exists" - exit 1 - fi - - - name: Verify GitHub Release does not exist + - name: Verify GitHub Release does not block publish env: PUBLISH_VERSION: ${{ steps.publish_meta.outputs.publish_version }} + RELEASE_KIND: ${{ steps.vars.outputs.release_kind }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -339,8 +331,17 @@ jobs: for i in $(seq 1 "$RETRIES"); do API_OUTPUT="" if API_OUTPUT=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${PUBLISH_VERSION}" 2>&1); then + if [ "$RELEASE_KIND" = "final" ]; then + IS_DRAFT=$(printf '%s' "$API_OUTPUT" | jq -r '.draft') + if [ "$IS_DRAFT" = "true" ]; then + echo "Draft GitHub Release already exists for $PUBLISH_VERSION; publish job may retry release creation." + exit 0 + fi + echo "ERROR: Published (non-draft) GitHub Release already exists for $PUBLISH_VERSION" + echo "Immutable releases cannot be replaced; use a patch version if a new release is required." + exit 1 + fi echo "ERROR: GitHub Release already exists for $PUBLISH_VERSION" - echo "Delete it manually before retrying: gh release delete $PUBLISH_VERSION --yes" exit 1 fi @@ -552,6 +553,7 @@ jobs: actions: write # trigger sync-issues workflow outputs: finalize_sha: ${{ steps.finalize.outputs.finalize_sha }} + tag_already_exists: ${{ steps.tag_state.outputs.tag_already_exists }} steps: - name: Generate GitHub App Token @@ -755,6 +757,31 @@ jobs: echo "finalize_sha=$FINALIZE_SHA" >> $GITHUB_OUTPUT echo "Release kind: $RELEASE_KIND — SHA: $FINALIZE_SHA" + - name: Check if publish tag already exists at finalize SHA + id: tag_state + env: + PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} + FINALIZE_SHA: ${{ steps.finalize.outputs.finalize_sha }} + run: | + set -euo pipefail + REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}" || true) + if [ -z "$REMOTE_LINE" ]; then + echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" + echo "No remote tag ${PUBLISH_VERSION} yet" + exit 0 + fi + REMOTE_PEEL=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') + if [ -z "$REMOTE_PEEL" ]; then + echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$REMOTE_PEEL" != "$FINALIZE_SHA" ]; then + echo "ERROR: Tag $PUBLISH_VERSION exists but peeled commit is $REMOTE_PEEL; expected $FINALIZE_SHA (finalize SHA)" + exit 1 + fi + echo "tag_already_exists=true" >> "$GITHUB_OUTPUT" + echo "Remote tag $PUBLISH_VERSION already points to finalize SHA; publish will skip tag create/push" + build-and-test: name: Build and Test (${{ matrix.arch }}) needs: [validate, finalize] @@ -862,6 +889,7 @@ jobs: git config user.email "$GIT_USER_EMAIL" - name: Create annotated tag + if: ${{ needs.finalize.outputs.tag_already_exists != 'true' }} env: PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} run: | @@ -870,6 +898,7 @@ jobs: echo "✓ Tag created: $PUBLISH_VERSION" - name: Push tag + if: ${{ needs.finalize.outputs.tag_already_exists != 'true' }} env: PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} @@ -1164,13 +1193,19 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - # Fail if release already exists; only treat API "not found" as absent (same semantics as Validate job). + # Draft first: human publishes from the UI when ready (GitHub immutable-releases best practice). RETRIES=5 LAST_ERROR="" for i in $(seq 1 "$RETRIES"); do API_OUTPUT="" if API_OUTPUT=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${PUBLISH_VERSION}" 2>&1); then - echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" + IS_DRAFT=$(printf '%s' "$API_OUTPUT" | jq -r '.draft') + if [ "$IS_DRAFT" = "true" ]; then + echo "✓ Draft GitHub Release already exists for $PUBLISH_VERSION; skipping create (retry path)." + echo "Review and publish from: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases" + exit 0 + fi + echo "ERROR: Published (non-draft) GitHub Release already exists for tag $PUBLISH_VERSION" exit 1 fi @@ -1211,14 +1246,17 @@ jobs: retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$PUBLISH_VERSION" \ --title "$PUBLISH_VERSION" \ --notes-file /tmp/github-release-notes.md \ - --verify-tag || { + --verify-tag \ + --draft || { RELEASE_PRESENT=0 + IS_DRAFT_RETRY=0 rb_retries=5 rb_last="" for j in $(seq 1 "$rb_retries"); do rb_out="" if rb_out=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${PUBLISH_VERSION}" 2>&1); then RELEASE_PRESENT=1 + IS_DRAFT_RETRY=$(printf '%s' "$rb_out" | jq -r '.draft') break fi rb_last="$rb_out" @@ -1244,7 +1282,12 @@ jobs: exit 1 done if [ "$RELEASE_PRESENT" -eq 1 ]; then - echo "GitHub Release already present after create attempt: $PUBLISH_VERSION" + if [ "$IS_DRAFT_RETRY" = "true" ]; then + echo "✓ Draft GitHub Release present after create attempt: $PUBLISH_VERSION" + else + echo "ERROR: Published (non-draft) GitHub Release appeared for $PUBLISH_VERSION during create fallback — expected draft." + exit 1 + fi else if [ "$j" -eq "$rb_retries" ] && ! printf '%s' "$rb_last" | grep -Eqi "not found|HTTP 404"; then echo "ERROR: Unable to verify GitHub Release state for $PUBLISH_VERSION after create failure (fail closed)." @@ -1255,7 +1298,8 @@ jobs: fi } - echo "✓ GitHub Release published: $PUBLISH_VERSION" + echo "✓ Draft GitHub Release created: $PUBLISH_VERSION" + echo "Review and publish from: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases (publishing locks the linked tag and assets when immutable releases are enabled)" - name: Summary env: @@ -1285,7 +1329,8 @@ jobs: echo " 2. If downstream pre-release passes, run: just finalize-release $BASE_VERSION" else echo " 1. Smoke-test dispatch runs in the smoke-test job." - echo " 2. Merge release PR to main (triggers sync-main-to-dev workflow automatically)" + echo " 2. Review the draft GitHub Release and publish it from the Releases UI (publishing applies immutable-release lock-in for the tag and assets when that setting is enabled)." + echo " 3. Merge release PR to main (triggers sync-main-to-dev workflow automatically)" fi smoke-test: @@ -1347,9 +1392,13 @@ jobs: - name: Summary env: RELEASE_TAG: ${{ needs.validate.outputs.publish_version }} + RELEASE_KIND: ${{ needs.validate.outputs.release_kind }} run: | echo "✓ Triggered smoke-test dispatch for release tag: $RELEASE_TAG" echo "Downstream validation is asynchronous; verify in devcontainer-smoke-test." + if [ "$RELEASE_KIND" = "final" ]; then + echo "Final: review the draft GitHub Release in this repo and publish it from the Releases UI when validation is complete." + fi - name: Create issue for smoke dispatch failure if: ${{ failure() }} @@ -1368,7 +1417,7 @@ jobs: **Important:** - Upstream publish already completed before this dispatch step. - - Published artifacts (GHCR images, tag, signatures, attestations, and final GitHub Release if applicable) are intentionally left intact. + - Published artifacts (GHCR images, tag, signatures, attestations, and draft or published GitHub Release if applicable) are intentionally left intact. - No branch reset or tag deletion is performed for dispatch-only failures. **Next Steps:** @@ -1390,7 +1439,7 @@ jobs: rollback: name: Rollback on Failure # Rollback is intentionally scoped to pre-publish/publish failures. - # Dispatch-only smoke-test failures happen after publish and should not delete tags or reset branches. + # Dispatch-only smoke-test failures happen after publish; automation does not delete tags or reset branches (forward-fix policy). needs: [validate, finalize, build-and-test, publish] runs-on: ubuntu-22.04 timeout-minutes: 10 @@ -1398,7 +1447,7 @@ jobs: permissions: contents: read # required by actions/checkout in rollback job issues: write # create failure issue - # Branch rollback and tag deletion use the RELEASE_APP token (not GITHUB_TOKEN), + # Branch rollback uses the RELEASE_APP token (not GITHUB_TOKEN), # which has Contents read/write configured on the GitHub App. steps: @@ -1434,23 +1483,6 @@ jobs: -F force=true echo "✓ Release branch rolled back to $PRE_SHA" - - name: Delete tag if created - id: rollback-tag - continue-on-error: true - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} - run: | - set -euo pipefail - TAG="$PUBLISH_VERSION" - if retry --retries 2 --backoff 5 --max-backoff 20 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then - echo "Deleting remote tag: $TAG" - retry --retries 3 --backoff 5 --max-backoff 30 -- gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" -X DELETE - echo "✓ Tag deleted" - else - echo "Tag does not exist on remote (not created)" - fi - - name: Create failure issue uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: @@ -1466,7 +1498,6 @@ jobs: if ('${{ needs.publish.result }}' !== 'success') failedJobs.push('publish'); const rollbackBranch = '${{ steps.rollback-branch.outcome }}'; - const rollbackTag = '${{ steps.rollback-tag.outcome }}'; const title = `Release ${publishVersion} failed -- automatic rollback`; const body = ` @@ -1480,21 +1511,24 @@ jobs: **Rollback Results:** - Branch rollback: ${rollbackBranch} - - Tag deletion: ${rollbackTag} + + **Tag status (forward-fix policy):** + - Release tags are **not** deleted by automation (workflow choice; not the same as GitHub immutable-release lock-in). + - If the tag was pushed before the failure, it remains on the remote; use a new release candidate to validate fixes, then re-run the final release when ready. **Actions Taken:** - - Release branch rolled back to pre-finalization state - - Release tag deleted (if created) + - Release branch reset to pre-finalization state (best-effort) - This issue created for investigation **Manual Cleanup May Be Needed:** - If images were pushed to GHCR before the failure, they are **not** automatically deleted. Check \`ghcr.io/vig-os/devcontainer:${publishVersion}-*\` and remove any orphaned images manually. + - If a **draft** GitHub Release exists for this tag, edit or manage it from the Releases UI (**publishing** locks the linked tag and assets when **immutable releases** are enabled). **Next Steps:** 1. Review the workflow logs to identify the root cause 2. Check rollback results above; fix any partial rollback manually 3. Fix the issue on the release branch - 4. Re-run the workflow when ready + 4. Publish a new release candidate to validate the fix; re-run the final workflow when ready For details, check the workflow run linked above. `; @@ -1514,8 +1548,8 @@ jobs: echo "✗ Release workflow failed" echo "" echo "Automatic rollback completed:" - echo " - Release branch reset to pre-finalization state" - echo " - Release tag deleted (if created)" + echo " - Release branch reset to pre-finalization state (best-effort)" + echo " - Tags were not deleted (forward-fix policy)" echo " - GitHub issue created for investigation" echo "" echo "Check the workflow logs and issue for details" diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e13575..be7d3252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cursor-agent install is now resilient to CDN failures** ([#434](https://github.com/vig-os/devcontainer/issues/434)) - Retries 3 times with backoff before giving up - Build succeeds without cursor-agent when Cursor's CDN is unavailable +- **Immutable GitHub releases, tag rulesets, and forward-fix policy** ([#446](https://github.com/vig-os/devcontainer/issues/446)) + - Final releases create a **draft** GitHub Release for human review before publishing; rollback no longer deletes remote tags + - Release workflows skip redundant tag push when the tag already matches the finalized commit; workspace `release-core` / `release-publish` and smoke-test failure guidance updated accordingly + - Document tag rulesets, immutable releases, and recovery in `docs/RELEASE_CYCLE.md`, `docs/DOWNSTREAM_RELEASE.md`, and `docs/CROSS_REPO_RELEASE_GATE.md` ### Removed diff --git a/assets/smoke-test/.github/workflows/repository-dispatch.yml b/assets/smoke-test/.github/workflows/repository-dispatch.yml index bc12e5c7..882cd213 100644 --- a/assets/smoke-test/.github/workflows/repository-dispatch.yml +++ b/assets/smoke-test/.github/workflows/repository-dispatch.yml @@ -894,7 +894,8 @@ jobs: ## Manual cleanup guidance - Inspect deploy/release PRs and workflow logs before retrying. - If needed, close stale release PRs and delete stale \`release/\` branch. - - Re-dispatch using a new RC tag/version once root cause is fixed. + - Do not rewrite or delete **published** GitHub Releases (or their linked tags when **immutable releases** are enabled) to retry the same version; bare git tags without a published release are not locked by that feature unless a tag ruleset applies. + - After fixing the root cause upstream, publish a **new** RC tag (or a new final attempt only after branch/tag state matches your release policy), then rely on a fresh dispatch. EOF )" diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index a0e13575..be7d3252 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -75,6 +75,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cursor-agent install is now resilient to CDN failures** ([#434](https://github.com/vig-os/devcontainer/issues/434)) - Retries 3 times with backoff before giving up - Build succeeds without cursor-agent when Cursor's CDN is unavailable +- **Immutable GitHub releases, tag rulesets, and forward-fix policy** ([#446](https://github.com/vig-os/devcontainer/issues/446)) + - Final releases create a **draft** GitHub Release for human review before publishing; rollback no longer deletes remote tags + - Release workflows skip redundant tag push when the tag already matches the finalized commit; workspace `release-core` / `release-publish` and smoke-test failure guidance updated accordingly + - Document tag rulesets, immutable releases, and recovery in `docs/RELEASE_CYCLE.md`, `docs/DOWNSTREAM_RELEASE.md`, and `docs/CROSS_REPO_RELEASE_GATE.md` ### Removed diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index 1bd1338b..ea77212f 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -57,6 +57,9 @@ on: # yamllint disable-line rule:truthy finalize_sha: description: "Release branch SHA after finalization" value: ${{ jobs.finalize.outputs.finalize_sha }} + tag_already_exists: + description: "Remote publish tag already exists at finalize SHA (retry path)" + value: ${{ jobs.finalize.outputs.tag_already_exists }} image_tag: description: "Resolved devcontainer image tag" value: ${{ jobs.validate.outputs.image_tag }} @@ -239,18 +242,7 @@ jobs: echo "publish_version=$PUBLISH_VERSION" >> "$GITHUB_OUTPUT" echo "next_rc=$NEXT_RC" >> "$GITHUB_OUTPUT" - - name: Verify publish tag does not exist - env: - PUBLISH_VERSION: ${{ steps.publish_meta.outputs.publish_version }} - run: | - if git ls-remote --exit-code --tags --refs origin "refs/tags/$PUBLISH_VERSION" > /dev/null 2>&1; then - echo "ERROR: Tag $PUBLISH_VERSION already exists" - exit 1 - fi - if git rev-parse -q --verify "refs/tags/$PUBLISH_VERSION" > /dev/null; then - echo "ERROR: Local tag $PUBLISH_VERSION already exists" - exit 1 - fi + # Remote tag vs finalize SHA is validated in the finalize job (tag_state) after finalize_sha is known. - name: Find and verify PR id: pr @@ -349,6 +341,7 @@ jobs: if: ${{ inputs.dry_run != true }} outputs: finalize_sha: ${{ steps.finalize.outputs.finalize_sha }} + tag_already_exists: ${{ steps.tag_state.outputs.tag_already_exists }} steps: - name: Generate commit app token @@ -492,6 +485,31 @@ jobs: fi echo "finalize_sha=$FINALIZE_SHA" >> "$GITHUB_OUTPUT" + - name: Check if publish tag already exists at finalize SHA + id: tag_state + env: + PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} + FINALIZE_SHA: ${{ steps.finalize.outputs.finalize_sha }} + run: | + set -euo pipefail + REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}" || true) + if [ -z "$REMOTE_LINE" ]; then + echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" + echo "No remote tag ${PUBLISH_VERSION} yet" + exit 0 + fi + REMOTE_PEEL=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') + if [ -z "$REMOTE_PEEL" ]; then + echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$REMOTE_PEEL" != "$FINALIZE_SHA" ]; then + echo "ERROR: Tag $PUBLISH_VERSION exists but peeled commit is $REMOTE_PEEL; expected $FINALIZE_SHA (finalize SHA)" + exit 1 + fi + echo "tag_already_exists=true" >> "$GITHUB_OUTPUT" + echo "Remote tag $PUBLISH_VERSION already points to finalize SHA; publish will skip tag create/push" + test: name: Test Finalized Release needs: [validate, finalize] diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index 60ae8dd7..37d56ec4 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -33,6 +33,11 @@ on: # yamllint disable-line rule:truthy required: false default: "41898282+github-actions[bot]@users.noreply.github.com" type: string + tag_already_exists: + description: "Skip tag create/push when remote tag already points at finalize SHA" + required: false + default: false + type: boolean secrets: token: required: false @@ -134,6 +139,7 @@ jobs: git config user.email "$GIT_USER_EMAIL" - name: Create and push tag + if: ${{ !inputs.tag_already_exists }} env: PUBLISH_VERSION: ${{ inputs.publish_version }} run: | @@ -181,8 +187,21 @@ jobs: GH_TOKEN: ${{ steps.auth.outputs.token }} run: | set -euo pipefail - if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then - echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" + if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" --json isDraft,isPrerelease >/dev/null 2>&1; then + RELEASE_JSON=$(gh release view "$PUBLISH_VERSION" --json isDraft,isPrerelease) + IS_DRAFT=$(printf '%s' "$RELEASE_JSON" | jq -r '.isDraft') + if [ "$IS_DRAFT" = "true" ]; then + echo "Draft GitHub Release already exists for $PUBLISH_VERSION; skipping create (retry path)." + exit 0 + fi + if [ "$RELEASE_KIND" = "candidate" ]; then + IS_PRERELEASE=$(printf '%s' "$RELEASE_JSON" | jq -r '.isPrerelease') + if [ "$IS_PRERELEASE" = "true" ]; then + echo "Pre-release already exists for $PUBLISH_VERSION; skipping create (candidate retry path)." + exit 0 + fi + fi + echo "ERROR: Published (non-draft) GitHub Release already exists for tag $PUBLISH_VERSION" exit 1 fi if [ "$RELEASE_KIND" = "candidate" ]; then @@ -195,7 +214,9 @@ jobs: retry --retries 3 --backoff 5 --max-backoff 30 -- gh release create "$PUBLISH_VERSION" \ --title "$PUBLISH_VERSION" \ --notes-file /tmp/release-notes.md \ - --verify-tag + --verify-tag \ + --draft + echo "Draft GitHub Release created; publish from ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases when review is complete." fi - name: Set outputs diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index 81b097ee..e32d1c56 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -104,6 +104,7 @@ jobs: release_kind: ${{ needs.core.outputs.release_kind }} git_user_name: ${{ inputs.git-user-name }} git_user_email: ${{ inputs.git-user-email }} + tag_already_exists: ${{ needs.core.outputs.tag_already_exists }} secrets: inherit rollback: @@ -183,17 +184,6 @@ jobs: fi fi - - name: Delete tag if created - if: ${{ needs.core.outputs.publish_version != '' }} - continue-on-error: true - env: - PUBLISH_VERSION: ${{ needs.core.outputs.publish_version }} - run: | - set -euo pipefail - if git ls-remote origin "refs/tags/$PUBLISH_VERSION" | grep -q "$PUBLISH_VERSION"; then - retry --retries 3 --backoff 5 --max-backoff 30 -- git push origin ":refs/tags/$PUBLISH_VERSION" || true - fi - - name: Create failure issue if: ${{ needs.core.outputs.version != '' }} env: @@ -213,5 +203,9 @@ jobs: **Release PR:** #$PR_NUMBER **Automatic rollback attempted:** - - Release branch reset to pre-finalization state - - Release tag deleted (if created)" + - Release branch reset to pre-finalization state (best-effort) + + **Tag status (forward-fix policy):** + - Release tags are not deleted by automation (workflow choice; GitHub immutable-release lock-in applies only after a release is **published** when that setting is enabled). If a tag was pushed before the failure, it remains on the remote. + - Use a new release candidate to validate fixes, then re-run the final release when ready. + - If a draft GitHub Release exists, manage it from the Releases UI; **publishing** locks the linked tag and assets when **immutable releases** are enabled." diff --git a/docs/CROSS_REPO_RELEASE_GATE.md b/docs/CROSS_REPO_RELEASE_GATE.md index cf529e0e..b902ae36 100644 --- a/docs/CROSS_REPO_RELEASE_GATE.md +++ b/docs/CROSS_REPO_RELEASE_GATE.md @@ -71,6 +71,8 @@ The orchestrator validates: If any of these checks fail, the release workflow fails and rollback handling is evaluated by workflow conditions. +**Immutable releases:** Where **immutable releases** are enabled, a **published** GitHub Release (including a published **pre-release**) locks its **linked** tag and assets; they cannot be rewritten via normal GitHub UI/API. Downstream and smoke-test flows should fix forward with a new RC or version rather than deleting tags or releases. See [Immutable releases, tag rulesets, and forward-fix policy](RELEASE_CYCLE.md#immutable-releases-tag-rulesets-and-forward-fix-policy) for full policy and recovery procedures (including tags without a published release and the forward-fix no-delete policy). + ## Expected Output ### Success Signals diff --git a/docs/DOWNSTREAM_RELEASE.md b/docs/DOWNSTREAM_RELEASE.md index f4a437ee..3c323810 100644 --- a/docs/DOWNSTREAM_RELEASE.md +++ b/docs/DOWNSTREAM_RELEASE.md @@ -1,6 +1,6 @@ # Downstream Release Workflows -This document describes the downstream release workflows shipped in `assets/workspace/.github/workflows/`. +This document is the **only** place that describes the release process for **consumer projects** that install workflows from `assets/workspace/`. The upstream devcontainer and smoke-test validation flow is documented in [`docs/RELEASE_CYCLE.md`](RELEASE_CYCLE.md) and [`docs/CROSS_REPO_RELEASE_GATE.md`](CROSS_REPO_RELEASE_GATE.md). ## Overview @@ -14,17 +14,23 @@ The downstream template uses a split release architecture: All files are deployed from `assets/workspace/` by `init-workspace.sh`. -On failure, the orchestrator runs a single consolidated rollback that resets the release branch, removes any created tag, and opens a failure issue. +On failure, the orchestrator runs a single consolidated rollback that resets the release branch (best-effort), does **not** delete tags (forward-fix policy), and opens a failure issue with forward-fix guidance. ## Release Modes `release.yml` supports two release modes via `release_kind`: - `candidate` (default): computes and publishes the next `X.Y.Z-rcN` tag as a GitHub pre-release (or use optional `rc-number` to pin `N` when orchestrating from an upstream dispatch; see `docs/CROSS_REPO_RELEASE_GATE.md`) -- `final`: publishes `X.Y.Z`, finalizes `CHANGELOG.md` release date, and runs `sync-issues` +- `final`: publishes `X.Y.Z`, finalizes `CHANGELOG.md` release date, runs `sync-issues`, and creates a **draft** GitHub Release (publish from the UI when review is complete; aligns with GitHub’s [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) and [draft-first guidance](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases#best-practices-for-publishing-immutable-releases)) Candidate mode keeps release branch content unchanged (no CHANGELOG date finalization). Final mode performs changelog finalization before publish. +## Immutable releases, tag rulesets, and forward-fix policy (downstream) + +- **Candidate (`X.Y.Z-rcN`)**: `release-publish.yml` creates a **published** GitHub **pre-release** for the RC tag. With **immutable releases** enabled, **publishing** that pre-release locks the **linked** tag and assets (see [upstream policy](RELEASE_CYCLE.md#immutable-releases-tag-rulesets-and-forward-fix-policy)); iterate with a **new** RC tag. +- **Final (`X.Y.Z`)**: Automation creates a **draft** GitHub Release; a human **publishes** it from the Releases UI when ready—**publishing** applies immutable-release lock-in for the linked tag and assets when that setting is enabled. Enable **immutable releases** and **tag rulesets** on each consumer repository (and org policy) as needed; see [Preventing changes to your releases](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases). +- **Rollback**: The orchestrator resets the release branch and does **not** delete tags (forward-fix policy); recover with a new RC or a careful final retry per workflow logs. + ## Workflow Interface The orchestrator `release.yml` passes release context directly to the called reusable workflows: diff --git a/docs/RELEASE_CYCLE.md b/docs/RELEASE_CYCLE.md index bd7d096d..26bc0391 100644 --- a/docs/RELEASE_CYCLE.md +++ b/docs/RELEASE_CYCLE.md @@ -124,7 +124,7 @@ graph TB N --> O["Workflow: set release date
build & test"] O --> P{Tests pass?} P -->|No| Q["Automatic rollback
+ issue creation"] - P -->|Yes| R["Workflow: create tag
publish images
publish GitHub Release (final only)"] + P -->|Yes| R["Workflow: create tag
publish images
draft GitHub Release (final only)"] R --> S["Merge PR to main"] S --> T["Workflow: sync-main-to-dev
PR-based sync"] ``` @@ -134,9 +134,21 @@ graph TB 1. **Preparation** (`prepare-release`): Freeze CHANGELOG on dev, create release branch, reset Unreleased on dev, open draft PR 2. **Review & Testing**: CI validation, mark PR ready, fix issues, get approvals 3. **Candidate Publish** (`publish-candidate`): Build/test/publish `X.Y.Z-rcN` and dispatch cross-repo validation workflow -4. **Cross-Repo Validation Gate (automated prerequisite)**: Final release validate step requires downstream pre-release for the latest RC tag -5. **Finalization & Post-Release**: Publish final image/tag, then merge PR to main and let sync automation update dev +4. **Cross-Repo Validation Gate (automated prerequisite)**: Final release validate step requires a GitHub **pre-release** for the latest RC tag in the validation repo (`vig-os/devcontainer-smoke-test`); see `docs/CROSS_REPO_RELEASE_GATE.md` +5. **Finalization & Post-Release**: Publish final image/tag, open a **draft** GitHub Release for human review, then merge PR to main and let sync automation update dev +## Immutable releases, tag rulesets, and forward-fix policy + +This section applies to **`vig-os/devcontainer`** (this repo) and, for matching supply-chain posture, **`vig-os/devcontainer-smoke-test`**. It does **not** describe release workflows in consumer projects; those are documented in [Downstream release workflows](DOWNSTREAM_RELEASE.md). + +**GitHub immutability (organization settings):** With **immutable releases** enabled, a **published** GitHub Release (including a published **pre-release**) locks its **linked** tag and release assets. A git tag with **no** linked published release is **not** immutable via that feature. **Tag rulesets** are separate: they can restrict creating, updating, or deleting tags regardless of releases. + +**Policy (behavior in [`.github/workflows/release.yml`](../.github/workflows/release.yml) for this repository):** + +- **Final releases (`X.Y.Z`)**: After build/publish, automation creates a **draft** GitHub Release (see GitHub’s [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) and [draft-first best practice](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases#best-practices-for-publishing-immutable-releases)). A human **publishes** the draft from the **Releases** UI when satisfied; with **immutable releases** enabled, **publishing** makes the linked tag and assets immutable. +- **Candidates (`X.Y.Z-rcN`)**: Candidate mode creates and pushes the **git tag**, publishes **GHCR** images (and related signing/attestations), and triggers smoke-test dispatch. It does **not** create a GitHub **Release** object for the RC—only **final** runs use `gh release create` (as a draft). The RC tag is therefore **not** locked by immutable releases until/unless you add a published release or a tag ruleset applies. +- **Forward-fix (automation)**: Rollback **does not** delete remote tags—this is a **workflow choice** to avoid rewriting history, not GitHub declaring the tag immutable. Recovery is **forward-fix** (new RC, then final when ready). If a retry publishes the same tag, the workflow skips re-creating the tag when it already points at the finalized commit. +- **Repository settings** (manual; not stored in git): enable **immutable releases** and **tag rulesets** as appropriate for `vig-os/devcontainer` and `vig-os/devcontainer-smoke-test`. See GitHub: [Preventing changes to your releases](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases). Use **RELEASE_APP** in bypass lists only where tag creation requires it. --- @@ -342,7 +354,7 @@ The `release.yml` workflow performs the entire remaining release process. Behavi - Validates semantic version format - Checks release branch exists - Verifies CHANGELOG has `## [X.Y.Z] - TBD` - - Verifies tag doesn't already exist + - For **final**: allows an existing **draft** GitHub Release for the publish tag (retry path); rejects a **published** (non-draft) release for the same tag - Confirms PR exists, is not draft, is approved, and CI passed - Records pre-finalization commit for rollback @@ -359,9 +371,9 @@ The `release.yml` workflow performs the entire remaining release process. Behavi 4. ✅ **Publish** job (runs only if all builds/tests pass) - Candidate mode: infers next `rcN`, creates annotated tag `X.Y.Z-rcN`, publishes candidate manifests - - Final mode: creates annotated tag `X.Y.Z`, publishes final manifests - - Pushes tag to origin - - Final mode only: extracts release notes from finalized `CHANGELOG.md` and publishes GitHub Release for `X.Y.Z` + - Final mode: creates annotated tag `X.Y.Z` (or skips create/push if the tag already points at the finalized commit), publishes final manifests + - Pushes tag to origin when needed + - Final mode only: extracts release notes from finalized `CHANGELOG.md` and creates a **draft** GitHub Release for `X.Y.Z` - Downloads tested images from artifacts - Logs in to GitHub Container Registry - Pushes images to GHCR with architecture-specific tags @@ -370,10 +382,10 @@ The `release.yml` workflow performs the entire remaining release process. Behavi - Verifies manifests exist - Candidate and final modes: trigger cross-repository validation dispatch with `client_payload[tag]` plus source metadata (`source_repo`, `source_workflow`, `source_run_id`, `source_run_url`, `source_sha`, `correlation_id`) -5. ✅ **Rollback** job (runs if ANY job failed) - - Resets release branch to pre-finalization state - - Deletes tag if it was created - - Creates GitHub issue with failure details for investigation +5. ✅ **Rollback** job (runs if ANY job failed among validate/finalize/build-and-test/publish) + - Resets release branch to pre-finalization state (best-effort) + - Does **not** delete tags (forward-fix policy) + - Creates GitHub issue with failure details and forward-fix guidance **Output example:** @@ -398,7 +410,7 @@ Release Summary: - **Earlier validation**: All checks happen at the start in CI - **Safer workflow**: Tag is created AFTER successful build/test, not before -- **Automatic rollback**: Failed releases are automatically cleaned up +- **Automatic rollback**: Failed releases roll back the release branch; tags are not deleted (forward-fix policy, independent of whether GitHub immutability applies)—recover with a new RC or a careful final retry per docs above - **Audit trail**: All steps are recorded in GitHub Actions logs with actor information - **Reproducible**: Uses consistent CI environment, not dependent on local tooling @@ -408,7 +420,11 @@ Cross-repository validation gate rationale, mechanics, payload contract, and pas ### Phase 5: Post-Release Cleanup -**Manual step:** Merge the release PR to main. +**Manual steps (final releases):** + +1. Verify the workflow run succeeded and smoke-test dispatch completed as expected. +2. Open **Releases** in GitHub, review the **draft** release for `X.Y.Z`, and **Publish** it when ready (with **immutable releases** enabled, **publishing** is what locks the linked tag and assets). +3. Merge the release PR to `main`. ```bash # Verify release workflow succeeded @@ -529,7 +545,7 @@ Release automation relies on two GitHub Apps with different scopes: Additional requirement: - `COMMIT_APP` must be allowed in branch protection bypass rules for `dev` so sync commits can be pushed by automation. -- `RELEASE_APP` must be installed on the validation repository with Contents read and Actions read/write permissions so `release.yml` can send `repository_dispatch` and `repository-dispatch.yml` can trigger downstream workflow dispatch events for candidate and final release validation. +- `RELEASE_APP` must be installed on the validation repository (`vig-os/devcontainer-smoke-test`) with Contents read and Actions read/write permissions so `release.yml` can send `repository_dispatch` and `repository-dispatch.yml` can trigger workflow runs there for candidate and final release validation. #### prepare-release.yml (Release Preparation Workflow) @@ -586,7 +602,7 @@ gh workflow run prepare-release.yml --ref dev -f "version=1.0.0" -f "dry-run=tru - Computes publish tag (`X.Y.Z-rcN` for candidate, `X.Y.Z` for final) - Verifies release branch exists - Checks CHANGELOG has `[X.Y.Z] - TBD` - - Confirms publish tag doesn't exist + - For **final**: rejects a **published** GitHub Release for the publish tag; allows an existing **draft** (retry path) - Verifies PR: not draft, approved, CI passed - Records pre-finalization commit for rollback - Outputs: PR number, release date, pre-finalization SHA, publish tag metadata @@ -594,6 +610,7 @@ gh workflow run prepare-release.yml --ref dev -f "version=1.0.0" -f "dry-run=tru 2. **finalize** (skipped if dry-run) - Conditionally updates release branch - **Candidate**: No CHANGELOG changes, no sync-issues. Outputs current release branch HEAD SHA. - **Final**: Sets release date in CHANGELOG (TBD → YYYY-MM-DD), regenerates docs, commits all tracked finalization changes via dynamic file list, refreshes release PR body from finalized changelog content, triggers sync-issues, outputs finalized SHA. + - After computing `finalize_sha`, checks whether the remote publish tag already points at that SHA (retry path; skips redundant tag push in **publish**). 3. **build-and-test** (matrix: amd64, arm64) - Builds and validates images - Builds container image for architecture @@ -604,24 +621,23 @@ gh workflow run prepare-release.yml --ref dev -f "version=1.0.0" -f "dry-run=tru 4. **publish** (runs if all builds/tests pass) - Creates tag and publishes - Candidate mode creates and pushes `X.Y.Z-rcN` (next available `N`) - - Final mode creates and pushes `X.Y.Z` - - Pushes tag - - Final mode only: publishes GitHub Release `X.Y.Z` with notes sourced from finalized `CHANGELOG.md` + - Final mode creates and pushes `X.Y.Z` unless the tag already exists at `finalize_sha` + - Final mode only: creates a **draft** GitHub Release `X.Y.Z` with notes sourced from finalized `CHANGELOG.md` - Downloads tested images from artifacts - Pushes images to GHCR - Creates multi-architecture manifest for computed publish tag - Updates `latest` only in final mode - Verifies manifests exist -5. **smoke-test** (runs after publish) - Triggers downstream validation +5. **smoke-test** (runs after publish) - Triggers `repository_dispatch` on `vig-os/devcontainer-smoke-test` (validation repo) - Candidate and final modes trigger cross-repository validation `repository_dispatch` with `client_payload[tag]=` - Dispatch failures mark the workflow as failed and create a targeted issue - - Dispatch failures do **not** rollback branch/tag, because published artifacts are already immutable at this point + - Dispatch failures do **not** rollback branch/tag: publish outputs are already public. Only a **published** GitHub Release locks its tag via **immutable releases**; RC tags in this repo have no release object. Automation still avoids tag deletion (forward-fix policy). 6. **rollback** (runs if validate/finalize/build-and-test/publish fails) - Cleans up partial state - - Resets release branch to pre-finalization state - - Deletes tag if it was created - - Creates GitHub issue with failure details + - Resets release branch to pre-finalization state (best-effort) + - Does **not** delete tags (forward-fix policy) + - Creates GitHub issue with failure details and forward-fix guidance **Manual trigger (for testing):** @@ -643,7 +659,8 @@ gh workflow run release.yml \ **Key characteristics:** - Tag created AFTER successful build/test (safer than before) -- Automatic rollback on validate/finalize/build-and-test/publish failure +- Final GitHub Release is a **draft** until a human publishes it from the UI +- Automatic rollback resets the release branch only; tags are not deleted (forward-fix policy) - All in one workflow for atomic operation - Audit trail in GitHub Actions logs - Dispatch is pinned to `release/X.Y.Z` so candidate/final runs use the release branch workflow definition @@ -709,19 +726,10 @@ gh workflow run release.yml \ --- -## Downstream Integration - -Downstream repositories that use the workspace template consume release workflows from: +## Related documentation -- `assets/workspace/.github/workflows/prepare-release.yml` -- `assets/workspace/.github/workflows/release.yml` -- `assets/workspace/.github/workflows/release-core.yml` -- `assets/workspace/.github/workflows/release-extension.yml` -- `assets/workspace/.github/workflows/release-publish.yml` - -The orchestrator (`release.yml`) runs core -> extension -> publish and uses contract validation for local `workflow_call` interfaces. Cross-repository validation details are documented in `docs/CROSS_REPO_RELEASE_GATE.md`. - -For contract details and extension ownership boundaries, see `docs/DOWNSTREAM_RELEASE.md`. +- **[Downstream release workflows](DOWNSTREAM_RELEASE.md)** — release process for **consumer projects** that deploy templates from `assets/workspace/` (prepare-release, release orchestration, extension hook, publish). This guide does not duplicate that material. +- **[Cross-repo release validation gate](CROSS_REPO_RELEASE_GATE.md)** — contract between this repo’s `release.yml` and `vig-os/devcontainer-smoke-test` (`repository_dispatch`, RC/final gates). ## QMS and Compliance @@ -880,19 +888,15 @@ gh run view # If it failed, check the sync-issues workflow logs and fix the issue ``` -#### "Tag already exists" +#### "Tag already exists" / wrong tag target -**Cause:** A previous release attempt didn't clean up the tag +**Cause:** A previous run pushed the tag, or the tag points at a different commit than the current finalized release. **Solution:** -```bash -# Delete the old tag -git push origin :refs/tags/ - -# Try again -just finalize-release -``` +- If the tag already points at the **same** finalized commit, re-run `just finalize-release `: the workflow skips re-pushing the tag and can complete the draft release step. +- If the tag points at the **wrong** commit, do **not** delete or move the tag when GitHub blocks it (published **immutable release** for that tag, or a restrictive **tag ruleset**)—publish a **new release candidate** with fixes, then run the final release again when ready. +- For a mistaken **draft** GitHub Release only, you may edit or delete the draft from the Releases UI per repository policy. ### Recovery Procedures @@ -921,8 +925,8 @@ gh issue list --label release # 3. Examine what was rolled back (issue will document this) # The workflow automatically: -# - Reset release branch to pre-finalization state -# - Deleted any tag that was created +# - Reset release branch to pre-finalization state (best-effort) +# - Left any pushed tags in place (forward-fix policy) # - Created this issue for investigation # 4. Fix the underlying issue on the release branch @@ -936,7 +940,7 @@ git commit -m "fix: address release issue Refs: #" git push origin release/X.Y.Z -# 5. Re-run the workflow +# 5. Re-run the workflow (or publish a new RC first if the tag already exists at the wrong commit) just finalize-release X.Y.Z ``` @@ -952,10 +956,8 @@ git checkout release/X.Y.Z git reset --hard $PRE_SHA git push --force-with-lease origin release/X.Y.Z -# Delete tag if it exists -git push origin :refs/tags/X.Y.Z - -# Fix the issue and re-run workflow +# Do not delete or force-move release tags when a tag ruleset or published immutable release blocks it. +# Fix forward with a new RC, then re-run the final workflow when ready. just finalize-release X.Y.Z ``` @@ -1000,7 +1002,7 @@ Follow [Semantic Versioning 2.0.0](https://semver.org/): - [CHANGELOG Format](../CHANGELOG.md) - Keep a Changelog standard - [Commit Message Standard](COMMIT_MESSAGE_STANDARD.md) - Commit format and validation -- [Downstream Release Workflows](DOWNSTREAM_RELEASE.md) - Downstream release contract and extension model +- [Downstream Release Workflows](DOWNSTREAM_RELEASE.md) - Release process for consumer projects using `assets/workspace/` templates (not this repo’s pipeline) - [Branch Naming Rules](../.cursor/rules/branch-naming.mdc) - Topic branch conventions - [IEC 62304](https://www.iso.org/standard/38421.html) - Medical device software lifecycle - [Semantic Versioning](https://semver.org/) - Version numbering scheme From 495e767930b79981291d844214b2327c0c11ddbe Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 26 Mar 2026 11:31:13 +0100 Subject: [PATCH 185/215] fix(ci): add lightweight-tag fallback and deduplicate release view call Refs: #446 --- .github/workflows/release.yml | 14 ++++++++++---- CHANGELOG.md | 3 +++ assets/workspace/.devcontainer/CHANGELOG.md | 3 +++ .../workspace/.github/workflows/release-core.yml | 11 +++++++---- .../.github/workflows/release-publish.yml | 6 ++++-- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 072f8569..93301d39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -765,18 +765,21 @@ jobs: run: | set -euo pipefail REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}" || true) + if [ -z "$REMOTE_LINE" ]; then + REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}" || true) + fi if [ -z "$REMOTE_LINE" ]; then echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" echo "No remote tag ${PUBLISH_VERSION} yet" exit 0 fi - REMOTE_PEEL=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') - if [ -z "$REMOTE_PEEL" ]; then + REMOTE_TARGET_SHA=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') + if [ -z "$REMOTE_TARGET_SHA" ]; then echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" exit 0 fi - if [ "$REMOTE_PEEL" != "$FINALIZE_SHA" ]; then - echo "ERROR: Tag $PUBLISH_VERSION exists but peeled commit is $REMOTE_PEEL; expected $FINALIZE_SHA (finalize SHA)" + if [ "$REMOTE_TARGET_SHA" != "$FINALIZE_SHA" ]; then + echo "ERROR: Tag $PUBLISH_VERSION exists but target commit is $REMOTE_TARGET_SHA; expected $FINALIZE_SHA (finalize SHA)" exit 1 fi echo "tag_already_exists=true" >> "$GITHUB_OUTPUT" @@ -916,6 +919,9 @@ jobs: if retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then LOCAL_TAG_TARGET_SHA=$(git rev-parse "$PUBLISH_VERSION^{}") REMOTE_TAG_TARGET_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') + if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then + REMOTE_TAG_TARGET_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION" | awk '{print $1}') + fi if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then echo "ERROR: Remote tag exists but target SHA could not be resolved: $PUBLISH_VERSION" exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index be7d3252..798893f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release validate fails early when GitHub Release already exists** ([#443](https://github.com/vig-os/devcontainer/issues/443)) - Validate job in `.github/workflows/release.yml` queries `GET /repos/.../releases/tags/` with retries and classifies errors like the downstream RC gate; only a documented not-found response is treated as “no release,” and ambiguous API failures fail closed before build/sign/publish - Publish job uses the same existence checks before and after `gh release create` instead of `gh release view` with discarded stderr +- **Release tag resolution and GitHub Release view retries** ([#446](https://github.com/vig-os/devcontainer/issues/446)) + - Fall back to plain `refs/tags/` when the peeled ref is empty (lightweight remote tags) in `.github/workflows/release.yml`, `release-core.yml`, and `release-publish.yml` + - Use one retried `gh release view` in workspace `release-publish.yml` so draft/prerelease skip paths parse JSON from the same successful response ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index be7d3252..798893f1 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -203,6 +203,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release validate fails early when GitHub Release already exists** ([#443](https://github.com/vig-os/devcontainer/issues/443)) - Validate job in `.github/workflows/release.yml` queries `GET /repos/.../releases/tags/` with retries and classifies errors like the downstream RC gate; only a documented not-found response is treated as “no release,” and ambiguous API failures fail closed before build/sign/publish - Publish job uses the same existence checks before and after `gh release create` instead of `gh release view` with discarded stderr +- **Release tag resolution and GitHub Release view retries** ([#446](https://github.com/vig-os/devcontainer/issues/446)) + - Fall back to plain `refs/tags/` when the peeled ref is empty (lightweight remote tags) in `.github/workflows/release.yml`, `release-core.yml`, and `release-publish.yml` + - Use one retried `gh release view` in workspace `release-publish.yml` so draft/prerelease skip paths parse JSON from the same successful response ### Security diff --git a/assets/workspace/.github/workflows/release-core.yml b/assets/workspace/.github/workflows/release-core.yml index ea77212f..1432b637 100644 --- a/assets/workspace/.github/workflows/release-core.yml +++ b/assets/workspace/.github/workflows/release-core.yml @@ -493,18 +493,21 @@ jobs: run: | set -euo pipefail REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}" || true) + if [ -z "$REMOTE_LINE" ]; then + REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}" || true) + fi if [ -z "$REMOTE_LINE" ]; then echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" echo "No remote tag ${PUBLISH_VERSION} yet" exit 0 fi - REMOTE_PEEL=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') - if [ -z "$REMOTE_PEEL" ]; then + REMOTE_TARGET_SHA=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') + if [ -z "$REMOTE_TARGET_SHA" ]; then echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" exit 0 fi - if [ "$REMOTE_PEEL" != "$FINALIZE_SHA" ]; then - echo "ERROR: Tag $PUBLISH_VERSION exists but peeled commit is $REMOTE_PEEL; expected $FINALIZE_SHA (finalize SHA)" + if [ "$REMOTE_TARGET_SHA" != "$FINALIZE_SHA" ]; then + echo "ERROR: Tag $PUBLISH_VERSION exists but target commit is $REMOTE_TARGET_SHA; expected $FINALIZE_SHA (finalize SHA)" exit 1 fi echo "tag_already_exists=true" >> "$GITHUB_OUTPUT" diff --git a/assets/workspace/.github/workflows/release-publish.yml b/assets/workspace/.github/workflows/release-publish.yml index 37d56ec4..00afaef6 100644 --- a/assets/workspace/.github/workflows/release-publish.yml +++ b/assets/workspace/.github/workflows/release-publish.yml @@ -149,6 +149,9 @@ jobs: if retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags --refs origin "$PUBLISH_VERSION" | grep -q "refs/tags/$PUBLISH_VERSION$"; then LOCAL_TAG_TARGET_SHA=$(git rev-parse "$PUBLISH_VERSION^{}") REMOTE_TAG_TARGET_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION^{}" | awk '{print $1}') + if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then + REMOTE_TAG_TARGET_SHA=$(retry --retries 3 --backoff 5 --max-backoff 30 -- git ls-remote --tags origin "refs/tags/$PUBLISH_VERSION" | awk '{print $1}') + fi if [ -z "$REMOTE_TAG_TARGET_SHA" ]; then echo "ERROR: Remote tag exists but target SHA could not be resolved: $PUBLISH_VERSION" exit 1 @@ -187,8 +190,7 @@ jobs: GH_TOKEN: ${{ steps.auth.outputs.token }} run: | set -euo pipefail - if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" --json isDraft,isPrerelease >/dev/null 2>&1; then - RELEASE_JSON=$(gh release view "$PUBLISH_VERSION" --json isDraft,isPrerelease) + if RELEASE_JSON=$(retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" --json isDraft,isPrerelease 2>/dev/null); then IS_DRAFT=$(printf '%s' "$RELEASE_JSON" | jq -r '.isDraft') if [ "$IS_DRAFT" = "true" ]; then echo "Draft GitHub Release already exists for $PUBLISH_VERSION; skipping create (retry path)." From 5bd0444449dabe7e1687e0bbd79648ffd17be785 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 26 Mar 2026 12:55:57 +0100 Subject: [PATCH 186/215] fix(ci): use H1 release title and drop Release Content in PR bodies Refs: #449 --- .github/workflows/prepare-release.yml | 4 +--- .github/workflows/release.yml | 2 -- assets/workspace/.github/workflows/prepare-release.yml | 4 +--- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 74a4ab88..efda9c3e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -295,12 +295,10 @@ jobs: run: | set -euo pipefail - PR_BODY="## Release $VERSION + PR_BODY="# Release $VERSION This PR prepares release $VERSION for merge to main. - ### Release Content - $CHANGELOG_CONTENT " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93301d39..37170df4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -731,8 +731,6 @@ jobs: This PR prepares release $VERSION for merge to main. - ### Release Content - $CHANGELOG_CONTENT EOF diff --git a/assets/workspace/.github/workflows/prepare-release.yml b/assets/workspace/.github/workflows/prepare-release.yml index cc4b6379..7f1a6a81 100644 --- a/assets/workspace/.github/workflows/prepare-release.yml +++ b/assets/workspace/.github/workflows/prepare-release.yml @@ -286,12 +286,10 @@ jobs: CHANGELOG_CONTENT: ${{ steps.changelog.outputs.changelog }} run: | set -euo pipefail - PR_BODY="## Release $VERSION + PR_BODY="# Release $VERSION This PR prepares release $VERSION for merge to main. - ### Release Content - $CHANGELOG_CONTENT " From 622568e19f1ec316c8ccf9e6a02f97fb713bc9e8 Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 26 Mar 2026 15:43:39 +0100 Subject: [PATCH 187/215] fix(ci): coerce tag_already_exists for workspace release publish Refs: #451 --- CHANGELOG.md | 2 ++ assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ assets/workspace/.github/workflows/release.yml | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 798893f1..72cce290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -206,6 +206,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release tag resolution and GitHub Release view retries** ([#446](https://github.com/vig-os/devcontainer/issues/446)) - Fall back to plain `refs/tags/` when the peeled ref is empty (lightweight remote tags) in `.github/workflows/release.yml`, `release-core.yml`, and `release-publish.yml` - Use one retried `gh release view` in workspace `release-publish.yml` so draft/prerelease skip paths parse JSON from the same successful response +- **Workspace release publish `tag_already_exists` input coercion** ([#451](https://github.com/vig-os/devcontainer/issues/451)) + - Pass a boolean into `release-publish.yml` via `needs.core.outputs.tag_already_exists == 'true'` so `workflow_call` does not reject string `"true"`/`"false"` job outputs ### Security diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 798893f1..72cce290 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -206,6 +206,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Release tag resolution and GitHub Release view retries** ([#446](https://github.com/vig-os/devcontainer/issues/446)) - Fall back to plain `refs/tags/` when the peeled ref is empty (lightweight remote tags) in `.github/workflows/release.yml`, `release-core.yml`, and `release-publish.yml` - Use one retried `gh release view` in workspace `release-publish.yml` so draft/prerelease skip paths parse JSON from the same successful response +- **Workspace release publish `tag_already_exists` input coercion** ([#451](https://github.com/vig-os/devcontainer/issues/451)) + - Pass a boolean into `release-publish.yml` via `needs.core.outputs.tag_already_exists == 'true'` so `workflow_call` does not reject string `"true"`/`"false"` job outputs ### Security diff --git a/assets/workspace/.github/workflows/release.yml b/assets/workspace/.github/workflows/release.yml index e32d1c56..1a87458d 100644 --- a/assets/workspace/.github/workflows/release.yml +++ b/assets/workspace/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: release_kind: ${{ needs.core.outputs.release_kind }} git_user_name: ${{ inputs.git-user-name }} git_user_email: ${{ inputs.git-user-email }} - tag_already_exists: ${{ needs.core.outputs.tag_already_exists }} + tag_already_exists: ${{ needs.core.outputs.tag_already_exists == 'true' }} secrets: inherit rollback: From be51ce5bbc7af79278373ba95cd84db283e385dc Mon Sep 17 00:00:00 2001 From: Carlos Vigo Date: Thu, 26 Mar 2026 16:03:03 +0100 Subject: [PATCH 188/215] chore(image): expect GitHub CLI 2.89 in image version checks --- CHANGELOG.md | 2 ++ assets/workspace/.devcontainer/CHANGELOG.md | 2 ++ tests/test_image.py | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72cce290..44864e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Final releases create a **draft** GitHub Release for human review before publishing; rollback no longer deletes remote tags - Release workflows skip redundant tag push when the tag already matches the finalized commit; workspace `release-core` / `release-publish` and smoke-test failure guidance updated accordingly - Document tag rulesets, immutable releases, and recovery in `docs/RELEASE_CYCLE.md`, `docs/DOWNSTREAM_RELEASE.md`, and `docs/CROSS_REPO_RELEASE_GATE.md` +- **Container image tests expect current GitHub CLI minor line** + - Update `tests/test_image.py` `EXPECTED_VERSIONS["gh"]` to `2.89.` to match the CLI shipped in the image ### Removed diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index 72cce290..44864e75 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -79,6 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Final releases create a **draft** GitHub Release for human review before publishing; rollback no longer deletes remote tags - Release workflows skip redundant tag push when the tag already matches the finalized commit; workspace `release-core` / `release-publish` and smoke-test failure guidance updated accordingly - Document tag rulesets, immutable releases, and recovery in `docs/RELEASE_CYCLE.md`, `docs/DOWNSTREAM_RELEASE.md`, and `docs/CROSS_REPO_RELEASE_GATE.md` +- **Container image tests expect current GitHub CLI minor line** + - Update `tests/test_image.py` `EXPECTED_VERSIONS["gh"]` to `2.89.` to match the CLI shipped in the image ### Removed diff --git a/tests/test_image.py b/tests/test_image.py index 02717ad7..e1b82076 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -19,7 +19,7 @@ EXPECTED_VERSIONS = { "git": "2.", # Major version check (from apt package) "curl": "8.", # Major version check (from apt package) - "gh": "2.88.", # Minor version check (GitHub CLI (manually installed from latest release) + "gh": "2.89.", # Minor version check (GitHub CLI (manually installed from latest release) "uv": "0.11.", # Minor version check (manually installed from latest release) "python": "3.12", # Python (from base image) "pre_commit": "4.5.", # Minor version check (installed via uv pip) From ef7eb6eae9a9e85b7fb401572e1b870704d264a8 Mon Sep 17 00:00:00 2001 From: "commit-action-bot[bot]" <248498966+commit-action-bot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:53:44 +0000 Subject: [PATCH 189/215] chore: sync issues and PRs --- docs/issues/issue-443.md | 162 +++++++++++++++++++++ docs/issues/issue-444.md | 51 +++++++ docs/issues/issue-446.md | 52 +++++++ docs/issues/issue-449.md | 59 ++++++++ docs/issues/issue-451.md | 95 ++++++++++++ docs/issues/issue-453.md | 53 +++++++ docs/issues/issue-454.md | 53 +++++++ docs/pull-requests/pr-342.md | 272 +++++++++++++++++++++++++---------- docs/pull-requests/pr-447.md | 144 +++++++++++++++++++ docs/pull-requests/pr-448.md | 272 +++++++++++++++++++++++++++++++++++ docs/pull-requests/pr-450.md | 89 ++++++++++++ docs/pull-requests/pr-452.md | 95 ++++++++++++ 12 files changed, 1324 insertions(+), 73 deletions(-) create mode 100644 docs/issues/issue-443.md create mode 100644 docs/issues/issue-444.md create mode 100644 docs/issues/issue-446.md create mode 100644 docs/issues/issue-449.md create mode 100644 docs/issues/issue-451.md create mode 100644 docs/issues/issue-453.md create mode 100644 docs/issues/issue-454.md create mode 100644 docs/pull-requests/pr-447.md create mode 100644 docs/pull-requests/pr-448.md create mode 100644 docs/pull-requests/pr-450.md create mode 100644 docs/pull-requests/pr-452.md diff --git a/docs/issues/issue-443.md b/docs/issues/issue-443.md new file mode 100644 index 00000000..a6b59d5b --- /dev/null +++ b/docs/issues/issue-443.md @@ -0,0 +1,162 @@ +--- +type: issue +state: closed +created: 2026-03-25T17:15:30Z +updated: 2026-03-26T07:32:48Z +author: github-actions[bot] +author_url: https://github.com/github-actions[bot] +url: https://github.com/vig-os/devcontainer/issues/443 +comments: 1 +labels: bug, area:ci +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-26T17:53:32.306Z +--- + +# [Issue 443]: [Release 0.3.1 failed -- automatic rollback](https://github.com/vig-os/devcontainer/issues/443) + + +Release 0.3.1 encountered an error during the automated release workflow. + +**Failed Jobs:** publish + +**Workflow Run:** [View logs](https://github.com/vig-os/devcontainer/actions/runs/23553565067) + +**Release PR:** #342 + +**Rollback Results:** +- Branch rollback: success +- Tag deletion: success + +**Actions Taken:** +- Release branch rolled back to pre-finalization state +- Release tag deleted (if created) +- This issue created for investigation + +**Manual Cleanup May Be Needed:** +- If images were pushed to GHCR before the failure, they are **not** automatically deleted. Check `ghcr.io/vig-os/devcontainer:0.3.1-*` and remove any orphaned images manually. + +**Next Steps:** +1. Review the workflow logs to identify the root cause +2. Check rollback results above; fix any partial rollback manually +3. Fix the issue on the release branch +4. Re-run the workflow when ready + +For details, check the workflow run linked above. + +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 25, 2026 at 06:28 PM_ + +## Investigation + +### Root cause + +Root cause **not conclusively identified**. The `Publish final GitHub Release` step failed because `gh release view 0.3.1` returned success, meaning a GitHub Release object for `0.3.1` already existed before this run attempted to create one. + +However: +- No prior workflow run published a final `0.3.1` release (the last successful run [`23548412415`](https://github.com/vig-os/devcontainer/actions/runs/23548412415) was a **candidate** producing `0.3.1-rc23`) +- The GitHub Events API no longer retains a `CreateEvent` for the `0.3.1` tag (only the `DeleteEvent` at `2026-03-25T17:15:30Z` from rollback is visible) +- The org audit log is unavailable (free plan) + +**Likely culprit:** a stale GitHub Release for `0.3.1` existed from a previous manual action or partial run, which was then cleaned up by the rollback job -- removing the tag but not the release, causing the guard to trip. + +### Timeline (2026-03-25) + +| Time (UTC) | Event | +|---|---| +| 17:00:31 | Workflow run [`23553565067`](https://github.com/vig-os/devcontainer/actions/runs/23553565067) started (`release-kind: final`) | +| ~17:11 | `Create annotated tag` + `Push tag` steps succeeded (tag `0.3.1` pushed) | +| ~17:12-17:14 | Images pushed to GHCR, signed with cosign, provenance + SBOM attested | +| 17:14:47 | `Publish final GitHub Release` step runs `gh release view 0.3.1` → **succeeds** (release already exists) | +| 17:14:53 | Step exits with `ERROR: GitHub Release already exists for tag 0.3.1` | +| 17:15:30 | Rollback job deletes tag `0.3.1`, rolls back release branch | + +### Current state + +| Artifact | Status | +|---|---| +| Git tag `0.3.1` | Does not exist (deleted by rollback) | +| GitHub Release `0.3.1` | Does not exist (deleted by rollback or was transient) | +| GHCR image `ghcr.io/vig-os/devcontainer:0.3.1` | **Exists** (orphaned -- pushed before the failure, not cleaned up by rollback) | +| GHCR attestation index `sha256-8e925ffe...` | **Exists** (cosign signature + build provenance + SBOM, 3 artifacts) | +| GHCR `latest` tag | **Points to `0.3.1` images** (incorrect -- should point to `0.3.0`) | + +Digests: +| Tag | amd64 | arm64 | +|---|---|---| +| `0.3.1` / `latest` | `sha256:20541c4955...` | `sha256:30b626c290...` | +| `0.3.0` | `sha256:2ccefe1781...` | `sha256:5a0d2e760f...` | + +## Manual cleanup + +> **Prerequisites:** These commands require a token with `read:packages`, `write:packages`, and `delete:packages` scopes. +> +> ```bash +> gh auth refresh --scopes read:packages,write:packages,delete:packages +> ``` + +### Step 1 -- Retag `latest` to `0.3.0` + +This must happen **before** deleting `0.3.1`, since deleting the underlying manifest would leave `latest` dangling. + +```bash +docker buildx imagetools create \ + --tag ghcr.io/vig-os/devcontainer:latest \ + ghcr.io/vig-os/devcontainer:0.3.0 +``` + +### Step 2 -- Delete orphaned `0.3.1` artifacts from GHCR + +```bash +PKG="orgs/vig-os/packages/container/devcontainer/versions" + +# List all versions with their tags to identify IDs to delete +gh api "$PKG" --paginate \ + --jq '.[] | {id, tags: .metadata.container.tags, created: .created_at}' \ + | jq -s '.' \ + | jq '.[] | select( + (.tags | any(. == "0.3.1")) or + (.tags | any(startswith("sha256-"))) + ) | select( + (.tags | any(. == "latest" or startswith("0.3.0"))) | not + )' + +# For each version ID returned above, delete it: +# gh api --method DELETE "$PKG/" +``` + +### Step 3 -- Validate + +```bash +# 1. Confirm latest now matches 0.3.0 +echo "--- latest ---" +docker manifest inspect ghcr.io/vig-os/devcontainer:latest \ + | jq '[.manifests[] | {arch: .platform.architecture, digest: .digest}]' + +echo "--- 0.3.0 ---" +docker manifest inspect ghcr.io/vig-os/devcontainer:0.3.0 \ + | jq '[.manifests[] | {arch: .platform.architecture, digest: .digest}]' +# → digests must be identical + +# 2. Confirm 0.3.1 is gone +docker manifest inspect ghcr.io/vig-os/devcontainer:0.3.1 +# → expected: "manifest unknown" + +# 3. Confirm attestation index is gone +docker manifest inspect ghcr.io/vig-os/devcontainer:sha256-8e925ffefc85d5b705f72a788e2cc37233460bc8c0b9619aa8aa35c244f50d69 +# → expected: "manifest unknown" +``` + +## Mitigation + +The Validate job currently checks for an existing **git tag** ([`release.yml:321-328`](https://github.com/vig-os/devcontainer/blob/main/.github/workflows/release.yml#L321-L328)) but not for an existing **GitHub Release**. Adding a `gh release view` guard in the Validate step (for final releases) would catch this before the expensive build/sign/publish pipeline runs. + +Will open a separate issue to track this. + + diff --git a/docs/issues/issue-444.md b/docs/issues/issue-444.md new file mode 100644 index 00000000..f23c2758 --- /dev/null +++ b/docs/issues/issue-444.md @@ -0,0 +1,51 @@ +--- +type: issue +state: closed +created: 2026-03-25T17:21:28Z +updated: 2026-03-26T07:32:03Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/issues/444 +comments: 0 +labels: chore, priority:medium, area:ci, effort:small +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-26T17:53:31.912Z +--- + +# [Issue 444]: [[CHORE] Remove PR Title Check workflow from CI](https://github.com/vig-os/devcontainer/issues/444) + +## Chore Type +CI / Build change + +## Description +Remove the **PR Title Check** GitHub Actions workflow (`pr-title-check.yml`). It produces too many false positives and blocks otherwise valid PRs (e.g. release automation). + +Example failure: https://github.com/vig-os/devcontainer/actions/runs/23553640179 + +Rationale: PR title enforcement duplicates concerns already covered locally (e.g. commit-msg / pre-commit) and is brittle for bot-driven or conventionally titled release PRs. + +## Acceptance Criteria +- [ ] `PR Title Check` / `pr-title-check.yml` is removed or clearly disabled so it no longer appears as a required PR check. +- [ ] Any references that assume this check exists are updated (e.g. docs, tests that list workflow job names) if they would otherwise fail or mislead. +- [ ] `CHANGELOG.md` updated under **Removed** (user-visible CI change) if applicable per project rules. + +## Implementation Notes +- Primary file: `.github/workflows/pr-title-check.yml` (delete or replace with a no-op only if deletion is not desired — default preference: delete). +- Follow-up: confirm branch protection no longer lists "Validate PR Title" as required (repo settings; may be outside this repo's diff). + +## Related Issues +Related context: #276, #221 + +## Priority +Medium + +## Changelog Category +Removed + +## Additional Context +None + diff --git a/docs/issues/issue-446.md b/docs/issues/issue-446.md new file mode 100644 index 00000000..864515b7 --- /dev/null +++ b/docs/issues/issue-446.md @@ -0,0 +1,52 @@ +--- +type: issue +state: closed +created: 2026-03-25T18:36:48Z +updated: 2026-03-26T10:39:36Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/issues/446 +comments: 0 +labels: chore, area:ci +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-26T17:53:31.585Z +--- + +# [Issue 446]: [[CHORE] Enforce immutable git tags and GitHub releases; align rollback workflows](https://github.com/vig-os/devcontainer/issues/446) + +## Chore Type +CI / Build change + +## Description +Git tags and GitHub Releases should be treated as **immutable**: once published, they must not be rewritten or deleted by automation (or by default repo policy). Today, failed release runs can still **delete** a created tag during rollback (e.g. `release.yml`), which conflicts with immutability and with stricter repo settings. + +This issue covers: +1. **Repository configuration** — enable settings/rules so tags (and releases, where applicable) cannot be casually updated or removed (e.g. tag protection / rulesets, immutable release behavior as offered by GitHub for the org/repo). +2. **Workflow alignment** — update **rollback** jobs so they no longer rely on **deleting** tags (or mutating published releases). Compensation should be documented and implemented (e.g. branch-only rollback, failure issues with manual guidance, follow-up patch release), consistent with “publish” vs “post-publish” stages already called out in release docs. + +## Acceptance Criteria +- [ ] Repository (and org, if required) is configured so release tags and GitHub Releases match the project’s **immutability** policy (document **what** was enabled and **where** in runbooks or release docs, single source of truth). +- [ ] Rollback paths in CI (at minimum `.github/workflows/release.yml` and the mirrored workspace workflow under `assets/workspace/.github/workflows/release.yml` if still authoritative) **do not** attempt tag deletion when immutability is enforced; behavior is explicit in logs and in the rollback issue body. +- [ ] `docs/RELEASE_CYCLE.md`, `docs/DOWNSTREAM_RELEASE.md`, or other canonical release docs are updated if user-visible rollback behavior changes. +- [ ] No regression in failure visibility (failed releases still produce clear issues / logs). + +## Implementation Notes +- Current rollback deletes the remote tag via GitHub API, e.g. `gh api ... -X DELETE` on `git/refs/tags/$TAG` in `.github/workflows/release.yml` (and workspace copy). +- Confirm interaction with **protected tags**, **rulesets**, and **immutable releases** (GitHub product names/settings may vary by plan); adjust app/token permissions if rollback strategy changes. +- `prepare-release.yml` rollback appears focused on **branch** / changelog cleanup; verify no hidden tag mutation paths. + +## Related Issues +- Historical rollback issues note “Release tag deleted (if created)” — behavior will change once this is done. + +## Priority +Medium + +## Changelog Category +Changed + +## Additional Context +Aligns supply-chain / audit expectations: released refs should remain stable; failed pipelines should clean up **mutable** state without assuming tags can be removed. diff --git a/docs/issues/issue-449.md b/docs/issues/issue-449.md new file mode 100644 index 00000000..f28e3963 --- /dev/null +++ b/docs/issues/issue-449.md @@ -0,0 +1,59 @@ +--- +type: issue +state: closed +created: 2026-03-26T10:55:09Z +updated: 2026-03-26T12:06:51Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/issues/449 +comments: 0 +labels: chore, area:ci +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-26T17:53:31.228Z +--- + +# [Issue 449]: [[CHORE] Fix release PR body Markdown heading hierarchy](https://github.com/vig-os/devcontainer/issues/449) + +**Chore Type:** CI / Build change + +**Description** + +Release PR bodies (e.g. PR 342) use the wrong heading levels: the main title is `## Release X.Y.Z`, the changelog section from `CHANGELOG.md` starts with `## [X.Y.Z] - TBD`, and a redundant `### Release Content` wrapper sits between the intro and the changelog. That makes the changelog subsection effectively demoted under "Release Content" instead of reading as the top-level release notes under the PR title. + +**Desired structure** + +- **Prepare (draft PR):** `# Release X.Y.Z`, one-line intro, then the extracted changelog block unchanged so it still begins with `## [X.Y.Z] - TBD`. +- **Finalize (`release.yml` refresh):** Keep the existing main H1 as-is (`# [Release X.Y.Z](…) - `). Only the changelog slice should be updated (date/link in the `## [X.Y.Z]` line per current behavior). Remove the `### Release Content` wrapper there as well. + +**Acceptance criteria** + +- [ ] `prepare-release.yml` — draft PR body uses `# Release $VERSION`, no `### Release Content`, changelog content still starts at `## [$VERSION]`. +- [ ] `release.yml` — refreshed PR body matches the same hierarchy (H1 unchanged from current linked title + date; no `### Release Content`). +- [ ] Synced copy under `assets/workspace/.github/workflows/prepare-release.yml` updated if it mirrors this workflow. +- [ ] BATS or other tests updated if they assert the old strings. +- [ ] `docs/RELEASE_CYCLE.md` (or other canonical release docs) updated only if they describe the old PR body shape. + +**Implementation notes** + +- Current templates: `PR_BODY` in `.github/workflows/prepare-release.yml` (~298–304); `cat > /tmp/release-pr-body.md` in `.github/workflows/release.yml` (~729–737). + +**Related issues** + +- Related: #300, PR #319 / #342 (release PR body refresh behavior). + +**Priority** + +Low + +**Changelog category** + +No changelog needed + +**Additional context** + +GitHub PR bodies render Markdown; correct hierarchy improves readability and matches Keep a Changelog–style `## [version]` sections. + diff --git a/docs/issues/issue-451.md b/docs/issues/issue-451.md new file mode 100644 index 00000000..944ed974 --- /dev/null +++ b/docs/issues/issue-451.md @@ -0,0 +1,95 @@ +--- +type: issue +state: closed +created: 2026-03-26T12:54:19Z +updated: 2026-03-26T15:11:47Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/451 +comments: 1 +labels: bug +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-26T17:53:30.916Z +--- + +# [Issue 451]: [Smoke-test dispatch failed for 0.3.1-rc24](https://github.com/vig-os/devcontainer/issues/451) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc24` +- release_kind: `candidate` +- source_repo: `vig-os/devcontainer` +- source_workflow: `Release` +- source_run_id: `23594310918` +- source_run_url: https://github.com/vig-os/devcontainer/actions/runs/23594310918 +- source_sha: `6f790039c606880a23e4d7ed639afa5254329234` +- correlation_id: `vig-os/devcontainer:23594310918:0.3.1-rc24` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23595002617 +- deploy PR: https://github.com/vig-os/devcontainer-smoke-test/pull/104 +- release PR: https://github.com/vig-os/devcontainer-smoke-test/pull/105 + +## Job results +- validate: `success` +- deploy: `success` +- wait-deploy-merge: `success` +- cleanup-release: `success` +- trigger-prepare-release: `success` +- ready-release-pr: `success` +- trigger-release: `failure` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Do not rewrite or delete **published** GitHub Releases (or their linked tags when **immutable releases** are enabled) to retry the same version; bare git tags without a published release are not locked by that feature unless a tag ruleset applies. +- After fixing the root cause upstream, publish a **new** RC tag (or a new final attempt only after branch/tag state matches your release policy), then rely on a fresh dispatch. +--- + +# [Comment #1]() by [c-vigo]() + +_Posted on March 26, 2026 at 02:42 PM_ + +## RCA: Smoke-test dispatch failed for `0.3.1-rc24` + +### Summary + +The downstream **Release** workflow on `vig-os/devcontainer-smoke-test` failed during the **Publish Release** phase. The smoke-test orchestration job **Trigger and wait for release workflow** correctly reported `failure` because the parent run concluded with `conclusion: failure`. + +### Root cause + +The workspace orchestrator [`assets/workspace/.github/workflows/release.yml`](https://github.com/vig-os/devcontainer/blob/main/assets/workspace/.github/workflows/release.yml) passes `tag_already_exists` into the reusable workflow [`release-publish.yml`](https://github.com/vig-os/devcontainer/blob/main/assets/workspace/.github/workflows/release-publish.yml) as: + +```yaml +tag_already_exists: ${{ needs.core.outputs.tag_already_exists }} +``` + +`needs.core.outputs.tag_already_exists` is always a **string** (`"true"` or `"false"`) because job outputs from shell steps are strings. The called workflow declares that input as **`type: boolean`**. GitHub Actions rejects the `workflow_call` when the value is not a proper boolean, so the **Publish Release** job fails **before any sub-jobs appear** in the UI/API. + +The monolithic [`.github/workflows/release.yml`](https://github.com/vig-os/devcontainer/blob/main/.github/workflows/release.yml) in this repo avoids the problem by comparing the output as a string (e.g. `!= 'true'`) instead of passing it into a boolean-typed reusable input. + +### Failure chain + +1. Upstream publishes `0.3.1-rc24` and dispatches `repository_dispatch` to the smoke-test repo. +2. Deploy merges updated workspace workflows (including commit `1bb5f78` — idempotent tags / `tag_already_exists` wiring). +3. `trigger-release` runs `gh workflow run release.yml` on `dev` with `version=0.3.1`, `release-kind=candidate`, `rc-number=24`. +4. **Release Core** and **Release Extension** succeed; **Finalize** logs show `No remote tag 0.3.1-rc24 yet` and sets `tag_already_exists=false` (string). +5. **Publish Release** reusable call fails at input validation → overall run `failure` → **Rollback on Failure** runs (e.g. [devcontainer-smoke-test#106](https://github.com/vig-os/devcontainer-smoke-test/issues/106)). + +### Evidence + +| Item | Detail | +|------|--------| +| Orchestration run | [23595002617](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23595002617) — `trigger-release` ended with `ERROR: release workflow concluded with 'failure'` | +| Downstream Release run | [23595160097](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23595160097) — no `Publish Release / *` jobs; rollback ran | +| Last good RC on same repo | [23549773061](https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23549773061) (`0.3.1-rc23`) — **had** `Publish Release` sub-jobs | +| Workflow diff | `release.yml` gained `tag_already_exists: ${{ needs.core.outputs.tag_already_exists }}`; `release-publish.yml` gained `type: boolean` input | + + diff --git a/docs/issues/issue-453.md b/docs/issues/issue-453.md new file mode 100644 index 00000000..23dd29d8 --- /dev/null +++ b/docs/issues/issue-453.md @@ -0,0 +1,53 @@ +--- +type: issue +state: open +created: 2026-03-26T15:45:21Z +updated: 2026-03-26T17:35:29Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/453 +comments: 0 +labels: bug +assignees: c-vigo +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-26T17:53:30.480Z +--- + +# [Issue 453]: [Smoke-test dispatch failed for 0.3.1-rc25](https://github.com/vig-os/devcontainer/issues/453) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc25` +- release_kind: `candidate` +- source_repo: `vig-os/devcontainer` +- source_workflow: `Release` +- source_run_id: `23602722662` +- source_run_url: https://github.com/vig-os/devcontainer/actions/runs/23602722662 +- source_sha: `11bc8e9db642a8f1801118b537db062a3b15609b` +- correlation_id: `vig-os/devcontainer:23602722662:0.3.1-rc25` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23603456323 +- deploy PR: https://github.com/vig-os/devcontainer-smoke-test/pull/107 +- release PR: not created + +## Job results +- validate: `success` +- deploy: `success` +- wait-deploy-merge: `success` +- cleanup-release: `success` +- trigger-prepare-release: `failure` +- ready-release-pr: `skipped` +- trigger-release: `skipped` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Do not rewrite or delete **published** GitHub Releases (or their linked tags when **immutable releases** are enabled) to retry the same version; bare git tags without a published release are not locked by that feature unless a tag ruleset applies. +- After fixing the root cause upstream, publish a **new** RC tag (or a new final attempt only after branch/tag state matches your release policy), then rely on a fresh dispatch. diff --git a/docs/issues/issue-454.md b/docs/issues/issue-454.md new file mode 100644 index 00000000..57612d99 --- /dev/null +++ b/docs/issues/issue-454.md @@ -0,0 +1,53 @@ +--- +type: issue +state: open +created: 2026-03-26T17:39:01Z +updated: 2026-03-26T17:39:01Z +author: vig-os-release-app[bot] +author_url: https://github.com/vig-os-release-app[bot] +url: https://github.com/vig-os/devcontainer/issues/454 +comments: 0 +labels: bug +assignees: none +milestone: none +projects: none +parent: none +children: none +synced: 2026-03-26T17:53:30.146Z +--- + +# [Issue 454]: [Smoke-test dispatch failed for 0.3.1-rc25](https://github.com/vig-os/devcontainer/issues/454) + +Smoke-test dispatch failed while orchestrating downstream release validation. + +## Dispatch metadata +- tag: `0.3.1-rc25` +- release_kind: `candidate` +- source_repo: `vig-os/devcontainer` +- source_workflow: `Release` +- source_run_id: `23602722662` +- source_run_url: https://github.com/vig-os/devcontainer/actions/runs/23602722662 +- source_sha: `11bc8e9db642a8f1801118b537db062a3b15609b` +- correlation_id: `vig-os/devcontainer:23602722662:0.3.1-rc25` + +## Workflow context +- downstream workflow run: https://github.com/vig-os/devcontainer-smoke-test/actions/runs/23603456323 +- deploy PR: https://github.com/vig-os/devcontainer-smoke-test/pull/107 +- release PR: not created + +## Job results +- validate: `success` +- deploy: `success` +- wait-deploy-merge: `success` +- cleanup-release: `success` +- trigger-prepare-release: `failure` +- ready-release-pr: `skipped` +- trigger-release: `skipped` +- merge-release-pr: `skipped` +- summary: `failure` + +## Manual cleanup guidance +- Inspect deploy/release PRs and workflow logs before retrying. +- If needed, close stale release PRs and delete stale `release/` branch. +- Do not rewrite or delete **published** GitHub Releases (or their linked tags when **immutable releases** are enabled) to retry the same version; bare git tags without a published release are not locked by that feature unless a tag ruleset applies. +- After fixing the root cause upstream, publish a **new** RC tag (or a new final attempt only after branch/tag state matches your release policy), then rely on a fresh dispatch. diff --git a/docs/pull-requests/pr-342.md b/docs/pull-requests/pr-342.md index e9dbcb12..62e1ee89 100644 --- a/docs/pull-requests/pr-342.md +++ b/docs/pull-requests/pr-342.md @@ -3,7 +3,7 @@ type: pull_request state: open branch: release/0.3.1 → main created: 2026-03-17T14:13:31Z -updated: 2026-03-25T14:48:16Z +updated: 2026-03-26T15:11:35Z author: vig-os-release-app[bot] author_url: https://github.com/vig-os-release-app[bot] url: https://github.com/vig-os/devcontainer/pull/342 @@ -12,82 +12,208 @@ labels: none assignees: none milestone: none projects: none -synced: 2026-03-25T17:01:39.447Z +synced: 2026-03-26T17:53:34.387Z --- # [PR 342](https://github.com/vig-os/devcontainer/pull/342) chore: release 0.3.1 -## Release 0.3.1 - -This PR prepares release 0.3.1 for merge to main. - -### Release Content - -## [0.3.1] - TBD - -### Added - -- **Split downstream release workflow with project-owned extension hook** ([#326](https://github.com/vig-os/devcontainer/issues/326)) - - Add local `workflow_call` release phases (`release-core.yml`, `release-publish.yml`) and a lightweight `release.yml` orchestrator in `assets/workspace/.github/workflows/` - - Add `release_kind` support with candidate mode (`X.Y.Z-rcN`) and final mode (`X.Y.Z`) in downstream release workflows - - Candidate mode now auto-computes the next RC tag, skips CHANGELOG finalization/sync-issues, and publishes a GitHub pre-release - - Add project-owned `release-extension.yml` stub and preserve it during `init-workspace.sh --force` upgrades - - Add `validate-contract` composite action for single-source contract version validation - - Add downstream release contract documentation and GHCR extension example in `docs/DOWNSTREAM_RELEASE.md` - -### Changed - -- **Dependabot dependency update batch** ([#302](https://github.com/vig-os/devcontainer/pull/302), [#303](https://github.com/vig-os/devcontainer/pull/303), [#305](https://github.com/vig-os/devcontainer/pull/305), [#306](https://github.com/vig-os/devcontainer/pull/306), [#307](https://github.com/vig-os/devcontainer/pull/307), [#308](https://github.com/vig-os/devcontainer/pull/308), [#309](https://github.com/vig-os/devcontainer/pull/309)) - - Bump `@devcontainers/cli` from `0.81.1` to `0.84.0` and `bats-assert` from `v2.2.0` to `v2.2.4` - - Bump GitHub Actions: `actions/download-artifact` (`4.3.0` -> `8.0.1`), `actions/github-script` (`7.1.0` -> `8.0.0`), `actions/attest-build-provenance` (`3.0.0` -> `4.1.0`), `actions/checkout` (`4.3.1` -> `6.0.2`) - - Bump release workflow action pins: `sigstore/cosign-installer` (`4.0.0` -> `4.1.0`) and `anchore/sbom-action` (`0.22.2` -> `0.23.1`) -- **Dependabot dependency update batch** ([#314](https://github.com/vig-os/devcontainer/pull/314), [#315](https://github.com/vig-os/devcontainer/pull/315), [#316](https://github.com/vig-os/devcontainer/pull/316), [#317](https://github.com/vig-os/devcontainer/pull/317)) - - Bump GitHub Actions: `actions/attest-sbom` (`3.0.0` -> `4.0.0`), `actions/upload-artifact` (`4.6.2` -> `7.0.0`), `actions/create-github-app-token` (`2.2.1` -> `3.0.0`) - - Bump `docker/login-action` from `3.7.0` to `4.0.0` - - Bump `just` minor version from `1.46` to `1.47` -- **Node24-ready GitHub Actions pin refresh for shared composite actions** ([#321](https://github.com/vig-os/devcontainer/issues/321)) - - Update Docker build path pins in `build-image` (`docker/setup-buildx-action`, `docker/metadata-action`, `docker/build-push-action`) to Node24-compatible releases - - Set `setup-env` default Node runtime to `24` and upgrade `actions/setup-node` - - Align test composite actions with newer pins (`actions/checkout`, `actions/cache`, `actions/upload-artifact`) -- **Smoke-test dispatch payload now carries source run traceability metadata** ([#289](https://github.com/vig-os/devcontainer/issues/289)) - - Candidate release dispatches now include source repo/workflow/run/SHA metadata plus a deterministic `correlation_id` - - Smoke-test dispatch receiver logs normalized source context, derives source run URL when possible, and writes it to workflow summary output - - Release-cycle docs now define required vs optional dispatch payload keys and the future callback contract path for `publish-candidate` -- **Smoke-test repository dispatch now runs for final releases too** ([#173](https://github.com/vig-os/devcontainer/issues/173)) - - `release.yml` now triggers the existing smoke-test dispatch contract for both `candidate` and `final` release kinds - - Final release summaries and release-cycle documentation now reflect dispatch behavior for both release modes -- **Workspace CI templates now use a single container-based workflow** ([#327](https://github.com/vig-os/devcontainer/issues/327)) - - Consolidate `assets/workspace/.github/workflows/ci.yml` as the canonical CI workflow and remove the obsolete `ci-container.yml` template - - Extract reusable `assets/workspace/.github/actions/resolve-image` and run workspace release tests in the same containerized workflow model - - Update smoke-test and release-cycle documentation to reference the single CI workflow contract -- **Final release now requires downstream RC pre-release gate** ([#331](https://github.com/vig-os/devcontainer/issues/331)) - - Add upstream final-release validation that requires a downstream GitHub pre-release for the latest published RC tag - - Move smoke-test dispatch to a dedicated release job and include `release_kind` in the dispatch payload - - Add downstream `repository-dispatch.yml` template that runs smoke tests and creates pre-release/final release artifacts -- **Ship changelog into workspace payload and smoke-test deploy root** ([#333](https://github.com/vig-os/devcontainer/issues/333)) - - Sync canonical `CHANGELOG.md` into both workspace root and `.devcontainer/` template paths - - Smoke-test dispatch now copies `.devcontainer/CHANGELOG.md` to repository root so deploy output keeps a root changelog -- **Final release now publishes a GitHub Release with finalized notes** ([#310](https://github.com/vig-os/devcontainer/issues/310)) - - Add a final-only publish step in `.github/workflows/release.yml` that creates a GitHub Release for `X.Y.Z` - - Source GitHub Release notes from the finalized `CHANGELOG.md` section and fail the run if notes extraction or release publishing fails -- **Release dispatch and publish ordering hardened for 0.3.1** ([#336](https://github.com/vig-os/devcontainer/issues/336)) - - Make smoke-test dispatch fire-and-forget in `.github/workflows/release.yml` and decouple rollback from downstream completion timing - - Add bounded retries to the final-release downstream RC pre-release gate API check - - Move final GitHub Release creation to the end of publish so artifact publication/signing completes before release object creation - - Add concurrency control to `assets/smoke-test/.github/workflows/repository-dispatch.yml` to prevent overlapping dispatch races - - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released - -### Fixed - -- **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) - - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs - - Release PR body is refreshed from finalized `CHANGELOG.md` - -### Security - -- **Smoke-test dispatch workflow permissions now follow least privilege** ([#340](https://github.com/vig-os/devcontainer/issues/340)) - - Reduce `assets/smoke-test/.github/workflows/repository-dispatch.yml` workflow token permissions from write to read by default - - Grant `contents: write` only to `publish-release`, the single job that creates or edits GitHub Releases +## Release 0.3.1 + +This PR prepares release 0.3.1 for merge to main. + +### Release Content + +## [0.3.1] - TBD + +### Added + +- **Split downstream release workflow with project-owned extension hook** ([#326](https://github.com/vig-os/devcontainer/issues/326)) + - Add local `workflow_call` release phases (`release-core.yml`, `release-publish.yml`) and a lightweight `release.yml` orchestrator in `assets/workspace/.github/workflows/` + - Add `release_kind` support with candidate mode (`X.Y.Z-rcN`) and final mode (`X.Y.Z`) in downstream release workflows + - Candidate mode now auto-computes the next RC tag, skips CHANGELOG finalization/sync-issues, and publishes a GitHub pre-release + - Add project-owned `release-extension.yml` stub and preserve it during `init-workspace.sh --force` upgrades + - Add `validate-contract` composite action for single-source contract version validation + - Add downstream release contract documentation and GHCR extension example in `docs/DOWNSTREAM_RELEASE.md` +- **`jq` in devcontainer image** ([#425](https://github.com/vig-os/devcontainer/issues/425)) + - Install the `jq` CLI in the GHCR image so containerized workflows (e.g. `release-core` validate / downstream Release Core) can pipe JSON through `jq` + +### Changed + +- **Dependabot dependency update batch** ([#302](https://github.com/vig-os/devcontainer/pull/302), [#303](https://github.com/vig-os/devcontainer/pull/303), [#305](https://github.com/vig-os/devcontainer/pull/305), [#306](https://github.com/vig-os/devcontainer/pull/306), [#307](https://github.com/vig-os/devcontainer/pull/307), [#308](https://github.com/vig-os/devcontainer/pull/308), [#309](https://github.com/vig-os/devcontainer/pull/309)) + - Bump `@devcontainers/cli` from `0.81.1` to `0.84.0` and `bats-assert` from `v2.2.0` to `v2.2.4` + - Bump GitHub Actions: `actions/download-artifact` (`4.3.0` -> `8.0.1`), `actions/github-script` (`7.1.0` -> `8.0.0`), `actions/attest-build-provenance` (`3.0.0` -> `4.1.0`), `actions/checkout` (`4.3.1` -> `6.0.2`) + - Bump release workflow action pins: `sigstore/cosign-installer` (`4.0.0` -> `4.1.0`) and `anchore/sbom-action` (`0.22.2` -> `0.23.1`) +- **Dependabot dependency update batch** ([#314](https://github.com/vig-os/devcontainer/pull/314), [#315](https://github.com/vig-os/devcontainer/pull/315), [#316](https://github.com/vig-os/devcontainer/pull/316), [#317](https://github.com/vig-os/devcontainer/pull/317)) + - Bump GitHub Actions: `actions/attest-sbom` (`3.0.0` -> `4.0.0`), `actions/upload-artifact` (`4.6.2` -> `7.0.0`), `actions/create-github-app-token` (`2.2.1` -> `3.0.0`) + - Bump `docker/login-action` from `3.7.0` to `4.0.0` + - Bump `just` minor version from `1.46` to `1.47` +- **Node24-ready GitHub Actions pin refresh for shared composite actions** ([#321](https://github.com/vig-os/devcontainer/issues/321)) + - Update Docker build path pins in `build-image` (`docker/setup-buildx-action`, `docker/metadata-action`, `docker/build-push-action`) to Node24-compatible releases + - Set `setup-env` default Node runtime to `24` and upgrade `actions/setup-node` + - Align test composite actions with newer pins (`actions/checkout`, `actions/cache`, `actions/upload-artifact`) +- **Smoke-test dispatch payload now carries source run traceability metadata** ([#289](https://github.com/vig-os/devcontainer/issues/289)) + - Candidate release dispatches now include source repo/workflow/run/SHA metadata plus a deterministic `correlation_id` + - Smoke-test dispatch receiver logs normalized source context, derives source run URL when possible, and writes it to workflow summary output + - Release-cycle docs now define required vs optional dispatch payload keys and the future callback contract path for `publish-candidate` +- **Smoke-test repository dispatch now runs for final releases too** ([#173](https://github.com/vig-os/devcontainer/issues/173)) + - `release.yml` now triggers the existing smoke-test dispatch contract for both `candidate` and `final` release kinds + - Final release summaries and release-cycle documentation now reflect dispatch behavior for both release modes +- **Workspace CI templates now use a single container-based workflow** ([#327](https://github.com/vig-os/devcontainer/issues/327)) + - Consolidate `assets/workspace/.github/workflows/ci.yml` as the canonical CI workflow and remove the obsolete `ci-container.yml` template + - Extract reusable `assets/workspace/.github/actions/resolve-image` and run workspace release tests in the same containerized workflow model + - Update smoke-test and release-cycle documentation to reference the single CI workflow contract +- **Final release now requires downstream RC pre-release gate** ([#331](https://github.com/vig-os/devcontainer/issues/331)) + - Add upstream final-release validation that requires a downstream GitHub pre-release for the latest published RC tag + - Move smoke-test dispatch to a dedicated release job and include `release_kind` in the dispatch payload + - Add downstream `repository-dispatch.yml` template that runs smoke tests and creates pre-release/final release artifacts +- **Ship changelog into workspace payload and smoke-test deploy root** ([#333](https://github.com/vig-os/devcontainer/issues/333)) + - Sync canonical `CHANGELOG.md` into both workspace root and `.devcontainer/` template paths + - Smoke-test dispatch now copies `.devcontainer/CHANGELOG.md` to repository root so deploy output keeps a root changelog +- **Final release now publishes a GitHub Release with finalized notes** ([#310](https://github.com/vig-os/devcontainer/issues/310)) + - Add a final-only publish step in `.github/workflows/release.yml` that creates a GitHub Release for `X.Y.Z` + - Source GitHub Release notes from the finalized `CHANGELOG.md` section and fail the run if notes extraction or release publishing fails +- **Release dispatch and publish ordering hardened for 0.3.1** ([#336](https://github.com/vig-os/devcontainer/issues/336)) + - Make smoke-test dispatch fire-and-forget in `.github/workflows/release.yml` and decouple rollback from downstream completion timing + - Add bounded retries to the final-release downstream RC pre-release gate API check + - Move final GitHub Release creation to the end of publish so artifact publication/signing completes before release object creation + - Add concurrency control to `assets/smoke-test/.github/workflows/repository-dispatch.yml` to prevent overlapping dispatch races + - Handle smoke-test dispatch failures with a targeted issue while avoiding destructive rollback after publish artifacts are already released +- **Redesigned smoke-test dispatch release orchestration** ([#358](https://github.com/vig-os/devcontainer/issues/358)) + - Replace premature `publish-release` behavior with full downstream orchestration: deploy-to-dev merge gate, `prepare-release.yml`, release PR readiness/approval, and `release.yml` dispatch polling + - Add upstream failure issue reporting with job-phase results and cleanup guidance when dispatch orchestration fails +- **Smoke-test release orchestration now runs as two phases** ([#402](https://github.com/vig-os/devcontainer/issues/402)) + - Keep `repository-dispatch.yml` focused on deploy/prepare/release-PR readiness and move release dispatch to a dedicated merged-PR workflow (`on-release-pr-merge.yml`) + - Add release-kind labeling and auto-merge enablement for release PRs, and keep upstream failure notifications in both phases + - Remove release-branch upstream `CHANGELOG.md` sync from `repository-dispatch.yml` (previously added in [#358](https://github.com/vig-os/devcontainer/issues/358)) +- **Dependabot dependency update batch** ([#414](https://github.com/vig-os/devcontainer/pull/414)) + - Bump `github/codeql-action` from `4.32.6` to `4.34.1` and `anchore/sbom-action` from `0.23.1` to `0.24.0` + - Bump `actions/cache` restore/save pins from `5.0.3` to `5.0.4` in `sync-issues.yml` +- **Dependabot dependency update batch** ([#413](https://github.com/vig-os/devcontainer/pull/413)) + - Bump `@devcontainers/cli` from `0.84.0` to `0.84.1` +- **cursor-agent install is now resilient to CDN failures** ([#434](https://github.com/vig-os/devcontainer/issues/434)) + - Retries 3 times with backoff before giving up + - Build succeeds without cursor-agent when Cursor's CDN is unavailable + +### Fixed + +- **Smoke-test deploy restores workspace CHANGELOG for prepare-release** ([#417](https://github.com/vig-os/devcontainer/issues/417)) + - Add `prepare-changelog unprepare` to rename the top `## [semver] - …` heading to `## Unreleased` + - `init-workspace.sh --smoke-test` copies `.devcontainer/CHANGELOG.md` into workspace `CHANGELOG.md` and runs unprepare; remove duplicate remap from smoke-test dispatch workflow +- **Release app permission docs now include downstream workflow dispatch requirements** ([#397](https://github.com/vig-os/devcontainer/issues/397)) + - Update `docs/RELEASE_CYCLE.md` to require `Actions` read/write for `RELEASE_APP` on the validation repository + - Clarify this is required so downstream `repository-dispatch.yml` can trigger release orchestration workflows via `workflow_dispatch` +- **Smoke-test dispatch no longer fails on release PR self-approval** ([#402](https://github.com/vig-os/devcontainer/issues/402)) + - Remove bot self-approval from `repository-dispatch.yml` and replace with release-kind labeling plus auto-merge enablement + - Remove in-job polling for release PR merge and downstream release execution from phase 1 orchestration + - Phase 2 (`on-release-pr-merge.yml`) fails validation unless the merged release PR has `release-kind:final` or `release-kind:candidate` +- **Sync-main-to-dev PRs now trigger CI reliably in downstream repos** ([#398](https://github.com/vig-os/devcontainer/issues/398)) + - Replace API-based sync branch creation with `git push` in `assets/workspace/.github/workflows/sync-main-to-dev.yml` +- **Sync-main-to-dev no longer dispatches CI via workflow_dispatch** ([#405](https://github.com/vig-os/devcontainer/issues/405)) + - `workflow_dispatch` runs are omitted from the PR status check rollup, so they do not satisfy branch protection on the sync PR + - Remove the post-PR `gh workflow run ci.yml` step and drop `actions: write` from the sync job in `.github/workflows/sync-main-to-dev.yml` and `assets/workspace/.github/workflows/sync-main-to-dev.yml` +- **Sync-main-to-dev conflict detection uses merge-tree** ([#410](https://github.com/vig-os/devcontainer/issues/410)) + - Replace working-tree trial merge with `git merge-tree --write-tree` so clean merges are not mislabeled as conflicts + - Enable auto-merge when dev merges cleanly with main; print merge-tree output on conflicts; fail the step on unexpected errors +- **Smoke-test release phase 2 branch-not-found failure** ([#419](https://github.com/vig-os/devcontainer/issues/419)) + - Merge phase 2 (`on-release-pr-merge.yml`) back into `repository-dispatch.yml` so the release runs while `release/` still exists, matching the normal release flow + - Remove `on-release-pr-merge.yml` from the smoke-test template +- **Pinned commit-action to v0.2.0** ([#354](https://github.com/vig-os/devcontainer/issues/354)) + - Updated workflow pins from `vig-os/commit-action@c0024cb` (v0.1.5) to `1bc004353d08d9332a0cb54920b148256220c8e0` (v0.2.0) in release, sync-issues, prepare-release, and smoke-test workflows + - Upstream v0.2.0 adds bounded retry with exponential backoff for transient GitHub API failures (configurable `MAX_ATTEMPTS` and delay bounds) + - Efficient multi-file commits via `createTree` inline content for text files, binary blobs only when needed, and chunked tree creation for large change sets + - Exports `isBinaryFile`, `getFileMode`, and `TREE_ENTRY_CHUNK_SIZE` for library use; sequential binary blob creation to reduce secondary rate-limit bursts + +- **Release finalization now commits generated docs and refreshes PR content** ([#300](https://github.com/vig-os/devcontainer/issues/300)) + - Final release automation regenerates docs before committing so pre-commit `generate-docs` does not fail CI with tracked file diffs + - Release PR body is refreshed from finalized `CHANGELOG.md` +- **Release attestation warnings reduced by granting artifact metadata permission** ([#348](https://github.com/vig-os/devcontainer/issues/348)) + - Add `artifact-metadata: write` to the release publish job so attestation steps can persist metadata storage records + - Keep `actions/attest`-based SBOM attestation path and remove missing-permission warnings from publish runs +- **Smoke-test dispatch deploy now repairs workspace ownership before changelog copy** ([#352](https://github.com/vig-os/devcontainer/issues/352)) + - Add a write probe and conditional `sudo chown -R` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` after installer execution + - Prevent `Permission denied` failures when copying `.devcontainer/CHANGELOG.md` to repository root in GitHub-hosted runner jobs +- **Smoke-test release lookup no longer treats missing tags as existing releases** ([#355](https://github.com/vig-os/devcontainer/issues/355)) + - Change `assets/smoke-test/.github/workflows/repository-dispatch.yml` to branch on `gh api` exit status when querying `releases/tags/` + - Ensure missing release tags follow the create path instead of failing with `prerelease=null` mismatch +- **Bounded retry added for network-dependent setup and prepare-release calls** ([#357](https://github.com/vig-os/devcontainer/issues/357)) + - Replace shell-based retry helper with pure Python `retry` CLI in `vig-utils` (`packages/vig-utils/src/vig_utils/retry.py`) + - Update this repository CI workflows to call `uv run retry` after `setup-env` dependency sync + - Update downstream workflow templates to call `retry` directly in devcontainer jobs and remove `source` lines + - Ensure downstream containerized jobs resolve image tags from `.vig-os` instead of hardcoded `latest` + - Bundle idempotency guards for branch/PR/tag/release creation paths to keep retried network calls safe on reruns + - Remove synced `retry.sh` artifacts and BATS retry tests in favor of `vig-utils` pytest coverage +- **Release workflow no longer fails when retry tooling is unavailable** ([#365](https://github.com/vig-os/devcontainer/issues/365)) + - Extend `.github/actions/setup-env/action.yml` with a reusable `retry` shell function exported via `BASH_ENV` as the retry single source of truth + - Add `setup-env` input support for uv-only usage by allowing Python setup to be disabled when jobs only need retry tooling + - Switch release workflow retry calls from `uv run retry` to shared `retry` and remove duplicated inline retry implementations +- **Upstream sync workflows no longer depend on pre-published GHCR image tags** ([#367](https://github.com/vig-os/devcontainer/issues/367)) + - Remove upstream `.vig-os` files at repository root and `assets/smoke-test/` to eliminate downstream-only configuration from upstream CI + - Refactor `.github/workflows/sync-issues.yml` and `.github/workflows/sync-main-to-dev.yml` to run natively on runners via `./.github/actions/setup-env` instead of `resolve-image` + `container` +- **Release test-image setup now recovers from uv sync crashes** ([#370](https://github.com/vig-os/devcontainer/issues/370)) + - Harden `.github/actions/setup-env/action.yml` to retry `uv sync --frozen --all-extras` once after clearing uv cache and removing stale `.venv` + - Prevent repeat release test failures when `setup-env` is executed multiple times in the same job +- **Release setup-env no longer self-sources retry helper via BASH_ENV** ([#374](https://github.com/vig-os/devcontainer/issues/374)) + - Guard the retry-helper merge logic in `.github/actions/setup-env/action.yml` to skip merging when `PREV_BASH_ENV` already equals `RETRY_HELPER` + - Prevent infinite `source` recursion and exit 139 crashes when `setup-env` is invoked multiple times in one job +- **Smoke-test dispatch now checks out repository before local setup action** ([#376](https://github.com/vig-os/devcontainer/issues/376)) + - Add `actions/checkout` to the `smoke-test` job in `.github/workflows/release.yml` before invoking `./.github/actions/setup-env` + - Prevent dispatch failures caused by missing local action metadata (`action.yml`) in a fresh job workspace +- **Workspace resolve-image jobs now checkout local action metadata** ([#380](https://github.com/vig-os/devcontainer/issues/380)) + - Update `sparse-checkout` in workspace `resolve-image` jobs to include `.github/actions/resolve-image` in addition to `.vig-os` + - Prevent CI failures in downstream deploy PRs where local composite actions were missing from sparse checkout +- **Smoke-test dispatch gh jobs now set explicit repo context** ([#386](https://github.com/vig-os/devcontainer/issues/386)) + - Add job-level `GH_REPO: ${{ github.repository }}` to `cleanup-release`, `trigger-prepare-release`, `ready-release-pr`, and `trigger-release` in `assets/smoke-test/.github/workflows/repository-dispatch.yml` + - Prevent `gh` CLI failures (`fatal: not a git repository`) in runner jobs that do not perform `actions/checkout` +- **Smoke-test release orchestration now validates workflow contract before dispatch** ([#389](https://github.com/vig-os/devcontainer/issues/389)) + - Add a preflight check that verifies `prepare-release.yml` and `release.yml` are resolvable on dispatch ref `dev` before downstream orchestration starts + - Dispatch and polling now use explicit ref/branch context (`--ref dev` / `--branch dev`) to avoid default-branch workflow registry drift and `404 workflow not found` failures +- **Smoke-test preflight now uses gh CLI ref-compatible workflow validation** ([#392](https://github.com/vig-os/devcontainer/issues/392)) + - Update `assets/smoke-test/.github/workflows/repository-dispatch.yml` preflight checks to call `gh workflow view` with `--yaml` when `--ref` is set + - Prevent false preflight failures caused by newer GitHub CLI argument validation before `prepare-release` dispatch +- **Downstream release workflow templates hardened for smoke-test orchestration** ([#394](https://github.com/vig-os/devcontainer/issues/394)) + - Add missing `git config --global --add safe.directory "$GITHUB_WORKSPACE"` in containerized release and sync jobs that run git after checkout + - Decouple `release.yml` rollback container startup from `needs.core.outputs.image_tag` by resolving the image in a dedicated `resolve-image` job + - Add explicit release caller/reusable workflow permissions for `actions` and `pull-requests` operations, and update dispatch header comments to reference only current CI workflows +- **Workspace containerized workflows now pin bash for run steps** ([#395](https://github.com/vig-os/devcontainer/issues/395)) + - Set `defaults.run.shell: bash` in containerized workspace release and prepare jobs so `set -euo pipefail` scripts do not execute under POSIX `sh` + - Prevent downstream smoke-test failures caused by `set: Illegal option -o pipefail` in container jobs +- **Downstream release templates now require explicit app tokens for write paths** ([#400](https://github.com/vig-os/devcontainer/issues/400)) + - Update `assets/workspace/.github/workflows/prepare-release.yml`, `release-core.yml`, `release-publish.yml`, `release.yml`, and `sync-issues.yml` to remove `github.token` fallback from protected write operations + - Route protected branch/ref writes through Commit App tokens and release orchestration/issue operations through Release App tokens + - Document downstream token requirements in `docs/DOWNSTREAM_RELEASE.md` and `docs/CROSS_REPO_RELEASE_GATE.md` + - Use `github.token` specifically for Actions cache deletion in `sync-issues.yml` because that API path requires explicit `actions: write` job token scope + - Use Commit App credentials for rollback checkout in `release.yml` so rollback branch/tag writes can still bypass protected refs +- **setup-env retries uv install on transient GitHub Releases download failures** ([#407](https://github.com/vig-os/devcontainer/issues/407)) + - Add `continue-on-error` plus a delayed second attempt for `astral-sh/setup-uv` in `.github/actions/setup-env/action.yml` + - Reduce flaky release publish failures when GitHub CDN returns transient HTTP errors for uv release assets +- **Smoke-test deploy keeps workspace scaffold as root CHANGELOG** ([#403](https://github.com/vig-os/devcontainer/issues/403)) + - Stop overwriting `CHANGELOG.md` with a minimal stub in `assets/smoke-test/.github/workflows/repository-dispatch.yml` + - Require the workspace `CHANGELOG.md` from `init-workspace` so downstream `prepare-release` validation matches shipped layout + - When the first changelog section is `## [X.Y.Z] - …` (TBD or a release date), remap that top version header to `## Unreleased` so downstream `prepare-release` can run +- **Smoke-test dispatch release validate no longer runs docker inside devcontainer** ([#421](https://github.com/vig-os/devcontainer/issues/421)) + - Remove redundant `docker manifest inspect` step from `release-core.yml` validate job (container image is already proof of accessibility; `resolve-image` validates on the runner) + - Set `GH_REPO` for rollback `gh issue create` in workspace `release.yml` when git checkout is skipped +- **Container image tests expect current uv minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) + - Update `tests/test_image.py` `EXPECTED_VERSIONS["uv"]` to match uv 0.11.x from the latest release install path in the image build +- **Container image tests expect current just minor line** ([#423](https://github.com/vig-os/devcontainer/issues/423)) + - Update `tests/test_image.py` `EXPECTED_VERSIONS["just"]` to match just 1.48.x from the latest release install path in the image build +- **Smoke-test dispatch approves release PR before downstream release** ([#430](https://github.com/vig-os/devcontainer/issues/430)) + - Grant `pull-requests: write` on `ready-release-pr` and approve with `github.token` (`git*ub-actions[bot]`) + - Satisfy `release-core.yml` approval gate without the release app self-approving its own PR +- **commit-action retries enabled for transient git ref API failures** ([#436](https://github.com/vig-os/devcontainer/issues/436)) + - Set `MAX_ATTEMPTS: "3"` on every `vig-os/commit-action` step so v0.2.0 bounded retry actually runs (default was 1) + - Covers smoke-test deploy, prepare-release, release finalization, sync-issues, and workspace templates +- **Release validation fails when bot approves PR** ([#438](https://github.com/vig-os/devcontainer/issues/438)) + - Add fallback to individual PR review check when `reviewDecision` is empty (bot approvals not counted by branch protection) +- **Downstream candidate RC tag can match upstream dispatch** ([#441](https://github.com/vig-os/devcontainer/issues/441)) + - Workspace `release.yml` / `release-core.yml` accept optional `rc-number` so candidate tags are not always recomputed from local tags only + - Smoke-test `repository-dispatch.yml` exposes `base_version` and `rc_number` job outputs for orchestration that calls workspace `release.yml` + +### Security + +- **Smoke-test dispatch workflow permissions now follow least privilege** ([#340](https://github.com/vig-os/devcontainer/issues/340)) + - Reduce `assets/smoke-test/.github/workflows/repository-dispatch.yml` workflow token permissions from write to read by default + - Grant `contents: write` only to `publish-release`, the single job that creates or edits GitHub Releases diff --git a/docs/pull-requests/pr-447.md b/docs/pull-requests/pr-447.md new file mode 100644 index 00000000..8b5fd02f --- /dev/null +++ b/docs/pull-requests/pr-447.md @@ -0,0 +1,144 @@ +--- +type: pull_request +state: closed (merged) +branch: chore/444-remove-pr-title-check-ci → release/0.3.1 +created: 2026-03-25T19:20:02Z +updated: 2026-03-26T07:31:50Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/447 +comments: 2 +labels: none +assignees: c-vigo +milestone: none +projects: none +merged: 2026-03-26T07:31:48Z +synced: 2026-03-26T17:53:39.024Z +--- + +# [PR 447](https://github.com/vig-os/devcontainer/pull/447) chore(ci): remove PR Title Check and validate-commit subject-only mode + +## Description + +Removes the **PR Title Check** GitHub Actions workflow that produced false positives (e.g. on release PRs) and drops the **`--subject-only`** mode from `validate-commit-msg`, which existed only for that CI path. Full commit messages remain validated by pre-commit and `validate-commit-msg` as before. + +## Type of Change + +- [ ] `feat` -- New feature +- [ ] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [x] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [x] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- **`.github/workflows/pr-title-check.yml`** — deleted (workflow removed from CI). +- **`.github/agent-blocklist.toml`**, **`assets/workspace/.github/agent-blocklist.toml`** — header comment no longer references removed CI. +- **`CHANGELOG.md`**, **`assets/workspace/.devcontainer/CHANGELOG.md`** — `### Removed` under `## [0.3.1] - TBD` documents the workflow removal and `--subject-only` removal. +- **`packages/vig-utils/src/vig_utils/validate_commit_msg.py`** — remove `subject_only` parameter and `--subject-only` CLI flag. +- **`packages/vig-utils/tests/test_validate_commit_msg.py`** — remove `TestSubjectOnly` and related coverage; rename chore minimal test for clarity. +- **`packages/vig-utils/tests/test_gh_issues.py`** — dedup CI test uses generic check name instead of `Validate PR Title`. +- **`packages/vig-utils/README.md`** — drop `--subject-only` from command reference. + +## Changelog Entry + +Target section is **`## [0.3.1] - TBD`** (release branch; not `## Unreleased`). + +```markdown +### Removed + +- **PR Title Check GitHub Actions workflow** ([#444](https://github.com/vig-os/devcontainer/issues/444)) + - Remove `.github/workflows/pr-title-check.yml`; commit message rules remain enforced via local hooks and `validate-commit-msg` + - Remove `--subject-only` from `validate-commit-msg` (it existed only for PR title CI) +``` + +## Testing + +- [ ] Tests pass locally (`just test`) +- [x] Manual testing performed (describe below) + +### Manual Testing Details + +Ran targeted pytest (not full `just test`): + +- `uv run pytest packages/vig-utils/tests/test_validate_commit_msg.py` — 110 passed +- `uv run pytest packages/vig-utils/tests/test_gh_issues.py` — 70 passed + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [ ] I have updated `CHANGELOG.md` in the `[Unreleased]` section (and pasted the entry above) +- [x] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [x] New and existing unit tests pass locally with my changes +- [x] Any dependent changes have been merged and published + +## Additional Notes + +- Changelog updates are under **`## [0.3.1] - TBD` → `### Removed`**, not `## Unreleased`, per release-branch rules. +- **`packages/vig-utils/README.md`** updated for the CLI; no `docs/templates/` change. +- After merge, remove **Validate PR Title** from required status checks in branch protection if it is still listed (repo settings). + +Refs: #444 + + + +--- +--- + +## Review Threads (1) + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 25, 2026 at 07:30 PM_ + +_File: [`packages/vig-utils/tests/test_validate_commit_msg.py`](https://github.com/vig-os/devcontainer/pull/447#discussion_r2990552732)_ + +```diff +@@ -212,7 +212,7 @@ def test_non_chore_types_still_require_refs(self): + assert valid is False, f"Type {ctype} should require Refs but passed" + assert "Refs" in err + +- def test_chore_subject_only_with_blank_line(self): ++ def test_chore_minimal_with_blank_line_only(self): + """Minimal chore commit: subject + blank line only.""" +``` + +`test_chore_minimal_with_blank_line_only` / its docstring claim this is validating “subject + blank line only”, but the validator currently `rstrip()`s the content, so the trailing blank line is removed before `splitlines()`. As written, this test passes because chores are accepted as subject-only, not because a blank separator is preserved. Consider renaming the test to reflect subject-only acceptance, or updating the validator to preserve/enforce the blank-line separator if that’s the intended rule. +```suggestion + def test_chore_minimal_subject_only(self): + """Minimal chore commit: subject line only (trailing blank lines are ignored).""" +``` + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 25, 2026 at 07:38 PM — [link](https://github.com/vig-os/devcontainer/pull/447#discussion_r2990596287) + + Thanks — agreed. Renamed the test to `test_chore_minimal_subject_only` and updated the docstring to match that we `rstrip()` before `splitlines()`, so trailing newlines do not create a second line. Also expanded the inline comment in `validate_commit_message()` to document that behavior (no validation semantics change). Landed in 4a9b2fb. + + +--- +--- + +## Commits + +### Commit 1: [8784c10](https://github.com/vig-os/devcontainer/commit/8784c10ebe0e420e0d30570e6789af77ee63616c) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 07:13 PM +chore(ci): remove PR Title Check workflow, 66 files modified + +### Commit 2: [3b23b36](https://github.com/vig-os/devcontainer/commit/3b23b36b5606ee0e705235bbc172de54dae75a6c) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 07:16 PM +chore(vigutils): remove validate-commit-msg --subject-only, 123 files modified (CHANGELOG.md, assets/workspace/.devcontainer/CHANGELOG.md, packages/vig-utils/README.md, packages/vig-utils/src/vig_utils/validate_commit_msg.py, packages/vig-utils/tests/test_validate_commit_msg.py) + +### Commit 3: [4a9b2fb](https://github.com/vig-os/devcontainer/commit/4a9b2fbfecac07300184d6d6c858a6d999e5feb8) by [c-vigo](https://github.com/c-vigo) on March 25, 2026 at 07:32 PM +test(vigutils): align minimal chore test with rstrip parsing, 9 files modified (packages/vig-utils/src/vig_utils/validate_commit_msg.py, packages/vig-utils/tests/test_validate_commit_msg.py) diff --git a/docs/pull-requests/pr-448.md b/docs/pull-requests/pr-448.md new file mode 100644 index 00000000..f1dd0cbf --- /dev/null +++ b/docs/pull-requests/pr-448.md @@ -0,0 +1,272 @@ +--- +type: pull_request +state: closed (merged) +branch: feature/446-immutable-tags-releases-rollback → release/0.3.1 +created: 2026-03-26T10:10:04Z +updated: 2026-03-26T10:38:06Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/448 +comments: 8 +labels: none +assignees: c-vigo +milestone: none +projects: none +merged: 2026-03-26T10:38:05Z +synced: 2026-03-26T17:53:37.850Z +--- + +# [PR 448](https://github.com/vig-os/devcontainer/pull/448) ci: Align release automation with draft finals and forward-fix rollback + +## Description + +Aligns release automation with GitHub **immutable releases** and **draft-first** practice for finals: `release.yml` creates a **draft** GitHub Release for human review, allows retry when a draft or matching remote tag already exists, skips redundant tag push when the tag peels to the finalize SHA, and stops deleting remote tags on rollback (forward-fix policy). Workspace downstream workflows, smoke-test dispatch guidance, and release docs are updated to match. + +## Type of Change + +- [ ] `feat` -- New feature +- [ ] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [ ] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [x] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- **`.github/workflows/release.yml`** — Draft final GitHub Release; validate/publish tolerate existing draft vs published; `tag_state` / skip tag create-push when remote tag matches finalize SHA; rollback removes tag deletion step; summaries and failure issues document forward-fix and draft handling. +- **`assets/workspace/.github/workflows/release-core.yml`** — Move tag-vs-SHA check to finalize job; expose `tag_already_exists`; drop blunt “tag must not exist” validate gate. +- **`assets/workspace/.github/workflows/release-publish.yml`** — Input `tag_already_exists`; conditional tag step; draft final release create; draft/prerelease retry semantics for candidates. +- **`assets/workspace/.github/workflows/release.yml`** — Pass `tag_already_exists`; rollback no longer deletes tags; issue body updated. +- **`assets/smoke-test/.github/workflows/repository-dispatch.yml`** — Failure copy: immutable/published release guidance and fresh RC dispatch. +- **`docs/RELEASE_CYCLE.md`**, **`docs/DOWNSTREAM_RELEASE.md`**, **`docs/CROSS_REPO_RELEASE_GATE.md`** — Immutable releases vs tag rulesets, operator steps, downstream scope, recovery. +- **`CHANGELOG.md`** and **`assets/workspace/.devcontainer/CHANGELOG.md`** — `## [0.3.1] - TBD` entry for #446 (template mirror). + +**Commits (vs `release/0.3.1`):** `1bb5f78` — feat(ci): draft final releases, idempotent tags, and forward-fix rollback + +## Changelog Entry + +On this branch the active section is `## [0.3.1] - TBD` (not `## Unreleased`). Added under **### Changed**: + +```markdown +- **Immutable GitHub releases, tag rulesets, and forward-fix policy** ([#446](https://github.com/vig-os/devcontainer/issues/446)) + - Final releases create a **draft** GitHub Release for human review before publishing; rollback no longer deletes remote tags + - Release workflows skip redundant tag push when the tag already matches the finalized commit; workspace `release-core` / `release-publish` and smoke-test failure guidance updated accordingly + - Document tag rulesets, immutable releases, and recovery in `docs/RELEASE_CYCLE.md`, `docs/DOWNSTREAM_RELEASE.md`, and `docs/CROSS_REPO_RELEASE_GATE.md` +``` + +## Testing + +- [ ] Tests pass locally (`just test`) +- [ ] Manual testing performed (describe below) + +### Manual Testing Details + +N/A — workflow and documentation changes; validate in Actions on merge / release dry-run as appropriate. + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [x] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [x] I have updated `CHANGELOG.md` under `## [0.3.1] - TBD` (and pasted the entry above) +- [x] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Additional Notes + +Target base is **`release/0.3.1`** (release train), not `dev`. Operators must **publish** the draft GitHub Release from the UI when ready for finals; see `docs/RELEASE_CYCLE.md`. + +Refs: #446 + + + +--- +--- + +## Review Threads (4) + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 26, 2026 at 10:15 AM_ + +_File: [`assets/workspace/.github/workflows/release-publish.yml (line 145 RIGHT)`](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993774634)_ + +```diff +@@ -134,6 +139,7 @@ jobs: + git config user.email "$GIT_USER_EMAIL" + + - name: Create and push tag ++ if: ${{ !inputs.tag_already_exists }} + env: + PUBLISH_VERSION: ${{ inputs.publish_version }} + run: | +``` + +With the new `tag_already_exists` skip path, it becomes more important that the workflow can reliably compare remote vs local tag targets when a push conflicts. Today the workflow’s SHA comparison later relies on the peeled ref `refs/tags/^{}`; that can be empty for lightweight tags even when the tag exists. Consider adding a fallback to `refs/tags/` when the peeled ref is missing so retries don’t fail with “target SHA could not be resolved.” + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 26, 2026 at 10:31 AM — [link](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993862892) + + Good catch. The workflow always creates annotated tags so this is unlikely in practice, but adding the lightweight fallback is cheap and avoids a confusing `target SHA could not be resolved` error if someone ever creates a lightweight tag manually. Fixed. + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 26, 2026 at 10:15 AM_ + +_File: [`.github/workflows/release.yml`](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993774667)_ + +```diff +@@ -755,6 +757,31 @@ jobs: + echo "finalize_sha=$FINALIZE_SHA" >> $GITHUB_OUTPUT + echo "Release kind: $RELEASE_KIND — SHA: $FINALIZE_SHA" + ++ - name: Check if publish tag already exists at finalize SHA ++ id: tag_state ++ env: ++ PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} ++ FINALIZE_SHA: ${{ steps.finalize.outputs.finalize_sha }} ++ run: | ++ set -euo pipefail ++ REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}" || true) ++ if [ -z "$REMOTE_LINE" ]; then ++ echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" ++ echo "No remote tag ${PUBLISH_VERSION} yet" ++ exit 0 ++ fi ++ REMOTE_PEEL=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') ++ if [ -z "$REMOTE_PEEL" ]; then ++ echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" ++ exit 0 ++ fi ++ if [ "$REMOTE_PEEL" != "$FINALIZE_SHA" ]; then ++ echo "ERROR: Tag $PUBLISH_VERSION exists but peeled commit is $REMOTE_PEEL; expected $FINALIZE_SHA (finalize SHA)" +``` + +The tag peel check uses `git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}"`. This returns an entry for annotated tags, but can return empty for an existing *lightweight* tag, which would make the workflow think the tag doesn’t exist and proceed (later steps may then fail with confusing SHA-resolution errors). Consider resolving the remote target SHA by first trying the peeled ref and falling back to `refs/tags/${PUBLISH_VERSION}` when the peeled ref is absent, and use the same fallback anywhere the workflow compares remote tag SHAs. +```suggestion + # Try to resolve the tag's target commit SHA. First attempt the peeled ref (annotated tags), + # and if that is empty, fall back to the plain tag ref (lightweight tags). + REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}" || true) + if [ -z "$REMOTE_LINE" ]; then + REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}" || true) + fi + if [ -z "$REMOTE_LINE" ]; then + echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" + echo "No remote tag ${PUBLISH_VERSION} yet" + exit 0 + fi + REMOTE_TARGET_SHA=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') + if [ -z "$REMOTE_TARGET_SHA" ]; then + echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$REMOTE_TARGET_SHA" != "$FINALIZE_SHA" ]; then + echo "ERROR: Tag $PUBLISH_VERSION exists but target commit is $REMOTE_TARGET_SHA; expected $FINALIZE_SHA (finalize SHA)" +``` + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 26, 2026 at 10:31 AM — [link](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993862948) + + Agreed. Added a fallback to the plain tag ref for both the `tag_state` check and the push-conflict SHA resolution. Also applied to the workspace template (`release-core.yml`, `release-publish.yml`). + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 26, 2026 at 10:15 AM_ + +_File: [`assets/workspace/.github/workflows/release-core.yml (line 511 RIGHT)`](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993774699)_ + +```diff +@@ -492,6 +485,31 @@ jobs: + fi + echo "finalize_sha=$FINALIZE_SHA" >> "$GITHUB_OUTPUT" + ++ - name: Check if publish tag already exists at finalize SHA ++ id: tag_state ++ env: ++ PUBLISH_VERSION: ${{ needs.validate.outputs.publish_version }} ++ FINALIZE_SHA: ${{ steps.finalize.outputs.finalize_sha }} ++ run: | ++ set -euo pipefail ++ REMOTE_LINE=$(git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}" || true) ++ if [ -z "$REMOTE_LINE" ]; then ++ echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" ++ echo "No remote tag ${PUBLISH_VERSION} yet" ++ exit 0 ++ fi ++ REMOTE_PEEL=$(printf '%s\n' "$REMOTE_LINE" | awk '{print $1}') ++ if [ -z "$REMOTE_PEEL" ]; then ++ echo "tag_already_exists=false" >> "$GITHUB_OUTPUT" ++ exit 0 ++ fi ++ if [ "$REMOTE_PEEL" != "$FINALIZE_SHA" ]; then ++ echo "ERROR: Tag $PUBLISH_VERSION exists but peeled commit is $REMOTE_PEEL; expected $FINALIZE_SHA (finalize SHA)" ++ exit 1 +``` + +`git ls-remote origin "refs/tags/${PUBLISH_VERSION}^{}"` only reliably returns output for annotated tags. If a lightweight tag with the same name already exists (e.g., created manually), this step can incorrectly treat the tag as absent and allow a later push attempt that fails. Consider checking both the peeled ref and (if empty) `refs/tags/${PUBLISH_VERSION}` so existing lightweight tags are detected and validated against `FINALIZE_SHA` consistently. + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 26, 2026 at 10:31 AM — [link](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993862994) + + Fixed alongside the parent workflow; same lightweight-tag fallback applied. + +--- + +### Review by [@Copilot](https://github.com/apps/copilot-pull-request-reviewer) + +_Posted on March 26, 2026 at 10:15 AM_ + +_File: [`assets/workspace/.github/workflows/release-publish.yml`](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993774729)_ + +```diff +@@ -181,8 +187,21 @@ jobs: + GH_TOKEN: ${{ steps.auth.outputs.token }} + run: | + set -euo pipefail +- if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" >/dev/null 2>&1; then +- echo "ERROR: GitHub Release already exists for tag $PUBLISH_VERSION" ++ if retry --retries 2 --backoff 5 --max-backoff 20 -- gh release view "$PUBLISH_VERSION" --json isDraft,isPrerelease >/dev/null 2>&1; then ++ RELEASE_JSON=$(gh release view "$PUBLISH_VERSION" --json isDraft,isPrerelease) ++ IS_DRAFT=$(printf '%s' "$RELEASE_JSON" | jq -r '.isDraft') ++ if [ "$IS_DRAFT" = "true" ]; then ++ echo "Draft GitHub Release already exists for $PUBLISH_VERSION; skipping create (retry path)." ++ exit 0 +``` + +In the release existence check, `gh release view` is executed twice: once under `retry` (discarding output) and then again without `retry` to capture JSON. This can fail on transient API/CLI issues even though the first call succeeded, and it adds an extra request. Consider doing a single retried call that captures the JSON (or capturing the output from the first call) and parsing that. + +Conversation: + +- **[@c-vigo](https://github.com/c-vigo)** on March 26, 2026 at 10:31 AM — [link](https://github.com/vig-os/devcontainer/pull/448#discussion_r2993863018) + + Good catch -- the second call lacked retry and could fail transiently. Consolidated into a single retried call that captures the JSON. + + +--- +--- + +## Commits + +### Commit 1: [1bb5f78](https://github.com/vig-os/devcontainer/commit/1bb5f783e6c0f50f7132f0745fd422bc85cc88c6) by [c-vigo](https://github.com/c-vigo) on March 26, 2026 at 10:04 AM +feat(ci): draft final releases, idempotent tags, and forward-fix rollback, 350 files modified + +### Commit 2: [495e767](https://github.com/vig-os/devcontainer/commit/495e767930b79981291d844214b2327c0c11ddbe) by [c-vigo](https://github.com/c-vigo) on March 26, 2026 at 10:31 AM +fix(ci): add lightweight-tag fallback and deduplicate release view call, 37 files modified (.github/workflows/release.yml, CHANGELOG.md, assets/workspace/.devcontainer/CHANGELOG.md, assets/workspace/.github/workflows/release-core.yml, assets/workspace/.github/workflows/release-publish.yml) diff --git a/docs/pull-requests/pr-450.md b/docs/pull-requests/pr-450.md new file mode 100644 index 00000000..7df6c197 --- /dev/null +++ b/docs/pull-requests/pr-450.md @@ -0,0 +1,89 @@ +--- +type: pull_request +state: closed (merged) +branch: bugfix/449-release-pr-body-markdown-hierarchy → release/0.3.1 +created: 2026-03-26T11:58:12Z +updated: 2026-03-26T12:06:22Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/450 +comments: 0 +labels: none +assignees: c-vigo +milestone: none +projects: none +merged: 2026-03-26T12:06:20Z +synced: 2026-03-26T17:53:36.672Z +--- + +# [PR 450](https://github.com/vig-os/devcontainer/pull/450) fix(ci): use H1 release title and drop Release Content in PR bodies + +## Description + +Release PR bodies from `prepare-release` and the finalize step in `release.yml` used `## Release` plus a redundant `### Release Content` wrapper, so the Keep a Changelog `## [version]` block was nested under an extra heading. This PR uses `# Release $VERSION` for draft PRs, keeps the existing linked H1 on finalize, and removes `### Release Content` everywhere. Downstream consumer template under `assets/workspace/` is kept in sync. + +## Type of Change + +- [ ] `feat` -- New feature +- [ ] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [ ] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [x] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- `.github/workflows/prepare-release.yml` — Draft PR `PR_BODY`: H1 `# Release $VERSION`, drop `### Release Content`, changelog slice unchanged. +- `.github/workflows/release.yml` — Finalize refresh heredoc: drop `### Release Content`; keep `# [Release …](tag) - date` and intro. +- `assets/workspace/.github/workflows/prepare-release.yml` — Mirror draft PR body formatting for downstream installs. + +## Changelog Entry + +No changelog needed — internal automation / PR body Markdown only; issue #449 tracked as no user-facing CHANGELOG entry. + +## Testing + +- [ ] Tests pass locally (`just test`) +- [x] Manual testing performed (describe below) + +### Manual Testing Details + +- `rg` over workflow paths: no `### Release Content`, no `PR_BODY="## Release`; both `prepare-release.yml` files contain `PR_BODY="# Release`. +- Pre-commit passed on commit `fix(ci): use H1 release title and drop Release Content in PR bodies`. + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [ ] I have updated `CHANGELOG.md` in the `[Unreleased]` section (and pasted the entry above) +- [x] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [x] Any dependent changes have been merged and published + +## Additional Notes + +Targets **release/0.3.1** so the fix can ride the current release line. See issue #449 for acceptance criteria. + +Refs: #449 + + + + +--- +--- + +## Commits + +### Commit 1: [5bd0444](https://github.com/vig-os/devcontainer/commit/5bd0444449dabe7e1687e0bbd79648ffd17be785) by [c-vigo](https://github.com/c-vigo) on March 26, 2026 at 11:55 AM +fix(ci): use H1 release title and drop Release Content in PR bodies, 10 files modified (.github/workflows/prepare-release.yml, .github/workflows/release.yml, assets/workspace/.github/workflows/prepare-release.yml) diff --git a/docs/pull-requests/pr-452.md b/docs/pull-requests/pr-452.md new file mode 100644 index 00000000..6b43efe0 --- /dev/null +++ b/docs/pull-requests/pr-452.md @@ -0,0 +1,95 @@ +--- +type: pull_request +state: closed (merged) +branch: bugfix/451-smoke-test-dispatch-failed → release/0.3.1 +created: 2026-03-26T14:46:02Z +updated: 2026-03-26T15:11:30Z +author: c-vigo +author_url: https://github.com/c-vigo +url: https://github.com/vig-os/devcontainer/pull/452 +comments: 0 +labels: none +assignees: c-vigo +milestone: none +projects: none +merged: 2026-03-26T15:11:29Z +synced: 2026-03-26T17:53:35.561Z +--- + +# [PR 452](https://github.com/vig-os/devcontainer/pull/452) fix: coerce workspace release tag_already_exists for workflow_call + +## Description + +Smoke-test orchestration failed for `0.3.1-rc24` because the workspace `release.yml` passed `needs.core.outputs.tag_already_exists` (a string from job outputs) into `release-publish.yml`, which declares that input as `type: boolean`. GitHub Actions rejects the reusable workflow call, so the Publish job never spawns sub-jobs and the downstream Release run fails. This PR coerces the value with `== 'true'` so the call receives a real boolean. + +## Type of Change + +- [ ] `feat` -- New feature +- [x] `fix` -- Bug fix +- [ ] `docs` -- Documentation only +- [ ] `chore` -- Maintenance task (deps, config, etc.) +- [ ] `refactor` -- Code restructuring (no behavior change) +- [ ] `test` -- Adding or updating tests +- [ ] `ci` -- CI/CD pipeline changes +- [ ] `build` -- Build system or dependency changes +- [ ] `revert` -- Reverts a previous commit +- [ ] `style` -- Code style (formatting, whitespace) + +### Modifiers + +- [ ] Breaking change (`!`) -- This change breaks backward compatibility + +## Changes Made + +- `assets/workspace/.github/workflows/release.yml` — pass `tag_already_exists: ${{ needs.core.outputs.tag_already_exists == 'true' }}` into `release-publish.yml`. +- `CHANGELOG.md` — document the fix under `## [0.3.1] - TBD` → **Fixed**. +- `assets/workspace/.devcontainer/CHANGELOG.md` — same entry (workspace SSoT mirror). + +## Changelog Entry + +Pasted from `CHANGELOG.md` on this branch (under `## [0.3.1] - TBD` → **Fixed**; this release branch uses the TBD section instead of `## Unreleased`): + +```markdown +- **Workspace release publish `tag_already_exists` input coercion** ([#451](https://github.com/vig-os/devcontainer/issues/451)) + - Pass a boolean into `release-publish.yml` via `needs.core.outputs.tag_already_exists == 'true'` so `workflow_call` does not reject string `"true"`/`"false"` job outputs +``` + +## Testing + +- [ ] Tests pass locally (`just test`) +- [ ] Manual testing performed (describe below) + +### Manual Testing Details + +N/A — workflow expression change only; behavior verified via RCA against failed run logs. Follow-up validation is a new smoke-test dispatch after template reaches the validation repo. + +## Checklist + +- [x] My code follows the project's style guidelines +- [x] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation accordingly (edit `docs/templates/`, then run `just docs`) +- [ ] I have updated `CHANGELOG.md` in the `[Unreleased]` section (and pasted the entry above) +- [x] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published + +## Additional Notes + +RCA draft for posting on the issue: `docs/issues/issue-451-github-comment.md` (optional). + +Refs: #451 + + + +--- +--- + +## Commits + +### Commit 1: [622568e](https://github.com/vig-os/devcontainer/commit/622568e19f1ec316c8ccf9e6a02f97fb713bc9e8) by [c-vigo](https://github.com/c-vigo) on March 26, 2026 at 02:43 PM +fix(ci): coerce tag_already_exists for workspace release publish, 6 files modified (CHANGELOG.md, assets/workspace/.devcontainer/CHANGELOG.md, assets/workspace/.github/workflows/release.yml) + +### Commit 2: [be51ce5](https://github.com/vig-os/devcontainer/commit/be51ce5bbc7af79278373ba95cd84db283e385dc) by [c-vigo](https://github.com/c-vigo) on March 26, 2026 at 03:03 PM +chore(image): expect GitHub CLI 2.89 in image version checks, 6 files modified (CHANGELOG.md, assets/workspace/.devcontainer/CHANGELOG.md, tests/test_image.py) From fb5b5780073e47055012b05d2fb54f04988b28ca Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 11:19:05 +0100 Subject: [PATCH 190/215] fix(remote): add ~/.local/bin to PATH in bootstrap build heredoc SSH non-login shells don't source .bashrc/.profile, so tools installed to ~/.local/bin (like uv) are not found during bootstrap. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 3 +++ scripts/devc-remote.sh | 3 +++ 2 files changed, 6 insertions(+) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 36022031..918c159b 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -952,6 +952,9 @@ DEVC_PATH="$2" IMAGE_TAG="$3" REGISTRY="$4" +# Ensure ~/.local/bin is in PATH (uv, etc.) +export PATH="$HOME/.local/bin:$PATH" + # Expand ~ in DEVC_PATH DEVC_PATH="${DEVC_PATH/#\~/$HOME}" diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 36022031..918c159b 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -952,6 +952,9 @@ DEVC_PATH="$2" IMAGE_TAG="$3" REGISTRY="$4" +# Ensure ~/.local/bin is in PATH (uv, etc.) +export PATH="$HOME/.local/bin:$PATH" + # Expand ~ in DEVC_PATH DEVC_PATH="${DEVC_PATH/#\~/$HOME}" From 8540109a196353132d64fc0f12fd4fb8b456acd3 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 15:10:13 +0100 Subject: [PATCH 191/215] fix(remote): sanitize underscores in Tailscale hostname and preserve project files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-tailscale.sh: replace underscores with hyphens in auto-generated hostname — DNS labels cannot contain underscores. init-workspace.sh: add pyproject.toml, uv.lock, .python-version to PRESERVE_FILES so --force upgrades don't overwrite project config. Refs: #70 --- assets/init-workspace.sh | 3 +++ assets/workspace/.devcontainer/scripts/setup-tailscale.sh | 2 ++ 2 files changed, 5 insertions(+) diff --git a/assets/init-workspace.sh b/assets/init-workspace.sh index 82792d1e..175d3b40 100755 --- a/assets/init-workspace.sh +++ b/assets/init-workspace.sh @@ -31,6 +31,9 @@ PRESERVE_FILES=( ".github/CODEOWNERS" ".github/workflows/release-extension.yml" "justfile.project" + "pyproject.toml" + "uv.lock" + ".python-version" ) # Get script directory for manifest location diff --git a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh index 2dc51dbb..1290eba9 100755 --- a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh +++ b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh @@ -37,6 +37,8 @@ resolve_hostname() { fi fi + # Sanitize: DNS labels cannot contain underscores + project="${project//_/-}" echo "${project}-devc-$(hostname -s)" } From 9dfecb1e9c0b51c2d8e48cb950dbf3a3bd1c61ca Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 15:55:58 +0100 Subject: [PATCH 192/215] feat(remote): resolve Claude OAuth token from macOS keychain devc-remote.sh now reads CLAUDE_CODE_OAUTH_TOKEN from macOS keychain (service: devc-remote) when not set as env var. First step toward full secret resolution chain (#464). Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 5 ++++- scripts/devc-remote.sh | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 918c159b..949cd79a 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -373,7 +373,10 @@ INJECT_EOF # ═══════════════════════════════════════════════════════════════════════════════ inject_claude_auth() { - # Skip if no OAuth token in local environment + # Resolve token: env var → macOS keychain → skip + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + CLAUDE_CODE_OAUTH_TOKEN=$(security find-generic-password -s devc-remote -a CLAUDE_CODE_OAUTH_TOKEN -w 2>/dev/null || true) + fi if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then return 0 fi diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 918c159b..949cd79a 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -373,7 +373,10 @@ INJECT_EOF # ═══════════════════════════════════════════════════════════════════════════════ inject_claude_auth() { - # Skip if no OAuth token in local environment + # Resolve token: env var → macOS keychain → skip + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + CLAUDE_CODE_OAUTH_TOKEN=$(security find-generic-password -s devc-remote -a CLAUDE_CODE_OAUTH_TOKEN -w 2>/dev/null || true) + fi if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then return 0 fi From 89311d17d54e167226fb3c03bf886327d4327764 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 15:58:25 +0100 Subject: [PATCH 193/215] feat(remote): resolve all secrets from macOS keychain at deploy time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Tailscale OAuth credentials (TS_CLIENT_ID, TS_CLIENT_SECRET) to the same keychain-fallback pattern as Claude OAuth token. All three secrets now resolve at deploy time: env var → macOS keychain → skip. No secrets need to be in shell profile env vars anymore. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 8 +++++++- scripts/devc-remote.sh | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 949cd79a..d8c7631d 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -268,7 +268,13 @@ detect_editor_cli() { # ═══════════════════════════════════════════════════════════════════════════════ inject_tailscale_key() { - # Skip if no OAuth credentials + # Resolve credentials: env var → macOS keychain → skip + if [[ -z "${TS_CLIENT_ID:-}" ]]; then + TS_CLIENT_ID=$(security find-generic-password -a tailscale-oauth -s TS_CLIENT_ID -w 2>/dev/null || true) + fi + if [[ -z "${TS_CLIENT_SECRET:-}" ]]; then + TS_CLIENT_SECRET=$(security find-generic-password -a tailscale-oauth -s TS_CLIENT_SECRET -w 2>/dev/null || true) + fi if [[ -z "${TS_CLIENT_ID:-}" || -z "${TS_CLIENT_SECRET:-}" ]]; then return 0 fi diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 949cd79a..d8c7631d 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -268,7 +268,13 @@ detect_editor_cli() { # ═══════════════════════════════════════════════════════════════════════════════ inject_tailscale_key() { - # Skip if no OAuth credentials + # Resolve credentials: env var → macOS keychain → skip + if [[ -z "${TS_CLIENT_ID:-}" ]]; then + TS_CLIENT_ID=$(security find-generic-password -a tailscale-oauth -s TS_CLIENT_ID -w 2>/dev/null || true) + fi + if [[ -z "${TS_CLIENT_SECRET:-}" ]]; then + TS_CLIENT_SECRET=$(security find-generic-password -a tailscale-oauth -s TS_CLIENT_SECRET -w 2>/dev/null || true) + fi if [[ -z "${TS_CLIENT_ID:-}" || -z "${TS_CLIENT_SECRET:-}" ]]; then return 0 fi From 3d0b2b302b39ba0d867484c87e03c66819b87ec9 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 16:27:00 +0100 Subject: [PATCH 194/215] fix(remote): use sanitize_dns_label for Tailscale hostname pattern match Extract sanitize_dns_label() helper and use it in wait_for_tailscale() so the pattern matches the sanitized hostname from setup-tailscale.sh. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 7 ++++++- scripts/devc-remote.sh | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index d8c7631d..13ed7f54 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -70,6 +70,11 @@ log_info() { echo -e "${BLUE}ℹ${NC} $1" } +# Sanitize a string for use as a DNS label (Tailscale hostnames, etc.) +sanitize_dns_label() { + echo "${1//_/-}" +} + log_success() { echo -e "${GREEN}✓${NC} $1" } @@ -791,7 +796,7 @@ wait_for_tailscale() { devc_name=$(ssh "$SSH_HOST" \ "python3 -c \"import json,sys; print(json.load(sys.stdin).get('name',''))\" \ < ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" || true) - devc_name="${devc_name:-devc}" + devc_name=$(sanitize_dns_label "${devc_name:-devc}") log_info "Tailscale: waiting for container to join tailnet (pattern: *${devc_name}*)..." diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index d8c7631d..13ed7f54 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -70,6 +70,11 @@ log_info() { echo -e "${BLUE}ℹ${NC} $1" } +# Sanitize a string for use as a DNS label (Tailscale hostnames, etc.) +sanitize_dns_label() { + echo "${1//_/-}" +} + log_success() { echo -e "${GREEN}✓${NC} $1" } @@ -791,7 +796,7 @@ wait_for_tailscale() { devc_name=$(ssh "$SSH_HOST" \ "python3 -c \"import json,sys; print(json.load(sys.stdin).get('name',''))\" \ < ${REMOTE_PATH}/.devcontainer/devcontainer.json 2>/dev/null" || true) - devc_name="${devc_name:-devc}" + devc_name=$(sanitize_dns_label "${devc_name:-devc}") log_info "Tailscale: waiting for container to join tailnet (pattern: *${devc_name}*)..." From b34f53037923dab371d4a02a01d2734571dccaa6 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 16:31:11 +0100 Subject: [PATCH 195/215] fix(remote): always run compose up to detect config changes Previously skipped compose up when container was already running, causing injected secrets to never reach the container. Now always runs compose up -d which is idempotent and auto-recreates only when config (env vars, devices, etc.) has changed. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 21 +++++++++++---------- scripts/devc-remote.sh | 21 +++++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 13ed7f54..b0032220 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -675,18 +675,19 @@ remote_compose_up() { # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + log_info "Starting devcontainer on $SSH_HOST..." + # Always run compose up -d: it's idempotent and auto-recreates if config changed. + # shellcheck disable=SC2029 + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then + log_error "Failed to start devcontainer on $SSH_HOST." + log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" + exit 1 + fi + sleep 2 + if [[ "$state" == "running" ]]; then - log_success "Devcontainer already running on $SSH_HOST" - CONTAINER_FRESH=0 + CONTAINER_FRESH=0 # was already running, lifecycle scripts already ran else - log_info "Starting devcontainer on $SSH_HOST..." - # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then - log_error "Failed to start devcontainer on $SSH_HOST." - log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" - exit 1 - fi - sleep 2 CONTAINER_FRESH=1 fi } diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 13ed7f54..b0032220 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -675,18 +675,19 @@ remote_compose_up() { # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + log_info "Starting devcontainer on $SSH_HOST..." + # Always run compose up -d: it's idempotent and auto-recreates if config changed. + # shellcheck disable=SC2029 + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then + log_error "Failed to start devcontainer on $SSH_HOST." + log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" + exit 1 + fi + sleep 2 + if [[ "$state" == "running" ]]; then - log_success "Devcontainer already running on $SSH_HOST" - CONTAINER_FRESH=0 + CONTAINER_FRESH=0 # was already running, lifecycle scripts already ran else - log_info "Starting devcontainer on $SSH_HOST..." - # shellcheck disable=SC2029 - if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full up -d"; then - log_error "Failed to start devcontainer on $SSH_HOST." - log_error "Debug with: ssh $SSH_HOST 'cd $devc_dir && $compose_full logs'" - exit 1 - fi - sleep 2 CONTAINER_FRESH=1 fi } From 18ef4e6230f5c2f53f95f2ab0436cf6852e4d2de Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 16:44:25 +0100 Subject: [PATCH 196/215] fix(lifecycle): make dependency sync non-fatal in post-create/post-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uv sync failure (corrupt lockfile, version mismatch) was killing the entire lifecycle script before Tailscale and Claude Code could install. Sync is now non-fatal — a warning is printed but setup continues. Refs: #70 --- assets/workspace/.devcontainer/scripts/post-create.sh | 4 +++- assets/workspace/.devcontainer/scripts/post-start.sh | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/assets/workspace/.devcontainer/scripts/post-create.sh b/assets/workspace/.devcontainer/scripts/post-create.sh index 67ad71e6..5d7fc530 100644 --- a/assets/workspace/.devcontainer/scripts/post-create.sh +++ b/assets/workspace/.devcontainer/scripts/post-create.sh @@ -33,7 +33,9 @@ sed -i 's/template-project/{{SHORT_NAME}}/g' /root/assets/workspace/.venv/bin/ac # Sync dependencies (fast if nothing changed from pre-built venv) echo "Syncing dependencies..." -just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" sync +just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" sync || { + echo "WARNING: dependency sync failed (non-fatal, continuing setup)" +} # Tailscale SSH (opt-in: no-op when TAILSCALE_AUTHKEY is unset) "$SCRIPT_DIR/setup-tailscale.sh" install diff --git a/assets/workspace/.devcontainer/scripts/post-start.sh b/assets/workspace/.devcontainer/scripts/post-start.sh index 4e668ef2..736e2982 100755 --- a/assets/workspace/.devcontainer/scripts/post-start.sh +++ b/assets/workspace/.devcontainer/scripts/post-start.sh @@ -19,7 +19,9 @@ sudo chmod 666 /var/run/docker.sock 2>/dev/null || true # Sync dependencies (fast no-op if nothing changed) echo "Syncing dependencies..." -just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" sync +just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" sync || { + echo "WARNING: dependency sync failed (non-fatal, continuing setup)" +} # Tailscale SSH (opt-in: no-op when TAILSCALE_AUTHKEY is unset) "$SCRIPT_DIR/setup-tailscale.sh" start From 20f61dfe5eb9b326613c75331a11407a24039be2 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 16:50:31 +0100 Subject: [PATCH 197/215] fix(remote): detect container recreate by comparing container IDs compose up -d silently recreates containers when config changes, but CONTAINER_FRESH stayed 0 because it only checked the pre-up state. Now compares container ID before and after compose up to detect recreates and run post-create lifecycle accordingly. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 22 +++++++++++++++++++--- scripts/devc-remote.sh | 22 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index b0032220..47b65e0b 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -675,6 +675,13 @@ remote_compose_up() { # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + # Capture container ID before compose up (to detect recreate) + local id_before="" + if [[ "$state" == "running" ]]; then + # shellcheck disable=SC2029 + id_before=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + fi + log_info "Starting devcontainer on $SSH_HOST..." # Always run compose up -d: it's idempotent and auto-recreates if config changed. # shellcheck disable=SC2029 @@ -685,10 +692,19 @@ remote_compose_up() { fi sleep 2 - if [[ "$state" == "running" ]]; then - CONTAINER_FRESH=0 # was already running, lifecycle scripts already ran + if [[ "$state" != "running" ]]; then + CONTAINER_FRESH=1 # was not running → fresh else - CONTAINER_FRESH=1 + # Was running — check if compose recreated it (different container ID) + local id_after + # shellcheck disable=SC2029 + id_after=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + if [[ "$id_before" != "$id_after" ]]; then + log_info "Container was recreated (config changed)" + CONTAINER_FRESH=1 + else + CONTAINER_FRESH=0 + fi fi } diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index b0032220..47b65e0b 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -675,6 +675,13 @@ remote_compose_up() { # shellcheck disable=SC2034 health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) + # Capture container ID before compose up (to detect recreate) + local id_before="" + if [[ "$state" == "running" ]]; then + # shellcheck disable=SC2029 + id_before=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + fi + log_info "Starting devcontainer on $SSH_HOST..." # Always run compose up -d: it's idempotent and auto-recreates if config changed. # shellcheck disable=SC2029 @@ -685,10 +692,19 @@ remote_compose_up() { fi sleep 2 - if [[ "$state" == "running" ]]; then - CONTAINER_FRESH=0 # was already running, lifecycle scripts already ran + if [[ "$state" != "running" ]]; then + CONTAINER_FRESH=1 # was not running → fresh else - CONTAINER_FRESH=1 + # Was running — check if compose recreated it (different container ID) + local id_after + # shellcheck disable=SC2029 + id_after=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + if [[ "$id_before" != "$id_after" ]]; then + log_info "Container was recreated (config changed)" + CONTAINER_FRESH=1 + else + CONTAINER_FRESH=0 + fi fi } From 345f2fee8404ccb9b1e8bef227fa22aa407b5d36 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 16:57:14 +0100 Subject: [PATCH 198/215] fix(remote): filter compose provider banner from container ID capture podman compose prefixes output with >>>> banner lines which polluted the container ID comparison, making recreated containers look identical. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 5 +++-- scripts/devc-remote.sh | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 47b65e0b..60553200 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -676,10 +676,11 @@ remote_compose_up() { health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) # Capture container ID before compose up (to detect recreate) + # Filter out compose provider banner lines (>>>>) local id_before="" if [[ "$state" == "running" ]]; then # shellcheck disable=SC2029 - id_before=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + id_before=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" | grep -v '^>' | head -1 || true) fi log_info "Starting devcontainer on $SSH_HOST..." @@ -698,7 +699,7 @@ remote_compose_up() { # Was running — check if compose recreated it (different container ID) local id_after # shellcheck disable=SC2029 - id_after=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + id_after=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" | grep -v '^>' | head -1 || true) if [[ "$id_before" != "$id_after" ]]; then log_info "Container was recreated (config changed)" CONTAINER_FRESH=1 diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 47b65e0b..60553200 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -676,10 +676,11 @@ remote_compose_up() { health=$(echo "$ps_output" | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true) # Capture container ID before compose up (to detect recreate) + # Filter out compose provider banner lines (>>>>) local id_before="" if [[ "$state" == "running" ]]; then # shellcheck disable=SC2029 - id_before=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + id_before=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" | grep -v '^>' | head -1 || true) fi log_info "Starting devcontainer on $SSH_HOST..." @@ -698,7 +699,7 @@ remote_compose_up() { # Was running — check if compose recreated it (different container ID) local id_after # shellcheck disable=SC2029 - id_after=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" || true) + id_after=$(ssh "$SSH_HOST" "cd $devc_dir && $compose_full ps -q 2>/dev/null" | grep -v '^>' | head -1 || true) if [[ "$id_before" != "$id_after" ]]; then log_info "Container was recreated (config changed)" CONTAINER_FRESH=1 From f3759acc29f6404a9361053acb06bcc8d39ceb00 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 17:11:12 +0100 Subject: [PATCH 199/215] fix(remote): handle apt clock skew in Tailscale install Containers often have clock skew causing apt-get update to fail with "Release file not valid yet". Add fallback manual install with Acquire::Check-Valid-Until=false. Also remove debug log line. Refs: #70 --- .../.devcontainer/scripts/setup-tailscale.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh index 1290eba9..a1087f26 100755 --- a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh +++ b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh @@ -53,7 +53,17 @@ cmd_install() { fi echo "Tailscale: installing..." - curl -fsSL https://tailscale.com/install.sh | sh + # Use Acquire::Check-Valid-Until=false to handle container clock skew + curl -fsSL https://tailscale.com/install.sh | env APT_SYSTEMCTL_SKIP=1 sh -s -- --apt-options="-o Acquire::Check-Valid-Until=false" 2>&1 || { + # Fallback: install manually with clock-skew workaround + echo "Tailscale: install.sh failed, trying manual install..." + curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \ + | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null + echo "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/debian bookworm main" \ + | tee /etc/apt/sources.list.d/tailscale.list + apt-get -o Acquire::Check-Valid-Until=false update -qq + apt-get install -y -qq tailscale + } echo "Tailscale: install complete." } From d7e056e645d281b4bb5a08c76a13cba79afb1c45 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 17:15:49 +0100 Subject: [PATCH 200/215] fix(remote): install Tailscale from its own repo only to avoid clock skew apt-get update on all repos fails when container clock is skewed. Install directly from Tailscale repo with Dir::Etc::sourcelist to only update that single source, bypassing clock-skew on system repos. Refs: #70 --- .../.devcontainer/scripts/setup-tailscale.sh | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh index a1087f26..95f8cff3 100755 --- a/assets/workspace/.devcontainer/scripts/setup-tailscale.sh +++ b/assets/workspace/.devcontainer/scripts/setup-tailscale.sh @@ -53,17 +53,17 @@ cmd_install() { fi echo "Tailscale: installing..." - # Use Acquire::Check-Valid-Until=false to handle container clock skew - curl -fsSL https://tailscale.com/install.sh | env APT_SYSTEMCTL_SKIP=1 sh -s -- --apt-options="-o Acquire::Check-Valid-Until=false" 2>&1 || { - # Fallback: install manually with clock-skew workaround - echo "Tailscale: install.sh failed, trying manual install..." - curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \ - | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null - echo "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/debian bookworm main" \ - | tee /etc/apt/sources.list.d/tailscale.list - apt-get -o Acquire::Check-Valid-Until=false update -qq - apt-get install -y -qq tailscale - } + # Containers often have clock skew causing apt "Release file not valid yet". + # Install directly from Tailscale repo with clock-skew workaround. + curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \ + | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null + echo "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/debian bookworm main" \ + | tee /etc/apt/sources.list.d/tailscale.list + # Only update the tailscale repo (avoids clock-skew failures on other repos) + apt-get -o Acquire::Check-Valid-Until=false update \ + -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/tailscale.list \ + -o Dir::Etc::sourceparts=- -qq 2>/dev/null + apt-get install -y -qq tailscale echo "Tailscale: install complete." } From 77062e0f7ccee3d80279f61a9e4bca26221e1a50 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 17:18:10 +0100 Subject: [PATCH 201/215] fix(remote): handle apt clock skew in Claude Code Node.js install Same issue as Tailscale: container clock skew breaks apt-get update on system repos. Install Node.js from nodesource repo only. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 9455a006..93806c3e 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -53,7 +53,12 @@ cmd_install() { # Ensure Node.js LTS is available (npm required for install) if ! command -v npm &>/dev/null; then echo "Claude: installing Node.js LTS..." - curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - + # Use nodesource repo directly; containers may have clock skew + # that breaks apt-get update on system repos. + curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - 2>/dev/null || true + apt-get -o Acquire::Check-Valid-Until=false update \ + -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/nodesource.list \ + -o Dir::Etc::sourceparts=- -qq 2>/dev/null apt-get install -y nodejs fi From 3f8d177878d5c71fb871e7b78a4f7c973c8841ba Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 17:20:33 +0100 Subject: [PATCH 202/215] fix(remote): add nodesource repo directly to avoid clock skew The nodesource setup_lts.x script runs apt-get update on all repos, failing with clock skew. Add the repo GPG key and source list manually, then update only that repo. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 93806c3e..b79c0d2a 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -53,9 +53,13 @@ cmd_install() { # Ensure Node.js LTS is available (npm required for install) if ! command -v npm &>/dev/null; then echo "Claude: installing Node.js LTS..." - # Use nodesource repo directly; containers may have clock skew - # that breaks apt-get update on system repos. - curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - 2>/dev/null || true + # Add nodesource repo directly (the setup_lts.x script fails with clock skew) + local arch + arch=$(dpkg --print-architecture 2>/dev/null || echo "amd64") + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg 2>/dev/null + echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg arch=$arch] https://deb.nodesource.com/node_22.x nodistro main" \ + | tee /etc/apt/sources.list.d/nodesource.list >/dev/null apt-get -o Acquire::Check-Valid-Until=false update \ -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/nodesource.list \ -o Dir::Etc::sourceparts=- -qq 2>/dev/null From 22af8d890fd7a2e1e406eddcd798b5620f544125 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 17:23:28 +0100 Subject: [PATCH 203/215] fix(remote): add --yes to gpg dearmor to overwrite existing keyring gpg refuses to overwrite an existing file without --yes, causing the nodesource key import to fail on container re-creation. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index b79c0d2a..741b2307 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -57,7 +57,7 @@ cmd_install() { local arch arch=$(dpkg --print-architecture 2>/dev/null || echo "amd64") curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg 2>/dev/null + | gpg --dearmor --yes -o /usr/share/keyrings/nodesource.gpg 2>/dev/null echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg arch=$arch] https://deb.nodesource.com/node_22.x nodistro main" \ | tee /etc/apt/sources.list.d/nodesource.list >/dev/null apt-get -o Acquire::Check-Valid-Until=false update \ From dc9035aa2bbfa14e211410984588e4cba6884cf2 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 17:25:35 +0100 Subject: [PATCH 204/215] fix(remote): update all repos with clock-skew tolerance for Node.js deps nodesource nodejs depends on system python3, so we need all repos updated with Check-Valid-Until=false, not just the nodesource repo. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 741b2307..f4131b0a 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -60,9 +60,8 @@ cmd_install() { | gpg --dearmor --yes -o /usr/share/keyrings/nodesource.gpg 2>/dev/null echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg arch=$arch] https://deb.nodesource.com/node_22.x nodistro main" \ | tee /etc/apt/sources.list.d/nodesource.list >/dev/null - apt-get -o Acquire::Check-Valid-Until=false update \ - -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/nodesource.list \ - -o Dir::Etc::sourceparts=- -qq 2>/dev/null + # Update all repos with clock-skew tolerance (nodesource nodejs depends on system python3) + apt-get -o Acquire::Check-Valid-Until=false update -qq 2>/dev/null apt-get install -y nodejs fi From 5539fd02c48f6fe5b667ec56a536e0da612fbdb4 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 27 Mar 2026 17:28:07 +0100 Subject: [PATCH 205/215] fix(remote): ignore apt-get update exit 100 from clock-skewed repos apt returns 100 when any repo has clock issues even with Check-Valid-Until=false. The repos we need still update successfully so ignore the exit code. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index f4131b0a..d94e6073 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -61,7 +61,8 @@ cmd_install() { echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg arch=$arch] https://deb.nodesource.com/node_22.x nodistro main" \ | tee /etc/apt/sources.list.d/nodesource.list >/dev/null # Update all repos with clock-skew tolerance (nodesource nodejs depends on system python3) - apt-get -o Acquire::Check-Valid-Until=false update -qq 2>/dev/null + # apt returns 100 if any repo has clock issues — ignore since the repos we need still update + apt-get -o Acquire::Check-Valid-Until=false update -qq 2>/dev/null || true apt-get install -y nodejs fi From 6224e5f41d850f52e17e493b83ce084d0be5e628 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 18:26:30 +0200 Subject: [PATCH 206/215] feat(remote): add cl/cld aliases in container for both root and claude user Mirrors local dev environment aliases. cl=claude, cld=claude --dangerously-skip-permissions. Added to both claude user .bashrc and root .bashrc for consistent DX regardless of SSH user. Refs: #70 --- .../workspace/.devcontainer/scripts/setup-claude.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index d94e6073..7a275280 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -121,9 +121,21 @@ if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -f /proc/1/environ ]; then fi export PATH="/usr/local/bin:/usr/bin:/bin:/root/.cargo/bin:$PATH" + +# Claude toolkit aliases (mirrors local dev environment) +alias cl='claude' +alias cld='claude --dangerously-skip-permissions' BASHRC chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.bashrc" + # Add aliases to root's shell too (for ssh root@... sessions) + grep -q 'alias cl=' /root/.bashrc 2>/dev/null || cat >> /root/.bashrc << 'ROOT_ALIASES' + +# Claude toolkit aliases (mirrors local dev environment) +alias cl='claude' +alias cld='claude --dangerously-skip-permissions' +ROOT_ALIASES + echo "Claude: install complete. 'claude' auto-switches to non-root user when run as root." } From 896e130c3d7ad3bda5dfcab40b4c0a058b0b3a0f Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 18:55:09 +0200 Subject: [PATCH 207/215] fix(remote): source OAuth token from PID 1 in claude wrapper Tailscale SSH sessions don't inherit compose env vars. The wrapper now reads CLAUDE_CODE_OAUTH_TOKEN from /proc/1/environ when not in the current environment, matching the claude user .bashrc pattern. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 7a275280..5fd0a90f 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -98,6 +98,10 @@ cmd_install() { # claude-wrapper: auto-switch to claude user when running as root. # The real binary lives at claude-bin (same directory). REAL="$(dirname "$0")/claude-bin" +# Source OAuth token from container PID 1 if not in current env (e.g. Tailscale SSH) +if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -f /proc/1/environ ]; then + export CLAUDE_CODE_OAUTH_TOKEN=$(tr '\0' '\n' < /proc/1/environ 2>/dev/null | sed -n 's/^CLAUDE_CODE_OAUTH_TOKEN=//p') +fi if [ "$(id -u)" = "0" ]; then exec runuser -w CLAUDE_CODE_OAUTH_TOKEN -u claude -- "$REAL" --dangerously-skip-permissions "$@" fi From 93cfb4c081a060b11c844926bd5c5c2555b33471 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 18:57:48 +0200 Subject: [PATCH 208/215] fix(remote): allocate PTY in claude wrapper for interactive TUI runuser without --pty doesn't allocate a terminal, causing Claude Code's interactive TUI to exit immediately. Add --pty flag. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 5fd0a90f..d91b6275 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -103,7 +103,7 @@ if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -f /proc/1/environ ]; then export CLAUDE_CODE_OAUTH_TOKEN=$(tr '\0' '\n' < /proc/1/environ 2>/dev/null | sed -n 's/^CLAUDE_CODE_OAUTH_TOKEN=//p') fi if [ "$(id -u)" = "0" ]; then - exec runuser -w CLAUDE_CODE_OAUTH_TOKEN -u claude -- "$REAL" --dangerously-skip-permissions "$@" + exec runuser --pty -w CLAUDE_CODE_OAUTH_TOKEN -u claude -- "$REAL" --dangerously-skip-permissions "$@" fi exec "$REAL" "$@" WRAPPER From b3d837a46324a18fd00d22fc25d8d020a858463a Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 19:10:31 +0200 Subject: [PATCH 209/215] feat(remote): sync local Claude Code config into container on deploy Copies ~/.claude/{CLAUDE.md,settings.json,commands/} into the container's claude user home after lifecycle scripts run. Gives the remote Claude Code the same global instructions, permissions, and custom commands as the local dev environment. Refs: #70 --- assets/workspace/scripts/devc-remote.sh | 61 +++++++++++++++++++++++++ scripts/devc-remote.sh | 61 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/assets/workspace/scripts/devc-remote.sh b/assets/workspace/scripts/devc-remote.sh index 60553200..4886662c 100755 --- a/assets/workspace/scripts/devc-remote.sh +++ b/assets/workspace/scripts/devc-remote.sh @@ -441,6 +441,66 @@ INJECT_EOF log_success "Claude: OAuth token injected into remote compose" } +# ═══════════════════════════════════════════════════════════════════════════════ +# CLAUDE CODE CONFIG SYNC (copies local ~/.claude config into container) +# ═══════════════════════════════════════════════════════════════════════════════ + +sync_claude_config() { + local compose_full devc_dir + compose_full=$(compose_cmd_with_files) + devc_dir="$REMOTE_PATH/.devcontainer" + + # Skip if claude user doesn't exist in container + # shellcheck disable=SC2029 + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer id claude" &>/dev/null; then + return 0 + fi + + local claude_home="$HOME/.claude" + local synced=0 + + # Sync CLAUDE.md (global instructions) + if [[ -f "$claude_home/CLAUDE.md" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" \ + "cd $devc_dir && $compose_full exec -T devcontainer tee /home/claude/.claude/CLAUDE.md" \ + < "$claude_home/CLAUDE.md" >/dev/null 2>&1 + synced=1 + fi + + # Sync settings.json (permissions, env config) + if [[ -f "$claude_home/settings.json" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" \ + "cd $devc_dir && $compose_full exec -T devcontainer tee /home/claude/.claude/settings.json" \ + < "$claude_home/settings.json" >/dev/null 2>&1 + synced=1 + fi + + # Sync custom commands + if [[ -d "$claude_home/commands" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer mkdir -p /home/claude/.claude/commands" 2>/dev/null + for cmd_file in "$claude_home/commands"/*.md; do + [[ -f "$cmd_file" ]] || continue + local fname + fname=$(basename "$cmd_file") + # shellcheck disable=SC2029 + ssh "$SSH_HOST" \ + "cd $devc_dir && $compose_full exec -T devcontainer tee /home/claude/.claude/commands/$fname" \ + < "$cmd_file" >/dev/null 2>&1 + synced=1 + done + fi + + # Fix ownership + if [[ "$synced" == "1" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer chown -R claude:claude /home/claude/.claude" 2>/dev/null + log_success "Claude: config synced (CLAUDE.md, settings, commands)" + fi +} + check_ssh() { if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" true 2>/dev/null; then log_error "Cannot connect to $SSH_HOST. Check your SSH config and network." @@ -1082,6 +1142,7 @@ main() { remote_compose_up run_container_lifecycle + sync_claude_config case "$OPEN_MODE" in cursor|code) diff --git a/scripts/devc-remote.sh b/scripts/devc-remote.sh index 60553200..4886662c 100755 --- a/scripts/devc-remote.sh +++ b/scripts/devc-remote.sh @@ -441,6 +441,66 @@ INJECT_EOF log_success "Claude: OAuth token injected into remote compose" } +# ═══════════════════════════════════════════════════════════════════════════════ +# CLAUDE CODE CONFIG SYNC (copies local ~/.claude config into container) +# ═══════════════════════════════════════════════════════════════════════════════ + +sync_claude_config() { + local compose_full devc_dir + compose_full=$(compose_cmd_with_files) + devc_dir="$REMOTE_PATH/.devcontainer" + + # Skip if claude user doesn't exist in container + # shellcheck disable=SC2029 + if ! ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer id claude" &>/dev/null; then + return 0 + fi + + local claude_home="$HOME/.claude" + local synced=0 + + # Sync CLAUDE.md (global instructions) + if [[ -f "$claude_home/CLAUDE.md" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" \ + "cd $devc_dir && $compose_full exec -T devcontainer tee /home/claude/.claude/CLAUDE.md" \ + < "$claude_home/CLAUDE.md" >/dev/null 2>&1 + synced=1 + fi + + # Sync settings.json (permissions, env config) + if [[ -f "$claude_home/settings.json" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" \ + "cd $devc_dir && $compose_full exec -T devcontainer tee /home/claude/.claude/settings.json" \ + < "$claude_home/settings.json" >/dev/null 2>&1 + synced=1 + fi + + # Sync custom commands + if [[ -d "$claude_home/commands" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer mkdir -p /home/claude/.claude/commands" 2>/dev/null + for cmd_file in "$claude_home/commands"/*.md; do + [[ -f "$cmd_file" ]] || continue + local fname + fname=$(basename "$cmd_file") + # shellcheck disable=SC2029 + ssh "$SSH_HOST" \ + "cd $devc_dir && $compose_full exec -T devcontainer tee /home/claude/.claude/commands/$fname" \ + < "$cmd_file" >/dev/null 2>&1 + synced=1 + done + fi + + # Fix ownership + if [[ "$synced" == "1" ]]; then + # shellcheck disable=SC2029 + ssh "$SSH_HOST" "cd $devc_dir && $compose_full exec -T devcontainer chown -R claude:claude /home/claude/.claude" 2>/dev/null + log_success "Claude: config synced (CLAUDE.md, settings, commands)" + fi +} + check_ssh() { if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_HOST" true 2>/dev/null; then log_error "Cannot connect to $SSH_HOST. Check your SSH config and network." @@ -1082,6 +1142,7 @@ main() { remote_compose_up run_container_lifecycle + sync_claude_config case "$OPEN_MODE" in cursor|code) From 146e5c78d2c49974ad1cd63af284937dde470dcf Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 19:13:43 +0200 Subject: [PATCH 210/215] feat(remote): skip Claude Code onboarding screen in container Pre-create .claude.json with hasCompletedOnboarding=true so the interactive TUI doesn't show the login method selection screen when CLAUDE_CODE_OAUTH_TOKEN is already set. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index d91b6275..54522fb9 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -140,6 +140,12 @@ alias cl='claude' alias cld='claude --dangerously-skip-permissions' ROOT_ALIASES + # Pre-configure onboarding and theme so interactive TUI skips login screen + cat > "$CLAUDE_HOME/.claude/.claude.json" << 'ONBOARD' +{"hasCompletedOnboarding": true} +ONBOARD + chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.claude/.claude.json" + echo "Claude: install complete. 'claude' auto-switches to non-root user when run as root." } From 82124fbfd2e5d1d648d6421a57c4255258698a3d Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 19:23:32 +0200 Subject: [PATCH 211/215] fix(remote): add hasCompletedAuthFlow to skip login screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasCompletedOnboarding alone was not enough — Claude Code also checks hasCompletedAuthFlow before skipping the interactive login method selection screen. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 54522fb9..52701b5b 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -142,7 +142,7 @@ ROOT_ALIASES # Pre-configure onboarding and theme so interactive TUI skips login screen cat > "$CLAUDE_HOME/.claude/.claude.json" << 'ONBOARD' -{"hasCompletedOnboarding": true} +{"hasCompletedOnboarding": true, "hasCompletedAuthFlow": true} ONBOARD chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.claude/.claude.json" From d51f34798d27c54a5deeda6a94eee1940bc03654 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 19:24:33 +0200 Subject: [PATCH 212/215] feat(remote): pre-configure workspace trust and dangerous mode skip setup-claude.sh now creates settings.json with additionalDirectories pointing to the workspace project and skipDangerousModePermissionPrompt so Claude Code trusts the workspace without interactive prompts. Refs: #70 --- .../.devcontainer/scripts/setup-claude.sh | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 52701b5b..2cf5902f 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -140,12 +140,29 @@ alias cl='claude' alias cld='claude --dangerously-skip-permissions' ROOT_ALIASES - # Pre-configure onboarding and theme so interactive TUI skips login screen + # Pre-configure onboarding so interactive TUI skips login screen cat > "$CLAUDE_HOME/.claude/.claude.json" << 'ONBOARD' {"hasCompletedOnboarding": true, "hasCompletedAuthFlow": true} ONBOARD chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.claude/.claude.json" + # Pre-configure settings: trust workspace, skip dangerous mode prompt + # devc-remote.sh sync_claude_config may overwrite with user's full settings later + local ws_project + ws_project=$(find /workspace -maxdepth 1 -mindepth 1 -type d 2>/dev/null | head -1) + if [[ -z "$ws_project" ]]; then + ws_project="/workspace" + fi + cat > "$CLAUDE_HOME/.claude/settings.json" << SETTINGS +{ + "permissions": { + "additionalDirectories": ["${ws_project}", "/workspace"] + }, + "skipDangerousModePermissionPrompt": true +} +SETTINGS + chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.claude/settings.json" + echo "Claude: install complete. 'claude' auto-switches to non-root user when run as root." } From f2a69e1f5cd4a6ff5e94117b322028e8ce58d74d Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 19:27:58 +0200 Subject: [PATCH 213/215] feat(remote): add --add-dir \$PWD to claude wrapper for workspace trust Instead of pre-configuring additionalDirectories, pass the current working directory via --add-dir so Claude Code always trusts wherever it's launched from. Refs: #70 --- assets/workspace/.devcontainer/scripts/setup-claude.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 2cf5902f..91081084 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -103,9 +103,9 @@ if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -f /proc/1/environ ]; then export CLAUDE_CODE_OAUTH_TOKEN=$(tr '\0' '\n' < /proc/1/environ 2>/dev/null | sed -n 's/^CLAUDE_CODE_OAUTH_TOKEN=//p') fi if [ "$(id -u)" = "0" ]; then - exec runuser --pty -w CLAUDE_CODE_OAUTH_TOKEN -u claude -- "$REAL" --dangerously-skip-permissions "$@" + exec runuser --pty -w CLAUDE_CODE_OAUTH_TOKEN -u claude -- "$REAL" --dangerously-skip-permissions --add-dir "$PWD" "$@" fi -exec "$REAL" "$@" +exec "$REAL" --add-dir "$PWD" "$@" WRAPPER chmod +x "$real_claude" fi From a1b7fa0c48a0ac144c9c77df131764e018e374f9 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Sun, 29 Mar 2026 19:33:46 +0200 Subject: [PATCH 214/215] fix(remote): pre-set workspace trust in both .claude.json and project settings Claude Code checks hasTrustDialogAccepted in two places: the global .claude.json (projects dict keyed by path) and the per-project settings.json. Set both to avoid the interactive trust prompt on first run. Refs: #70 --- .../.devcontainer/scripts/setup-claude.sh | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/assets/workspace/.devcontainer/scripts/setup-claude.sh b/assets/workspace/.devcontainer/scripts/setup-claude.sh index 91081084..cc6345f0 100755 --- a/assets/workspace/.devcontainer/scripts/setup-claude.sh +++ b/assets/workspace/.devcontainer/scripts/setup-claude.sh @@ -140,19 +140,44 @@ alias cl='claude' alias cld='claude --dangerously-skip-permissions' ROOT_ALIASES - # Pre-configure onboarding so interactive TUI skips login screen - cat > "$CLAUDE_HOME/.claude/.claude.json" << 'ONBOARD' -{"hasCompletedOnboarding": true, "hasCompletedAuthFlow": true} -ONBOARD - chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.claude/.claude.json" - - # Pre-configure settings: trust workspace, skip dangerous mode prompt - # devc-remote.sh sync_claude_config may overwrite with user's full settings later + # Pre-configure onboarding + workspace trust so interactive TUI skips all prompts local ws_project ws_project=$(find /workspace -maxdepth 1 -mindepth 1 -type d 2>/dev/null | head -1) if [[ -z "$ws_project" ]]; then ws_project="/workspace" fi + + # .claude.json: onboarding state + per-project trust (keyed by absolute path) + python3 -c " +import json, pathlib +data = { + 'hasCompletedOnboarding': True, + 'hasCompletedAuthFlow': True, + 'projects': { + '${ws_project}': { + 'hasTrustDialogAccepted': True, + 'allowedTools': [], + 'hasCompletedProjectOnboarding': True + }, + '/workspace': { + 'hasTrustDialogAccepted': True, + 'allowedTools': [], + 'hasCompletedProjectOnboarding': True + } + } +} +pathlib.Path('$CLAUDE_HOME/.claude/.claude.json').write_text(json.dumps(data, indent=2)) +" + chown "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.claude/.claude.json" + + # Per-project settings.json (trust dialog flag in project dir too) + local project_key + project_key=$(echo "$ws_project" | tr '/' '-') + mkdir -p "$CLAUDE_HOME/.claude/projects/${project_key}" + echo '{"hasTrustDialogAccepted": true}' > "$CLAUDE_HOME/.claude/projects/${project_key}/settings.json" + chown -R "$CLAUDE_USER:$CLAUDE_USER" "$CLAUDE_HOME/.claude/projects/" + + # Global settings: trust workspace dirs, skip dangerous mode prompt cat > "$CLAUDE_HOME/.claude/settings.json" << SETTINGS { "permissions": { From a39b1cdc32a190b61af2a6b3dc84bccf353dbfb7 Mon Sep 17 00:00:00 2001 From: gerchowl Date: Fri, 15 May 2026 13:14:03 +0200 Subject: [PATCH 215/215] feat(image): bundle agent-CLI toolkit + TUI debug stack + Claude Code Refs: #70 apt block extension: expect/neovim/ripgrep/fd-find/bat/fzf + symlinks /usr/local/bin/{fd,bat} so Debian fdfind/batcat names do not trip up agents. Binary release downloads: eza/delta/lazygit/zoxide/starship/charm-freeze. Claude Code baked in: - install via official installer (3-attempt retry, mirrors cursor-agent) - symlinked to /usr/local/bin/claude - ENV IS_SANDBOX=1 lets --dangerously-skip-permissions bypass the uid-0 refusal cleanly (container is the trust boundary) - aliases cc=claude, cld='claude --dangerously-skip-permissions' Verified: image builds (linux/arm64), all 14 added tools resolve on PATH, IS_SANDBOX=1 set, aliases written to /root/.bashrc. --- Containerfile | 154 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 4 deletions(-) diff --git a/Containerfile b/Containerfile index b938c3c5..37238995 100644 --- a/Containerfile +++ b/Containerfile @@ -49,7 +49,14 @@ ENV DEBIAN_FRONTEND=noninteractive # = \ # CVE-XXXX-XXXXX # && apt-get clean && rm -rf /var/lib/apt/lists/* -# Install minimal system dependencies +# Install minimal system dependencies + the agent-CLI / TUI-debug toolkit. +# Bundle includes: tmux for script-driven multiplexing (claude can `new-session +# -d`, `send-keys`, `capture-pane -p`); expect for driving interactive prompts; +# neovim for in-container quick edits; ripgrep/fd-find/bat for fast search + +# pretty file inspection (claude reaches for these constantly); fzf for +# fuzzy completion in interactive shells. +# Note: on Debian, fd-find ships as `fdfind` and bat as `batcat` — we add +# convenience symlinks below so claude can call `fd` and `bat` directly. RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ git \ @@ -62,7 +69,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ podman \ rsync \ tmux \ - && apt-get clean && rm -rf /var/lib/apt/lists/* + expect \ + neovim \ + ripgrep \ + fd-find \ + bat \ + fzf \ + && apt-get clean && rm -rf /var/lib/apt/lists/* \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && ln -s /usr/bin/batcat /usr/local/bin/bat # Generate en_US.UTF-8 locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen @@ -147,6 +162,106 @@ RUN set -eux; \ rm -f "taplo-linux-${ARCH}"; \ taplo --version; +# Install eza binary (modern ls). Not in apt for bookworm — pull from release. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-musl ;; \ + arm64) ARCH=aarch64-unknown-linux-gnu ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + EZA_VERSION="$(curl -fsSL https://api.github.com/repos/eza-community/eza/releases/latest | sed -n 's/.*"tag_name": *"\(v[^"]*\)".*/\1/p')"; \ + URL="https://github.com/eza-community/eza/releases/download/${EZA_VERSION}"; \ + FILE="eza_${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o eza.tar.gz; \ + tar -xzf eza.tar.gz; \ + install -m 0755 eza /usr/local/bin/eza; \ + rm -f eza eza.tar.gz; \ + eza --version | head -2; + +# Install delta (git-diff prettifier). Not in apt for bookworm. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-gnu ;; \ + arm64) ARCH=aarch64-unknown-linux-gnu ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + DELTA_VERSION="$(curl -fsSL https://api.github.com/repos/dandavison/delta/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/dandavison/delta/releases/download/${DELTA_VERSION}"; \ + FILE="delta-${DELTA_VERSION}-${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o delta.tar.gz; \ + tar -xzf delta.tar.gz; \ + install -m 0755 "delta-${DELTA_VERSION}-${ARCH}/delta" /usr/local/bin/delta; \ + rm -rf "delta-${DELTA_VERSION}-${ARCH}" delta.tar.gz; \ + delta --version; + +# Install lazygit binary (git TUI). Useful inside the container for quick +# review during a CC session. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=Linux_x86_64 ;; \ + arm64) ARCH=Linux_arm64 ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + LG_VERSION="$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/jesseduffield/lazygit/releases/download/v${LG_VERSION}"; \ + FILE="lazygit_${LG_VERSION}_${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o lazygit.tar.gz; \ + tar -xzf lazygit.tar.gz lazygit; \ + install -m 0755 lazygit /usr/local/bin/lazygit; \ + rm -f lazygit lazygit.tar.gz; \ + lazygit --version | head -2; + +# Install zoxide binary (z/cd-by-frecency). Skip the install.sh wrapper — +# pull the release tarball directly so we don't bring in a shell-init step. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-musl ;; \ + arm64) ARCH=aarch64-unknown-linux-musl ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + ZX_VERSION="$(curl -fsSL https://api.github.com/repos/ajeetdsouza/zoxide/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/ajeetdsouza/zoxide/releases/download/v${ZX_VERSION}"; \ + FILE="zoxide-${ZX_VERSION}-${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o zoxide.tar.gz; \ + tar -xzf zoxide.tar.gz zoxide; \ + install -m 0755 zoxide /usr/local/bin/zoxide; \ + rm -f zoxide zoxide.tar.gz; \ + zoxide --version; + +# Install starship prompt binary. Note: starship only ships musl builds for +# linux, no gnu variant. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=x86_64-unknown-linux-musl ;; \ + arm64) ARCH=aarch64-unknown-linux-musl ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + SS_VERSION="$(curl -fsSL https://api.github.com/repos/starship/starship/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/starship/starship/releases/download/v${SS_VERSION}"; \ + FILE="starship-${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o starship.tar.gz; \ + tar -xzf starship.tar.gz; \ + install -m 0755 starship /usr/local/bin/starship; \ + rm -f starship starship.tar.gz; \ + starship --version | head -1; + +# Install charm-freeze (render terminal output as styled PNG — claude reads +# images natively, so this gives the agent a way to "see" colored TUI state). +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ARCH=Linux_x86_64 ;; \ + arm64) ARCH=Linux_arm64 ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ + esac; \ + FZ_VERSION="$(curl -fsSL https://api.github.com/repos/charmbracelet/freeze/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')"; \ + URL="https://github.com/charmbracelet/freeze/releases/download/v${FZ_VERSION}"; \ + FILE="freeze_${FZ_VERSION}_${ARCH}.tar.gz"; \ + curl -fsSL "${URL}/${FILE}" -o freeze.tar.gz; \ + tar -xzf freeze.tar.gz; \ + install -m 0755 "freeze_${FZ_VERSION}_${ARCH}/freeze" /usr/local/bin/freeze; \ + rm -rf "freeze_${FZ_VERSION}_${ARCH}" freeze.tar.gz; \ + freeze --version; + # Install cursor-agent CLI (installs to ~/.local/bin) ENV PATH="/root/.local/bin:${PATH}" RUN set -eux; \ @@ -291,8 +406,39 @@ ENV PRE_COMMIT_HOME="/opt/pre-commit-cache" ENV UV_PROJECT_ENVIRONMENT="/root/assets/workspace/.venv" ENV VIRTUAL_ENV="/root/assets/workspace/.venv" -# Create aliases for pre-commit -RUN echo 'alias precommit="pre-commit run"' >> /root/.bashrc +# IS_SANDBOX=1 lets `claude --dangerously-skip-permissions` bypass the uid-0 +# refusal that otherwise fires when claude detects it's running as root. The +# container is the trust boundary; this is the documented escape hatch. +ENV IS_SANDBOX="1" + +# Install Claude Code CLI globally so it's on PATH for every shell. Mirrors +# the cursor-agent install pattern above. Three retries because the install +# script pulls from a CDN that occasionally hiccups. +RUN set -eux; \ + INSTALLER="/tmp/claude-install.sh"; \ + for attempt in 1 2 3; do \ + if curl -fsSL https://claude.ai/install.sh -o "${INSTALLER}" \ + && bash "${INSTALLER}" \ + && [ -x /root/.local/bin/claude ]; then \ + ln -s /root/.local/bin/claude /usr/local/bin/claude; \ + claude --version; \ + rm -f "${INSTALLER}"; \ + exit 0; \ + fi; \ + rm -f "${INSTALLER}"; \ + echo "claude install attempt ${attempt} failed, retrying in 10s..."; \ + sleep 10; \ + done; \ + echo "WARNING: claude install failed after 3 attempts (CDN issue); skipping"; \ + echo "Install manually: curl -fsSL https://claude.ai/install.sh | bash"; + +# Create aliases for pre-commit + Claude Code variants. `cc` is the safer +# default (prompts for permissions); `cld` is the auto-approve variant for +# fully-isolated containers where the dev consciously trades off oversight +# for autonomy. Both rely on IS_SANDBOX=1 above. +RUN echo 'alias precommit="pre-commit run"' >> /root/.bashrc \ + && echo 'alias cc="claude"' >> /root/.bashrc \ + && echo 'alias cld="claude --dangerously-skip-permissions"' >> /root/.bashrc # Default command - interactive shell CMD ["/bin/bash"]